# Can I self-host DocuSign?

**YES** — it's called DocuSeal. ONE EVENING setup · ~1.5 hours to running · 2 GB RAM minimum · $15/mo you stop paying ($180/yr on the Personal plan).

DocuSeal authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/docusign/

## Install prompt (Claude Code)

````text
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.
````

## Chat fallback

````text
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.
````

## docker-compose.yml

```yaml
# 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"
```

## Caddyfile

```text
# 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.sh

```bash
#!/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.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/docusign/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
