6.8 KiB
DocuSeal Deployment Template and Runbook
Spin up one self-hosted DocuSeal instance per legal entity. Reference live deployment: Core sign.itpropartner.com (container docuseal, image docuseal/docuseal:latest, bound 127.0.0.1:8091:3000, volume ./data:/data, env_file .env).
Hard isolation rule
One instance per legal entity. Do NOT multi-tenant a single DocuSeal across entities. Distinct legal entities require hard isolation: signatures, templates, and audit data must never mix. DocuSeal has a multitenant mode but it is not approved for cross-entity use here. Deploy a separate container, data volume, subdomain, and S3 bucket for each entity.
First target: Model Ortho. Future entities follow the same steps with a new slug, port, subdomain, and bucket.
Port remap
Core host port 3000 is occupied by browserless. Bind each instance to a unique loopback port, mapping to container port 3000:
- Core
sign.itpropartner.com:127.0.0.1:8091 - Model Ortho: next free port, e.g.
127.0.0.1:8092
List loopback listeners and pick an unused port:
ss -tln | grep 127.0.0.1
Prerequisites
- Docker and Compose on the host (Core today, app4 in future)
- DNS record for the sign subdomain
- Wasabi S3 bucket named
<entity>-legal(e.g.modelortho-legal) for attachments - SMTP relay credentials (netcup smarthost: only port 2525 works; 25/465/587 are blocked)
- Vaultwarden (
bwCLI) for credential storage
Steps
- Create the instance directory:
mkdir -p /root/docker/docuseal-<entity> && cd /root/docker/docuseal-<entity>
- Copy the templates and rename:
cp /root/projects/itpp-infrastructure/docs/infrastructure/docuseal/docker-compose.yml.template docker-compose.yml
cp /root/projects/itpp-infrastructure/docs/infrastructure/docuseal/.env.example .env
chmod 600 .env
-
Edit
docker-compose.yml. Replace__ENTITY_SLUG__with the entity slug and__HOST_PORT__with the unique loopback port. -
Fill
.env. Set HOST, the SMTP_* vars, SECRET_KEY_BASE, and (once the bucket exists) the S3_/AWS_ vars. Generate the secret:
openssl rand -hex 64
Record it in Vaultwarden (step 10). NEVER rotate it once the instance is live.
- Start the container:
docker compose up -d
- Verify boot. A clean instance returns HTTP 302 to /setup:
curl -sI http://127.0.0.1:<port> | head -1
A 500 means a stale encrypted DB under a different SECRET_KEY_BASE. Move data/ aside and boot clean:
mv data data.old-$(date +%s) && docker compose up -d
- Complete setup at
https://<host>/setup(owner email and password). If the submit button does not advance, run in the browser console:
document.querySelector('form').requestSubmit()
- Add the Caddy block for the sign subdomain:
<host> {
reverse_proxy 127.0.0.1:<port>
}
Then validate and reload:
caddy validate && systemctl reload caddy
- Point DNS (A record to origin; grey-cloud for
*.iamgmb.com). Verify:
curl -sI https://<host> | head -1
Expected: HTTP/2 200.
- Store admin credentials and the secret in Vaultwarden:
bw create item '{"type":1,"name":"DocuSeal <entity> admin","login":{"username":"owner@<entity>.com","password":"<admin password>","uris":[{"uri":"https://<host>"}]},"notes":"SECRET_KEY_BASE never rotate"}'
bw create item '{"type":2,"name":"DocuSeal <entity> SECRET_KEY_BASE","notes":"<secret key base>\nNEVER rotate on a live instance. ActiveRecord encryption root."}'
- Wire backup. Copy the Core script pattern (see Backup section) to
/root/.hermes/scripts/docuseal-<entity>-backup.shand add a Hermes cron job (daily).
SMTP env vars (exact names, verified against running container)
SMTP_ADDRESS: mail.itpropartner.com (or entity relay). NOT SMTP_HOST.SMTP_PORT: 2525 (netcup smarthost; 25/465/587 blocked).SMTP_USERNAME: noreply@. NOT SMTP_USER_NAME.SMTP_PASSWORD: relay password.SMTP_AUTHENTICATION: login (honored only when SMTP_PASSWORD present).SMTP_ENABLE_STARTTLS: true.SMTP_DOMAIN: .SMTP_SSL_VERIFY: true (false = VERIFY_NONE).
SMTP_FROM is INERT. DocuSeal does not read it. The mailer default from is hardcoded as DocuSeal <info@docuseal.com> in app/mailers/application_mailer.rb. Branding the From address requires a fork, which conflicts with the AGPL rule below.
S3/Wasabi attachment vars
S3 storage activates when S3_ATTACHMENTS_BUCKET is present. Exact vars read from config/storage.yml:
S3_ATTACHMENTS_BUCKET: -legal (presence triggers S3 storage).S3_ENDPOINT: s3.us-east-1.wasabisys.com (sets force_path_style true).AWS_ACCESS_KEY_ID: Wasabi access key.AWS_SECRET_ACCESS_KEY: Wasabi secret key.AWS_REGION: us-east-1 (default).ACTIVE_STORAGE_PUBLIC: false (optional).
Leave these unset to use local disk storage under ./data/attachments.
SECRET_KEY_BASE rule
SECRET_KEY_BASE is the encryption root for ActiveRecord encrypted columns in the SQLite DB. NEVER rotate it on a live instance. Rotating it breaks decryption (AEAD authentication tag verification failed, HTTP 500). Back it up with the instance and record it in Vaultwarden with a never-rotate note.
Backup script pattern
Runs locally on the host (no SSH). Archive the data dir, compose file, and .env so a restore is fully self-contained. Upload to the entity legal/ops bucket:
#!/bin/bash
set -euo pipefail
if [ -f /opt/awscli-venv/bin/activate ]; then source /opt/awscli-venv/bin/activate; fi
S3_BUCKET="s3://<entity>-legal/docuseal"
S3_ENDPOINT="--endpoint-url https://s3.us-east-1.wasabisys.com"
DOCUSEAL_DIR="/root/docker/docuseal-<entity>"
TSTAMP=$(date +%Y%m%d-%H%M%S)
WORKDIR=$(mktemp -d)
trap 'rm -rf "$WORKDIR"' EXIT
tar czf "$WORKDIR/docuseal-backup-$TSTAMP.tar.gz" -C "$DOCUSEAL_DIR" data docker-compose.yml .env
aws s3 cp $S3_ENDPOINT "$WORKDIR/docuseal-backup-$TSTAMP.tar.gz" "$S3_BUCKET/docuseal-backup-$TSTAMP.tar.gz"
Reference implementation: /root/.hermes/scripts/docuseal-backup.sh. Drive with a Hermes cron job.
AGPL-3.0 constraint
DocuSeal is AGPL-3.0. Run it UNMODIFIED and integrate via API only. Do not fork or patch the source. Consequences: no branded From address on email (see SMTP_FROM above), and all automation goes through DocuSeal's API rather than code changes.
docuseal:latest image note
Newer docuseal:latest bundles Redis and Sidekiq inside the single container. It may log a harmless memory overcommit warning (vm.overcommit_memory). This is cosmetic; the container runs fine. No separate Redis or Sidekiq services are needed.
Teardown
- Remove the Caddy block, then
caddy validateandsystemctl reload caddy. docker compose down(NOTstop:restart: alwaysresurrects stopped containers on reboot;downremoves the container while the bind-mounted data/ is preserved).- Delete the DNS record.
- Never delete an old encrypted DB. Preserve as
data.old-<epoch>.