Can I self-host DocuSign?
YES · ONE EVENING— setup effort 2 of 4YES — it's called DocuSeal. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $15 a month you stop paying DocuSign — $180 a year on the Personal plan.
Why people pay for DocuSign
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
DocuSign sells recognition. The counterparty has signed one before, their lawyer accepts the certificate of completion without a conversation, and the audit trail has survived enough disputes that nobody asks what it is. That is most of the product, and it is the part that has nothing to do with drawing a signature on a page.
| Plan | List price | What it buys |
|---|---|---|
| Personalthe plan this page prices against | $15/mo | Billed monthly, one user, 5 envelopes per month. About $10/user/month if paid annually. |
| Standard | $45/mo per seat | Billed monthly. About $25/user/month if paid annually. Unlimited envelopes through the web app, shared templates, reminders. |
| Business Pro | $65/mo per seat | Billed monthly. About $40/user/month if paid annually. Adds payment collection, bulk send and advanced fields. |
| Enhanced Plans | quote only | Quote only. The page says contact sales, with custom envelope limits. |
Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: low
Replaced by DocuSeal
One project, named before the prompt, so you know what you are about to install.
Send a PDF for signature and get it back signed, from one container, with the documents never leaving your disk.
The closest match on the part that actually matters day to day: you drag fields onto a PDF, send it, and the other person signs in a browser with no account. It keeps a completion audit trail and embeds the signature in the document. What it cannot hand you is DocuSign's name on that trail, which is exactly what the counterparty's lawyer is looking for, and the page says so rather than pretending otherwise.
The swap
You'd run
DocuSeal
ONE EVENING · ~90 min to running · 2048 MB RAM
DocuSign Personal · vendor list price · checked 2026-08-05 · source · confidence: low
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
One prompt, assuming Prompt Zero is done. It installs DocuSeal — read it before you paste it, which is the whole reason it is on the page instead of behind a download.
272 lines · 10,808 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install DocuSeal 3.1.7 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.
## 1. Preflight
If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask for both once and stop until the user
answers. The A record must already point here. In the same message ask three more things and
then stop asking: an SMTP relay hostname they already have, its port, and their username on it.
Do not ask for the relay credential; a STOP in step 3 has the user type that in themselves.
DocuSeal renders PDFs, so it needs 2048 MB of RAM available and 10 GB free on /srv, on amd64 or
arm64.
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If RAM is under 2048 MB or disk under 10 GB, print both and stop. If `dig +short` prints
nothing, stop: Caddy cannot certify a hostname that does not resolve.
## 2. Layout
The image creates a `docuseal` account with uid 2000 and runs as it, so `data` belongs to 2000.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/docuseal /srv/docuseal/backups
sudo install -d -m 750 -o 2000 -g 2000 /srv/docuseal/data
ls -la /srv/docuseal
```
Assert: `ls -la` shows `backups` owned by the login user and `data` owned by `2000`.
## 3. Secrets
One secret is generated here: `SECRET_KEY_BASE`. Do not print it, repeat it in your summary, or
log it. Replace `smtp.example.net`, `587` and `SMTP_USERNAME` with the step 1 values.
```bash
umask 077
cat > /srv/docuseal/.env <<EOF
SECRET_KEY_BASE=$(openssl rand -hex 64)
HOST=<DOMAIN>
FORCE_SSL=<DOMAIN>
SMTP_ADDRESS=smtp.example.net
SMTP_PORT=587
SMTP_DOMAIN=<DOMAIN>
SMTP_USERNAME=<ADMIN_EMAIL>
SMTP_ENABLE_STARTTLS=true
EOF
chmod 600 /srv/docuseal/.env
ls -l /srv/docuseal/.env
```
Assert: mode `-rw-------`. Tell the user one thing and make it stick: `SECRET_KEY_BASE` is also
what the record encryption keys are derived from, so changing it later makes every stored
signature unreadable. Step 8 backs it up with the database, and those two belong together.
STOP: tell the user to open their own terminal and run the block below on the server, so the
relay credential never enters this session. The third line waits with no prompt and echoes
nothing. Wait until they report what the last line printed.
```bash
umask 077
printf 'SMTP_PASSWORD=' >> /srv/docuseal/.env
read -rs && printf '%s\n' "$REPLY" >> /srv/docuseal/.env
unset REPLY
chmod 600 /srv/docuseal/.env
sudo awk -F= '/^SMTP_PASSWORD/ {print "recorded, length " length($2)}' /srv/docuseal/.env
```
Assert: a length greater than 0. Nothing printed means the line is missing.
## 4. compose.yml
```bash
cat > /srv/docuseal/compose.yml <<'EOF'
# DocuSeal · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# image, port, /data .. https://github.com/docusealco/docuseal/blob/master/README.md
# database selection .. https://github.com/docusealco/docuseal/blob/master/config/database.yml
# SMTP and FORCE_SSL .. https://github.com/docusealco/docuseal/blob/master/config/environments/production.rb
#
# One container. With DATABASE_URL unset the app uses SQLite at $WORKDIR/db.sqlite3,
# and the image already sets WORKDIR=/data/docuseal, so the single mount below
# holds the database, the uploaded documents and the signed PDFs. The image runs
# as uid 2000, hence the ownership in step 2. Tag and digest are the 3.1.7
# release read from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
docuseal:
image: docuseal/docuseal:3.1.7@sha256:a8ce45fc96cb0b8670021ba781966591a1d09efb70882c920a465e87e4fea800
container_name: docuseal
restart: unless-stopped
env_file: /srv/docuseal/.env
volumes:
# Database, attachments and signed documents, all in one directory.
- /srv/docuseal/data:/data/docuseal
ports:
# Loopback only. The Caddy that Prompt Zero installed on the host is the
# only thing that can reach this port, and 8089 never enters the firewall.
- "127.0.0.1:8089:3000"
EOF
cd /srv/docuseal && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Upstream's own compose file runs PostgreSQL beside the app;
this one does not, because a single-person install on SQLite is one process and one directory.
## 5. Caddy and TLS
Append the block below with `<DOMAIN>` replaced by the real hostname. Copy the file first: a
syntax error takes down every site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-docuseal
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# DocuSeal · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://github.com/docusealco/docuseal/blob/master/README.md
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Caddy runs under systemd. No Caddy container here.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# No X-Frame-Options here: DocuSeal publishes an embeddable signing form,
# and a blanket SAMEORIGIN would break it for anyone who uses that later.
#
# 8089 is the loopback port compose publishes; it is never in the firewall.
# FORCE_SSL in .env makes Rails trust the X-Forwarded-Proto Caddy sets, so
# the signing links it emails come out as https.
reverse_proxy 127.0.0.1:8089
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: both exit 0. If validate fails, restore /etc/caddy/Caddyfile.before-docuseal, reload,
and report what it objected to. Caddy gets the certificate on the first request.
## 6. Firewall
Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured they
change nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp
is HTTP/3. 8089 stays closed, bound to 127.0.0.1, and nothing opens 25, 465 or 587: this box
sends through the user's relay and accepts no mail. Assert: `ufw status verbose` prints
`Status: active`, shows 80, 443/tcp and 443/udp, and no 8089.
## 7. Start and verify
Rails migrates as it boots, so the first start is the slow one. Do not follow redirects in
these checks: the redirect is the signal.
```bash
cd /srv/docuseal
docker compose pull
docker compose up -d
sleep 45
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/up
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/setup
```
Assert: both print `200`, and print what you received. `/up` is the Rails health route and
answers only once the app has booted; `/setup` is the first-run form and answers 200 exactly
while no user exists. If either misses, stop, run `docker compose logs --tail 40 docuseal`, and
name the likely earlier step. A running container is not success.
The first screen at https://<DOMAIN> redirects to the setup form, which asks for a name, an
email address and a password for the first account.
STOP: tell the user to open https://<DOMAIN>/setup, create that first account with
<ADMIN_EMAIL>, and confirm when they are signed in. Wait. Until they do, whoever finds the
hostname can create it instead.
Now prove the setup form has closed itself:
```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/setup
```
Assert: this prints `302`, not `200`. DocuSeal redirects /setup to the sign-in page once a user
exists, and that is the security assert here. If it still prints `200`, the account was not
created and the hostname is standing open.
## 8. First backup and restore
Take the backup now, before the first real document. Stop first: SQLite copied mid-write is not
a backup.
```bash
cd /srv/docuseal
docker compose stop
sudo tar -C /srv/docuseal -czf /srv/docuseal/backups/docuseal-$(date +%F).tar.gz data .env
docker compose start
ls -lh /srv/docuseal/backups/
```
Assert: the archive exists and is non-empty. Print its size. `data` and `.env` travel together:
the documents are in `data`, and the key that decrypts the encrypted columns comes from
`SECRET_KEY_BASE` in `.env`. A backup on the same disk is not one, so run this from the user's
machine:
```bash
mkdir -p ~/backups/docuseal
scp vps:/srv/docuseal/backups/*.tar.gz ~/backups/docuseal/
```
To restore: `docker compose down`, `sudo rm -rf /srv/docuseal/data`,
`sudo tar -C /srv/docuseal -xzf` the archive, then `docker compose up -d`. Those four commands
are the whole disaster plan. A signed agreement is a document somebody else is relying on, so
this archive belongs somewhere the user would still have after a fire.
## 9. Updating later
New versions are at https://github.com/docusealco/docuseal/releases. Back up first, then edit
the image line in /srv/docuseal/compose.yml to the new tag and digest. Rails migrates on the
next boot, so read the log until it settles before calling this done.
```bash
cd /srv/docuseal
docker compose pull
docker compose up -d
docker compose logs --tail 20 docuseal
```
## 10. What will probably go wrong
The signing invitation will not arrive, and the install will look fine while it happens.
Hetzner blocks outbound 25, 465 and 587 on new cloud accounts until you open a support ticket,
and DigitalOcean restricts them too. Worse here than elsewhere: DocuSeal is configured not to
raise delivery errors, so a mail that never leaves the box produces a cheerful green interface
and silence at the other end. I only found it by sending myself a test document and watching
nothing happen. Run `docker compose logs --tail 40 docuseal` and look for a timeout to the
relay host before touching anything else.
## 11. Out of scope
- Do not add PostgreSQL. SQLite is why this is one container with one directory to copy.
- Do not change `SECRET_KEY_BASE` after the first boot. Record encryption keys derive from it,
and rotating it makes stored signatures unreadable.
- Do not configure S3, GCS or Azure attachment storage. Documents stay on this disk.
- Do not enable the embedded signing form or the API integrations. Both carry a security
surface, and they belong to the user, not to this install.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing DocuSeal 3.1.7 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise. Replace `<DOMAIN>` with the hostname whose A record already points at the
box, and `<ADMIN_EMAIL>` with the address your first account will use.
Have three things to hand before you start: the hostname of an SMTP relay you already have, its
port, and your username on it. DocuSeal invites signers by email, so a document you cannot send
is a document you cannot get signed.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP address on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it at your DNS
provider, wait a minute, and run `dig +short <DOMAIN>` again. Caddy cannot get a certificate
for a hostname that does not resolve, and failed attempts count against a rate limit you cannot
see. Under 2 GB of RAM the PDF rendering is what falls over, usually on the third document.
## 2. Layout
The image creates a `docuseal` account with uid 2000 and runs as it, so `data` belongs to 2000
and not to you.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/docuseal /srv/docuseal/backups
sudo install -d -m 750 -o 2000 -g 2000 /srv/docuseal/data
ls -la /srv/docuseal
```
You should see: `backups` owned by your own username, and `data` owned by `2000`.
If you do not: `data` owned by you means the second command did not run, and the container will
fail to create its database with a permission error that mentions nothing about ownership. Run
the second line again on its own.
## 3. Secrets
One secret is generated here: `SECRET_KEY_BASE`. Before you paste, edit three lines in the
block: `SMTP_ADDRESS` to your relay's hostname, `SMTP_PORT` to its port, and `SMTP_USERNAME` to
your username on it if that is not your email address.
```bash
umask 077
cat > /srv/docuseal/.env <<EOF
SECRET_KEY_BASE=$(openssl rand -hex 64)
HOST=<DOMAIN>
FORCE_SSL=<DOMAIN>
SMTP_ADDRESS=smtp.example.net
SMTP_PORT=587
SMTP_DOMAIN=<DOMAIN>
SMTP_USERNAME=<ADMIN_EMAIL>
SMTP_ENABLE_STARTTLS=true
EOF
chmod 600 /srv/docuseal/.env
ls -l /srv/docuseal/.env
```
You should see: mode `-rw-------`, your own username twice, and the path.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines one at a time in different shells. Run `chmod 600 /srv/docuseal/.env` and
carry on.
One thing about `SECRET_KEY_BASE` that is worth reading twice: it is not only a session key,
it is what the record encryption keys are derived from. If you ever regenerate it, every stored
signature and configuration value becomes unreadable. It gets backed up in step 8 with the
database, and those two belong together forever.
Now add the relay credential. These five lines never echo it and never put it in your shell
history:
```bash
umask 077
printf 'SMTP_PASSWORD=' >> /srv/docuseal/.env
read -rs && printf '%s\n' "$REPLY" >> /srv/docuseal/.env
unset REPLY
chmod 600 /srv/docuseal/.env
```
You should see: nothing at all after the third line. The cursor sits there waiting. Type or
paste the credential, press Return, and you are back at a prompt. Then check the shape of it
without reading it back:
```bash
sudo awk -F= '/^SMTP_PASSWORD/ {print "recorded, length " length($2)}' /srv/docuseal/.env
```
You should see: `recorded, length` and a number greater than zero.
If you do not: no output means the line is missing, so run the five-line block again. A length
of `0` means you pressed Return before typing anything: edit the file with
`sudo nano /srv/docuseal/.env` and fix that one line.
Do not paste the contents of that file, the relay credential, or any command output containing
it into this chat window. Nothing in the rest of this guide needs it, and once it is in a
transcript it is somebody else's copy.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/docuseal/compose.yml <<'EOF'
# DocuSeal · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# image, port, /data .. https://github.com/docusealco/docuseal/blob/master/README.md
# database selection .. https://github.com/docusealco/docuseal/blob/master/config/database.yml
# SMTP and FORCE_SSL .. https://github.com/docusealco/docuseal/blob/master/config/environments/production.rb
#
# One container. With DATABASE_URL unset the app uses SQLite at $WORKDIR/db.sqlite3,
# and the image already sets WORKDIR=/data/docuseal, so the single mount below
# holds the database, the uploaded documents and the signed PDFs. The image runs
# as uid 2000, hence the ownership in step 2. Tag and digest are the 3.1.7
# release read from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
docuseal:
image: docuseal/docuseal:3.1.7@sha256:a8ce45fc96cb0b8670021ba781966591a1d09efb70882c920a465e87e4fea800
container_name: docuseal
restart: unless-stopped
env_file: /srv/docuseal/.env
volumes:
# Database, attachments and signed documents, all in one directory.
- /srv/docuseal/data:/data/docuseal
ports:
# Loopback only. The Caddy that Prompt Zero installed on the host is the
# only thing that can reach this port, and 8089 never enters the firewall.
- "127.0.0.1:8089:3000"
EOF
cd /srv/docuseal && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/docuseal/.env not found` means step 3 did not write the file, so
go back. `services must be a mapping` means the indentation was lost between the page and your
terminal: run `rm /srv/docuseal/compose.yml` and paste the block again in one go.
Upstream's own example compose file runs PostgreSQL beside the app. This one does not, because
a single-person install on SQLite is one process to operate and one directory to copy.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-docuseal
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# DocuSeal · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://github.com/docusealco/docuseal/blob/master/README.md
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Caddy runs under systemd. No Caddy container here.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# No X-Frame-Options here: DocuSeal publishes an embeddable signing form,
# and a blanket SAMEORIGIN would break it for anyone who uses that later.
#
# 8089 is the loopback port compose publishes; it is never in the firewall.
# FORCE_SSL in .env makes Rails trust the X-Forwarded-Proto Caddy sets, so
# the signing links it emails come out as https.
reverse_proxy 127.0.0.1:8089
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-docuseal /etc/caddy/Caddyfile`, reload,
and paste again, checking that the blank line from the second command really landed. Caddy asks
Let's Encrypt for the certificate on the first request to your hostname and renews it on its
own, so there is nothing to schedule.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8089`, `25`, `465` or `587`.
If you do not: a rule for `8089` from an earlier attempt should go, with
`sudo ufw delete allow 8089`. 8089 is bound to 127.0.0.1 by the compose file, so nothing
outside the machine can reach it. The mail ports stay closed because this box sends outbound
through your relay and never accepts mail.
## 7. Start and verify
Rails migrates as it boots, so the first start is slow. Do not add `-L` to these commands: the
redirect is the signal you are looking for.
```bash
cd /srv/docuseal
docker compose pull
docker compose up -d
sleep 45
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/up
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/setup
```
You should see: `200` twice. `/up` is the Rails health route and answers only once the app has
booted. `/setup` is the first-run form, and it answers 200 exactly while no user exists.
If you do not: `000` or `502` means the certificate is not there yet, so run
`sudo journalctl -u caddy -n 30`. If `/up` still misses after another minute, run
`docker compose logs --tail 40 docuseal`: a permission error on `/data/docuseal` is step 2 done
wrong, and a container that vanished was killed for running out of memory.
A container listed in `docker ps` is not proof of anything. The two checks above are.
Now open https://<DOMAIN>/setup in a browser and create your account. Do it before you make
coffee: until that account exists, anyone who finds this hostname can create it instead, and
they would own every document you later put here. Then prove the form closed itself:
```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/setup
```
You should see: `302`. DocuSeal redirects /setup to the sign-in page once a user exists.
If you do not: `200` means no account was created, and your hostname is standing open. Go back
to the browser and finish the form.
## 8. First backup and restore
Do this before the first real document, so you find out now whether it works. The stop matters:
a SQLite file copied mid-write is not a backup.
```bash
cd /srv/docuseal
docker compose stop
sudo tar -C /srv/docuseal -czf /srv/docuseal/backups/docuseal-$(date +%F).tar.gz data .env
docker compose start
ls -lh /srv/docuseal/backups/
```
You should see: one `.tar.gz` file, a few hundred kilobytes on a fresh install.
If you do not: `tar: data: Cannot open` means the `cd` did not happen. A size of `45` bytes
means tar wrote an empty archive because the paths were wrong, so check
`sudo ls /srv/docuseal/data` before you trust it.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not on
the server:
```bash
mkdir -p ~/backups/docuseal
scp vps:/srv/docuseal/backups/*.tar.gz ~/backups/docuseal/
```
You should see: one file copied, and the same file listed by `ls -lh ~/backups/docuseal/`.
If you do not: `Permission denied (publickey)` means you ran it on the server by mistake. The
`vps:` prefix only means something on your own machine.
Now prove the restore, because a backup you have never restored is a guess:
```bash
cd /srv/docuseal
docker compose down
sudo rm -rf /srv/docuseal/data
sudo tar -C /srv/docuseal -xzf /srv/docuseal/backups/docuseal-$(date +%F).tar.gz
docker compose up -d
```
You should see: `Created` and `Started`, then after a minute a sign-in page at https://<DOMAIN>
that still accepts your account.
If you do not: a page that has turned back into the setup form means the archive did not
contain the database. Stop and go back to the tar step. If you can sign in but every document
shows an error, the archive had `data` without `.env`, which is the one mistake this install
cannot recover from. Those four commands are the whole disaster plan, and you have now run them
once.
## 9. Updating later
New versions are at https://github.com/docusealco/docuseal/releases. Take a backup first, then
edit the `image:` line in /srv/docuseal/compose.yml to the new tag and its digest.
```bash
cd /srv/docuseal
docker compose pull
docker compose up -d
docker compose logs --tail 20 docuseal
```
You should see: `Recreated`, then migration lines, then Puma booting and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Rails migrates
on the next boot, so read that log and load the page once before you call the update done.
## 10. What will probably go wrong
The signing invitation will not arrive, and the install will look fine while it happens.
Hetzner blocks outbound 25, 465 and 587 on new cloud accounts until you open a support ticket,
and DigitalOcean restricts them too. What makes it worse here than elsewhere is that DocuSeal
is configured not to raise delivery errors, so the interface stays green and cheerful while
nothing leaves the box. I only found it by sending myself a test document and watching nothing
happen. Run `docker compose logs --tail 40 docuseal` and look for a timeout to your relay host
before you touch anything else.
## 11. Out of scope
- Do not add PostgreSQL. SQLite is why this is one container with one directory to copy.
- Do not change `SECRET_KEY_BASE` after the first boot. The record encryption keys are derived
from it, and rotating it makes stored signatures unreadable.
- Do not configure S3, GCS or Azure attachment storage. Documents stay on this disk.
- Do not enable the embedded signing form or the API integrations. Both are decisions with a
security surface, and they are yours to make later.agent-readable mirror: /self-host/docusign.md
The files, if you'd rather do it yourself
The same install with no agent involved: three files, in the order you'd use them. The prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs.
compose.ymlthe services, pinned27 lines
# DocuSeal · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# image, port, /data .. https://github.com/docusealco/docuseal/blob/master/README.md
# database selection .. https://github.com/docusealco/docuseal/blob/master/config/database.yml
# SMTP and FORCE_SSL .. https://github.com/docusealco/docuseal/blob/master/config/environments/production.rb
#
# One container. With DATABASE_URL unset the app uses SQLite at $WORKDIR/db.sqlite3,
# and the image already sets WORKDIR=/data/docuseal, so the single mount below
# holds the database, the uploaded documents and the signed PDFs. The image runs
# as uid 2000, hence the ownership in step 2. Tag and digest are the 3.1.7
# release read from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
docuseal:
image: docuseal/docuseal:3.1.7@sha256:a8ce45fc96cb0b8670021ba781966591a1d09efb70882c920a465e87e4fea800
container_name: docuseal
restart: unless-stopped
env_file: /srv/docuseal/.env
volumes:
# Database, attachments and signed documents, all in one directory.
- /srv/docuseal/data:/data/docuseal
ports:
# Loopback only. The Caddy that Prompt Zero installed on the host is the
# only thing that can reach this port, and 8089 never enters the firewall.
- "127.0.0.1:8089:3000"Caddyfilethe hostname and TLS26 lines
# DocuSeal · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://github.com/docusealco/docuseal/blob/master/README.md
#
# Append this to /etc/caddy/Caddyfile, with <DOMAIN> replaced by the hostname
# pointed at this box. Caddy runs under systemd. No Caddy container here.
<DOMAIN> {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# No X-Frame-Options here: DocuSeal publishes an embeddable signing form,
# and a blanket SAMEORIGIN would break it for anyone who uses that later.
#
# 8089 is the loopback port compose publishes; it is never in the firewall.
# FORCE_SSL in .env makes Rails trust the X-Forwarded-Proto Caddy sets, so
# the signing links it emails come out as https.
reverse_proxy 127.0.0.1:8089
}install.shthe same install, no agent169 lines
#!/usr/bin/env bash
# DocuSeal · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=sign.example.com ADMIN_EMAIL=you@example.com \
# RELAY_HOST=smtp.example.net RELAY_PORT=587 RELAY_USER=you@example.com ./install.sh
#
# It prompts once, silently, for the relay credential. That value is never
# echoed and never reaches your shell history.
#
# Authored by caniselfhostit from the upstream documentation:
# https://github.com/docusealco/docuseal/blob/master/README.md
# https://github.com/docusealco/docuseal/blob/master/config/database.yml
# https://github.com/docusealco/docuseal/blob/master/config/environments/production.rb
# https://caddyserver.com/docs/automatic-https
#
# One secret is generated here: SECRET_KEY_BASE. It is written to
# /srv/docuseal/.env with mode 600 and never printed. Do not change it later:
# the record encryption keys are derived from it.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/docuseal}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
RELAY_HOST="${RELAY_HOST:-}"
RELAY_PORT="${RELAY_PORT:-587}"
RELAY_USER="${RELAY_USER:-$ADMIN_EMAIL}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. sign.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address the first account will use"
[ -n "$RELAY_HOST" ] || die "set RELAY_HOST to an SMTP relay you already have. Signing invitations are email."
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; this install wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# The image creates a docuseal account with uid 2000 and runs as it.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 2000 -g 2000 "$APP_DIR/data"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. One generated secret, plus the relay credential you already own ------
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
SECRET_KEY_BASE=$(openssl rand -hex 64)
HOST=${DOMAIN_HOST}
FORCE_SSL=${DOMAIN_HOST}
SMTP_ADDRESS=${RELAY_HOST}
SMTP_PORT=${RELAY_PORT}
SMTP_DOMAIN=${DOMAIN_HOST}
SMTP_USERNAME=${RELAY_USER}
SMTP_ENABLE_STARTTLS=true
ENVFILE
printf 'SMTP_PASSWORD=' >> "$APP_DIR/.env"
printf 'Relay credential for %s (input is hidden): ' "$RELAY_USER" > /dev/tty
read -rs relay_value < /dev/tty
printf '\n' > /dev/tty
printf '%s\n' "$relay_value" >> "$APP_DIR/.env"
unset relay_value
chmod 600 "$APP_DIR/.env"
umask 022
fi
sudo awk -F= '/^SMTP_PASSWORD/ {print "relay credential recorded, length " length($2)}' "$APP_DIR/.env"
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-docuseal"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and 8089 is not one of them -------------------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8089 stays closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it and prove it works ------------------------------------------
#
# Rails migrates on the first boot, so this takes a while the first time.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/up (Rails is migrating, Caddy is getting a certificate)"
for _ in $(seq 1 40); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/up" || true)"
[ "$code" = "200" ] && break
sleep 5
done
[ "${code:-}" = "200" ] || die "/up answered ${code:-nothing}. Check: docker compose logs --tail 40 docuseal"
setup_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/setup" || true)"
[ "$setup_code" = "200" ] || die "/setup answered ${setup_code}, so the first-run form is not there. Check the logs."
# --- 7. Create the first account, then prove the form closed -----------------
cat <<-SETUP
Open https://${DOMAIN_HOST}/setup now and create the first account with
${ADMIN_EMAIL}. Until you do, whoever finds this hostname can create it
instead, and they would own every document you later put here.
SETUP
printf 'Press Return once you are signed in. '
read -r _
setup_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/setup" || true)"
echo "==> /setup now answers ${setup_code}"
[ "$setup_code" != "200" ] || die "/setup still answers 200, so no account exists yet. Create it before going on."
# --- 8. The first backup, before day one ends --------------------------------
#
# Stopped, then copied. A SQLite file captured mid-write is not a backup.
docker compose stop
sudo tar -C "$APP_DIR" -czf "$APP_DIR/backups/docuseal-$(date +%Y%m%d-%H%M%S).tar.gz" data .env
docker compose start
ls -lh "$APP_DIR/backups/"
cat <<-DONE
DocuSeal is running at https://${DOMAIN_HOST}/
1. Send yourself a one-field document and sign it. If the invitation does
not arrive, your relay is the problem: DocuSeal is set not to raise
delivery errors, so the interface stays green while nothing is sent.
2. data/ and .env travel together. The documents are in data/, and the key
that decrypts the encrypted columns is derived from SECRET_KEY_BASE in
.env. One without the other is not a restore.
3. First backup written to $APP_DIR/backups. It is on the same disk as
the data, which is not a backup. Copy it somewhere else tonight, and
somewhere you would still have after a fire: these are agreements other
people are relying on.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel DocuSign.
- You are not buying recognition. The counterparty's lawyer has seen a DocuSign completion certificate before and has not seen yours. For a freelance contract or a household form that is fine; for something a bank or a court will examine, budget for a conversation you would not have had otherwise.
- Email is the product. A signing invitation is an email, and new Hetzner and DigitalOcean accounts block outbound mail ports until you ask them not to. DocuSeal is also configured not to raise delivery errors, so a broken relay looks exactly like a working install until you notice nobody ever signed anything.
- You own the backups, and two files have to travel together. The documents and the database are under /srv/docuseal/data, and the key that decrypts the encrypted columns is derived from SECRET_KEY_BASE in .env. Copy one without the other and the restore is unreadable.
- AGPL-3.0 with additional terms in a separate file, and a paid Pro edition upstream. Bulk send, conditional fields and single sign-on live there, not in the image this install runs, and the boundary can move between releases.
- SQLite is the honest choice for one person sending a handful of agreements. A team sending hundreds a week should be on PostgreSQL, which upstream's own compose file uses, and this install does not set that up.
Where this came from
“It is possible to use PostgreSQL or MySQL databases by specifying the DATABASE_URL environment variable.”
- With DATABASE_URL unset, DocuSeal falls back to SQLite at $WORKDIR/db.sqlite3, and the image sets WORKDIR to /data/docuseal, so one mounted directory holds the database and the documents. source
- SMTP is configured entirely through environment variables (SMTP_ADDRESS, SMTP_PORT, SMTP_DOMAIN, SMTP_USERNAME, SMTP_PASSWORD), and delivery errors are deliberately not raised, so a mail that never leaves the box produces no visible error. source
- The container runs as a docuseal account with uid 2000 and listens on port 3000. source
- /up is the Rails health route, and /setup stops answering with 200 and redirects to the sign-in page as soon as the first user exists. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host DocuSign?
Not DocuSign itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is DocuSeal. Send a PDF for signature and get it back signed, from one container, with the documents never leaving your disk. The install is one evening: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces DocuSign?
DocuSeal. Send a PDF for signature and get it back signed, from one container, with the documents never leaving your disk. The closest match on the part that actually matters day to day: you drag fields onto a PDF, send it, and the other person signs in a browser with no account. It keeps a completion audit trail and embeds the signature in the document. What it cannot hand you is DocuSign's name on that trail, which is exactly what the counterparty's lawyer is looking for, and the page says so rather than pretending otherwise. DocuSeal is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to DocuSign?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. DocuSeal itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: DocuSign Personal, $15/mo — $180 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the DocuSeal install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Content last checked 2026-08-05. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.