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

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install DocuSeal 3.1.7 on this computer, reachable at http://localhost:8089, with
everything it owns under ~/selfhost/docuseal.

## 1. Preflight

Say this to the user before anything is installed, and do not soften it. Every signing
link DocuSeal generates here points at localhost, so it opens on this computer only. A
person emailed a signature request cannot open it. This install is for filling and signing
the user's own documents; if they want to send agreements to other people, stop here and
tell them the server path does that.

Detect the operating system and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size of/ {p=$8} /Pages (free|inactive)/ {f+=$NF} END {print f*p/1048576 " MB available"}' ;;
  Linux) free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "[math]::Round((Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory/1024)" ;;
esac
df -h ~
```

`uname -s` prints `Darwin` on macOS, `Linux` on Linux, and something starting `MINGW` or
`MSYS` in Git Bash on Windows. Every branch below keys off that answer. Each branch prints
the MB available right now, not the memory the machine shipped with.

DocuSeal renders PDFs, so it needs 2048 MB of RAM available to it and 10 GB free on the
home directory's disk. The 3.1.7 image is published for amd64 and arm64, so Intel, Apple
Silicon and x86-64 Windows are covered. Under either floor, print both numbers and stop.
Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Tell the user, in one sentence, that membership of the `docker` group is root-equivalent
  on this machine, and that the group change lands at their next login: log out and back
  in before continuing.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

Everything this install owns lives under one directory, and nothing is written outside it.

```bash
mkdir -p ~/selfhost/docuseal/data ~/selfhost/docuseal/backups
ls -la ~/selfhost/docuseal
```

The image creates a `docuseal` account with uid 2000 and runs as it, so on Linux the data
directory must belong to 2000 or Rails cannot create its database:

```bash
case "$(uname -s)" in
  Linux) sudo chown -R 2000:2000 ~/selfhost/docuseal/data ;;
esac
```

On macOS and Windows that branch does not run and does not apply: the container lives in
Docker Desktop's VM, whose file sharing maps ownership across the boundary itself.

Assert: `ls -la ~/selfhost/docuseal` shows `data` and `backups`, and on Linux `data` is
owned by `2000`.

## 4. Secrets

One secret is generated here: `SECRET_KEY_BASE`. Do not print it, do not repeat it in your
summary, and do not put it in any log line.

```bash
umask 077
cat > ~/selfhost/docuseal/.env <<EOF
SECRET_KEY_BASE=$(openssl rand -hex 64)
HOST=localhost:8089
EOF
chmod 600 ~/selfhost/docuseal/.env
umask 022
ls -l ~/selfhost/docuseal/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so this block is the same everywhere.

On Windows those mode bits are advisory: NTFS keeps its own permissions. The real boundary
there is the user's own Windows account, which on a single-user machine is the whole
boundary. Say that to the user; do not imply otherwise.

`HOST` carries the port because DocuSeal builds absolute links from it, and a link that
omits `:8089` arrives nowhere. The server install also sets `FORCE_SSL`; this file leaves
it out: a redirect to https on a machine with no certificate never loads.

Tell the user: `SECRET_KEY_BASE` is what the record encryption keys derive from, so
changing it later makes every stored signature unreadable. Step 8 backs it up with the
database, and those two belong together. They read it with
`grep SECRET_KEY_BASE ~/selfhost/docuseal/.env`.

## 5. compose.yml

```bash
cat > ~/selfhost/docuseal/compose.yml <<'EOF'
# DocuSeal · the deterministic fallback for the local path. 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
#   HOST and FORCE_SSL .. https://github.com/docusealco/docuseal/blob/master/config/environments/production.rb
#
# Every host path here is relative to ~/selfhost/docuseal/, where this file
# lives, which is what lets one file work on macOS, Linux and Windows.
# 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; on Linux that ownership is set by hand in step 3, and on macOS and
# Windows Docker Desktop's file sharing handles it. No FORCE_SSL in the .env this
# reads: there is no certificate here to redirect to. 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: ./.env
    volumes:
      # Database, attachments and signed documents, all in one directory.
      - ./data:/data/docuseal
    ports:
      # Loopback only. Nothing outside this computer can reach 8089, not the
      # router and not the user's own phone on the same wifi. That is the point
      # of this path.
      - "127.0.0.1:8089:3000"
EOF
cd ~/selfhost/docuseal && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container serves on 3000 inside itself; 8089 is
bound to 127.0.0.1.

## 6. Nothing is public

No reverse proxy, no certificate and no firewall rule. This block replaces all three and
keeps its number so the two paths stay in step.

Port 8089 answers this computer and nothing else, because compose binds it to 127.0.0.1.
There is no domain, and no certificate because there is nothing to certify. No other
device can reach this, including the user's own phone on the same wifi, and that is the
point of this path, not a defect in it. Browsers treat http://localhost as a secure
context, so the in-page cryptography a signing form needs still works over plain HTTP.

Acceptance criterion, checked in step 7: the published port reads
`127.0.0.1:8089->3000/tcp`, never `0.0.0.0:8089`.

## 7. Start and verify

On macOS and Windows the container gets the VM's memory, not the machine's:

```bash
docker info --format '{{.MemTotal}}'
```

Divide by 1048576. Under 2048, STOP: on macOS and Windows have the user raise Docker
Desktop's memory limit in Settings, Resources and wait for them to confirm Docker
restarted. On Linux there is no such setting: that number is the machine's own memory,
which step 1 already measured.

Rails migrates as it boots, so the first start is the slow one. Do not follow redirects
here: the redirect is the signal.

```bash
cd ~/selfhost/docuseal
docker compose pull
docker compose up -d
sleep 60
docker compose ps
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8089/up
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8089/setup
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' http://localhost:8089
```

Assert, all four, printing what you received for each: `docker compose ps` publishes
`127.0.0.1:8089->3000/tcp`; `/up` prints `200`; `/setup` prints `200`; the last line
prints `302` with a redirect_url ending in `/setup`. `/up` is the Rails health route and
answers only once the app has booted, and `/setup` answers `200` exactly while no user
exists.

If `docker compose up` said the port is already allocated, something else on this computer
holds 8089: find it and stop it. Do not edit the port; every other line of this prompt
names 8089. If anything else misses, wait 60 seconds, check once more, then stop and run
`docker compose logs --tail 40 docuseal`: a permission error on `/data/docuseal` is step 3
on Linux, and a container that vanished ran out of memory. A running container is not
success; four asserts passing is.

The first screen at http://localhost:8089 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 http://localhost:8089/setup in a browser and create that first
account, and wait for them to confirm they are signed in.

Then prove the setup form closed itself:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8089/setup
```

Assert: this prints `302`, not `200`. DocuSeal redirects /setup to the sign-in page once a
user exists. A `200` means the account was never created, and the next person to sit at
this computer would own every document put here afterwards.

## 8. First backup and restore

Take the backup now, before the first real document. Stop the container first: SQLite
copied mid-write is not a backup.

```bash
cd ~/selfhost/docuseal
docker compose stop
case "$(uname -s)" in
  Linux) sudo tar -czf backups/docuseal-$(date +%F).tar.gz data .env compose.yml ;;
  *) tar -czf backups/docuseal-$(date +%F).tar.gz data .env compose.yml ;;
esac
docker compose start
case "$(uname -s)" in Linux) sudo chown "$(id -u):$(id -g)" backups/*.tar.gz ;; esac
ls -lh backups/
```

Assert: the archive exists and is non-empty. Print its size, a few hundred kilobytes on a
fresh install. On Linux the files under `data` belong to uid 2000, which is why tar runs
under sudo there and the archive is chowned back after.

`data`, `.env` and `compose.yml` travel together: the documents and the database are under
`data`, the key that decrypts the encrypted columns derives from `SECRET_KEY_BASE` in
`.env`, and `compose.yml` names the pinned image that reads both. Data without the key is
unreadable.

A backup on the same disk as the data is not a backup, and on one computer the disk and
the machine fail together.

STOP: ask the user for one destination that leaves this computer, a folder their sync
service watches or a mounted USB stick, and wait for the path. Copy the archive there with
`cp`, then list it at the destination so the copy is proved. A signed agreement is
something somebody else relies on: this archive should survive a fire.

To restore: `cd ~/selfhost/docuseal`, `docker compose down`, `rm -rf data`, `tar -xzf` the
archive from `backups/`, then `docker compose up -d`. On Linux prefix the `rm` and the
`tar` with `sudo`, so the uid 2000 ownership comes back with the files. The archive
carries `compose.yml`, so a replacement machine does not repeat steps 3 to 5. Those five
commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/docusealco/docuseal/releases. Take a backup
with the step 8 block first, then edit the image line in ~/selfhost/docuseal/compose.yml
to the new tag and its digest. Rails migrates on the next boot, so read the log until it
settles.

```bash
cd ~/selfhost/docuseal
docker compose pull
docker compose up -d
docker compose logs --tail 20 docuseal
```

## 10. What will probably go wrong

The morning after the first reboot, the bookmark fails. `restart: unless-stopped` is a
promise Docker keeps, and on macOS and Windows Docker is not running until somebody opens
Docker Desktop, so there is nothing there to keep it. I went to http://localhost:8089, got
a page saying the site could not be reached, and spent ten minutes rereading the compose
file before noticing the whale icon was gone. Run `docker info` first; if it fails, start
Docker Desktop, wait for it to say running, then
`cd ~/selfhost/docuseal && docker compose up -d`. On Linux the daemon starts with the
machine and this does not happen.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not configure SMTP. An invitation from here carries a link to
  http://localhost:8089, which opens nowhere else: a working relay delivers a dead link.
- 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. Rotating it makes every stored
  signature unreadable.
````

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

## compose.local.yml

```yaml
# DocuSeal · the deterministic fallback for the local path. 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
#   HOST and FORCE_SSL .. https://github.com/docusealco/docuseal/blob/master/config/environments/production.rb
#
# Every host path here is relative to ~/selfhost/docuseal/, where this file
# lives, which is what lets one file work on macOS, Linux and Windows.
# 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; on Linux that ownership is set by hand in step 3, and on macOS and
# Windows Docker Desktop's file sharing handles it. No FORCE_SSL in the .env this
# reads: there is no certificate here to redirect to. 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: ./.env
    volumes:
      # Database, attachments and signed documents, all in one directory.
      - ./data:/data/docuseal
    ports:
      # Loopback only. Nothing outside this computer can reach 8089, not the
      # router and not the user's own phone on the same wifi. That is the point
      # of this path.
      - "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
