# Can I self-host PandaDoc?

**YES, BUT** — it's called Documenso. ONE WEEKEND setup · ~4 hours to running · 2 GB RAM minimum · $195/mo you stop paying ($2,340/yr on the Business plan, 3 seats assumed).

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

## 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 Documenso 2.16.0 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 for three more things
and stop asking: an SMTP relay hostname, its port, and their username. Do not ask for the
relay password; a `STOP:` in step 3 has the user type that in.

Tell them one thing before anything installs: nobody signs in until that account's email
address is confirmed through a link Documenso mails out. Mail here is the front door.

Documenso 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. Do not install and hope. If
`dig +short` prints nothing, stop: Caddy cannot certify a hostname that does not resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/documenso /srv/documenso/backups
sudo install -d -m 700 /srv/documenso/postgres
ls -la /srv/documenso
```

Assert: `ls -la` shows `backups` owned by the login user and `postgres` at mode `700` owned by
root, which the PostgreSQL image chowns itself on first start. Everything Documenso keeps,
signed PDFs included, is a row in the database inside it.

## 3. Secrets and the signing certificate

Five secrets are generated here: three application keys, the database password, and the
passphrase on the signing certificate. Do not print any of them, repeat them in your summary,
or log them. Hex, not base64: one rides inside a connection string.

```bash
umask 077
cat > /srv/documenso/.env <<EOF
NEXT_PUBLIC_WEBAPP_URL=https://<DOMAIN>
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
NEXTAUTH_SECRET=$(openssl rand -hex 32)
NEXT_PRIVATE_ENCRYPTION_KEY=$(openssl rand -hex 32)
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
NEXT_PRIVATE_SIGNING_PASSPHRASE=$(openssl rand -hex 24)
NEXT_PRIVATE_SMTP_HOST=smtp.example.net
NEXT_PRIVATE_SMTP_PORT=587
NEXT_PRIVATE_SMTP_USERNAME=<ADMIN_EMAIL>
NEXT_PRIVATE_SMTP_FROM_NAME=Documenso
NEXT_PRIVATE_SMTP_FROM_ADDRESS=<ADMIN_EMAIL>
NEXT_PUBLIC_DISABLE_SIGNUP=false
DOCUMENSO_DISABLE_TELEMETRY=true
EOF
chmod 600 /srv/documenso/.env
ls -l /srv/documenso/.env
```

Replace `smtp.example.net`, `587` and both `<ADMIN_EMAIL>` lines with the step 1 values first.
Assert: mode `-rw-------`. Tell the user the encryption keys are how the encrypted columns are
read back, so changing either later makes stored data unreadable.

STOP: tell the user to run the block below on the server from their own terminal, so the relay
password never enters this session, and to report what the last line printed.
Do not continue until they confirm. The `read` line waits with no prompt and echoes nothing.

```bash
umask 077
printf 'NEXT_PRIVATE_SMTP_PASSWORD=' >> /srv/documenso/.env
read -rs && printf '%s\n' "$REPLY" >> /srv/documenso/.env
unset REPLY
chmod 600 /srv/documenso/.env
awk -F= '/^NEXT_PRIVATE_SMTP_PASSWORD/ {print "recorded, length " length($2)}' /srv/documenso/.env
```

Assert: a length greater than 0. Nothing printed means the line is missing.

Now the signing certificate. Documenso ships none: without one it starts, serves pages and
fails every signature. This one is self-signed and lasts ten years, because one that expires
quietly becomes failed signings nobody diagnoses.

```bash
umask 077
openssl genrsa -out /srv/documenso/private.key 2048
openssl req -new -x509 -key /srv/documenso/private.key -out /srv/documenso/certificate.crt -days 3650 -subj "/CN=<DOMAIN>/O=<DOMAIN>"
CERT_PASS=$(sed -n 's/^NEXT_PRIVATE_SIGNING_PASSPHRASE=//p' /srv/documenso/.env) openssl pkcs12 -export -out /srv/documenso/cert.p12 -inkey /srv/documenso/private.key -in /srv/documenso/certificate.crt -password env:CERT_PASS
rm -f /srv/documenso/private.key /srv/documenso/certificate.crt
sudo chown 1001:1001 /srv/documenso/cert.p12
sudo chmod 400 /srv/documenso/cert.p12
umask 022
ls -l /srv/documenso/cert.p12
```

Assert: `cert.p12` exists, is not empty, and reads `-r--------` owned by `1001`, the account
inside the image. Upstream documents both that uid and the rule that the file carry a
password.

## 4. compose.yml

```bash
cat > /srv/documenso/compose.yml <<'EOF'
# Documenso · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose deployment . https://docs.documenso.com/docs/self-hosting/deployment/docker-compose
#   signing certificate  https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/local
#
# Two services: Documenso and PostgreSQL, upstream's only supported engine, 14
# or later. Signed PDFs are rows in it, and the only state outside it is the
# signing certificate, mounted from the host because one made inside a container
# dies with it. Digests read on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    container_name: documenso-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: documenso
      POSTGRES_USER: documenso
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/documenso/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U documenso -d documenso"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  documenso:
    image: ghcr.io/documenso/documenso:v2.16.0@sha256:945bd2c04306bd5d78def0c4ceafdffb6b0a106cd6a2543db5acda9a6424b2d9
    container_name: documenso
    restart: unless-stopped
    env_file: /srv/documenso/.env
    environment:
      PORT: "3000"
      # One database, named twice: upstream wants a pooled URL and a direct URL
      # for migrations, and allows one string for both with no pooler in front.
      NEXT_PRIVATE_DATABASE_URL: postgresql://documenso:${DB_PASSWORD}@postgres:5432/documenso
      NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://documenso:${DB_PASSWORD}@postgres:5432/documenso
    volumes:
      # The signing certificate, read-only. The image runs as uid 1001, which
      # is why step 3 hands this file to 1001 first.
      - /srv/documenso/cert.p12:/opt/documenso/cert.p12:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8142.
      - "127.0.0.1:8142:3000"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/documenso && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Upstream's own compose publishes 3000 on every interface and
pins nothing; this one binds loopback and pins digests.

## 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 other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-documenso
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Documenso · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://docs.documenso.com/docs/self-hosting/deployment/docker-compose
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also NEXT_PUBLIC_WEBAPP_URL in .env, and it is the address inside every
# signing link Documenso mails out, so the two have to agree.

<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: Documenso publishes an embeddable signing view, and a
	# blanket SAMEORIGIN would break it later. No body limit either, because a
	# scanned contract is often several megabytes.
	#
	# 8142 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8142
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-documenso, reload, and report what it said. Caddy gets the
certificate on the first request and renews it alone. That one is for the website; step 3's
signs documents.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero already configured:

```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. 8142 stays closed because it is bound to 127.0.0.1, 5432 because compose publishes
no host port, 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` with 80, 443/tcp and
443/udp, and no rule for 8142 or 5432.

## 7. Start and verify

Migrations run inside the container on the way up, so the first start is slow.

```bash
cd /srv/documenso
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health
curl -sS https://<DOMAIN>/signin | grep -c 'Sign in to your account'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/signup
```

Assert all four and print what you received. The loop ends on `200`. The health body contains
`"certificate":{"status":"ok"}`, which is step 3's certificate being read rather than merely
present; a `"warning"` there means the container cannot open that file. The `grep -c` prints
`1`: the first screen at https://<DOMAIN> is the sign-in page, heading
`Sign in to your account`. The last curl prints `200`, so registration is open, correct for one
more step. If any of the four misses, stop, run
`docker compose logs --tail 40 documenso` and `docker compose logs --tail 20 postgres`, and
name the likely earlier step: a database that never reports healthy is step 2, a `502` is step
5. A running container is not success.

STOP: tell the user to open https://<DOMAIN>/signup, create the first account with
`<ADMIN_EMAIL>`, click the link in the confirmation mail, and sign in, and wait.
Do not continue until they confirm. That proves the hostname is theirs before a stranger
finds it, and that their relay delivers. If the mail never lands, step 10 is about that.

Once they confirm, close registration and prove it closed:

```bash
sed -i 's/^NEXT_PUBLIC_DISABLE_SIGNUP=false$/NEXT_PUBLIC_DISABLE_SIGNUP=true/' /srv/documenso/.env
docker compose up -d --force-recreate documenso
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/signup
```

Assert: this prints `302`, not `200`. With signups disabled the app redirects /signup to the
sign-in page, and that redirect is the security assert here. A `200` means the container never
took the change and the hostname stands open.

## 8. First backup and restore

Two artifacts. The dump holds the accounts, the audit trail and the documents, because the
default upload transport keeps every PDF in the database. The config archive holds what
rebuilds the service around it.

```bash
cd /srv/documenso
docker compose exec -T postgres pg_dump -U documenso -d documenso | gzip > /srv/documenso/backups/documenso-db-$(date +%F).sql.gz
sudo tar -czf /srv/documenso/backups/documenso-config-$(date +%F).tar.gz -C /srv/documenso compose.yml .env cert.p12 -C /etc/caddy Caddyfile
ls -lh /srv/documenso/backups/
```

Assert: both exist and both are non-empty. Print both sizes. Nothing stops: `pg_dump`
snapshots a running database consistently. A backup on the same disk is not a backup, so run
this from the user's machine:

```bash
mkdir -p ~/backups/documenso
scp vps:/srv/documenso/backups/* ~/backups/documenso/
```

To restore: `docker compose down`, `sudo rm -rf /srv/documenso/postgres`, recreate it as in
step 2, untar the config archive into /srv/documenso, `docker compose up -d postgres`, wait for
healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U documenso -d documenso`, then `docker compose up -d`.
Say why the two travel together: the dump alone is unreadable without the keys in `.env`.

## 9. Updating later

Versions are listed at https://github.com/documenso/documenso/releases and the image tags at
https://github.com/documenso/documenso/pkgs/container/documenso. Upstream tags and builds
before writing the notes, so the newest tag can sit one ahead of that page. Back up both
artifacts, then edit the image line in compose.yml to the new tag and digest:

```bash
cd /srv/documenso
docker compose pull
docker compose up -d
docker compose logs --tail 30 documenso
```

Migrations run on the way up. Watch that log until it settles, then re-run step 7's health
check.

## 10. What will probably go wrong

The confirmation mail will not arrive, and everything else will look right while it does not.
Hetzner and DigitalOcean block outbound 25, 465 and 587 on new accounts until you ask them not
to, and a relay that refuses the credential fails the same way. I sat on the sign-in page for
ten minutes typing a password that was right, being told the account was not verified, before I
read the log. Run `docker compose logs --tail 40 documenso` and look for the connection to the
relay. Until that mail lands, nobody signs in.

## 11. Out of scope

- Do not buy or install a certificate from a Certificate Authority. Step 3's self-signed one
  proves the document has not changed since signing; making Adobe Acrobat show a green check
  is the user's purchase, not this install's.
- Do not set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` or configure CSC signing. Both belong to the
  paid enterprise edition and need a subscription this install does not have.
- Do not configure S3 storage, switch `NEXT_PUBLIC_UPLOAD_TRANSPORT`, or add Google, Microsoft
  or OIDC sign-in. Documents stay in the database, which is what makes one dump the whole
  backup, and email with a password is a working way in.
````

## 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 Documenso 2.16.0 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, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

Read this before step 1. Documenso will not let anyone sign in, you included, until that
account's email address has been confirmed by clicking a link it emails. So you need an SMTP
relay you can already send through, with its hostname, its port and your username on it, before
you start. `<ADMIN_EMAIL>` below is the address your first account will use.

## 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 on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
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 2048 MB of
available RAM is the number to take seriously here: Next.js and PostgreSQL in the same box on a
1 GB plan will pass this install and then meet the OOM killer during the first upload.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/documenso /srv/documenso/backups
sudo install -d -m 700 /srv/documenso/postgres
ls -la /srv/documenso
```

You should see: `backups` owned by you, and `postgres` at mode `drwx------` owned by root.

If you do not: leave `postgres` owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts, and one you have already chowned to yourself makes it
refuse to initialise. There is no `data` directory in this install: accounts, the audit trail
and the signed PDFs themselves are all rows in that database.

## 3. Secrets and the signing certificate

Five secrets are generated here, on the server: three application keys, the database password
and the passphrase on the signing certificate. Hex rather than base64, because one of them
rides inside a PostgreSQL connection string. Replace `smtp.example.net`, `587` and both
`<ADMIN_EMAIL>` lines with your own values before you paste.

```bash
umask 077
cat > /srv/documenso/.env <<EOF
NEXT_PUBLIC_WEBAPP_URL=https://<DOMAIN>
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
NEXTAUTH_SECRET=$(openssl rand -hex 32)
NEXT_PRIVATE_ENCRYPTION_KEY=$(openssl rand -hex 32)
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
NEXT_PRIVATE_SIGNING_PASSPHRASE=$(openssl rand -hex 24)
NEXT_PRIVATE_SMTP_HOST=smtp.example.net
NEXT_PRIVATE_SMTP_PORT=587
NEXT_PRIVATE_SMTP_USERNAME=<ADMIN_EMAIL>
NEXT_PRIVATE_SMTP_FROM_NAME=Documenso
NEXT_PRIVATE_SMTP_FROM_ADDRESS=<ADMIN_EMAIL>
NEXT_PUBLIC_DISABLE_SIGNUP=false
DOCUMENSO_DISABLE_TELEMETRY=true
EOF
chmod 600 /srv/documenso/.env
ls -l /srv/documenso/.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 separately in different shells. Run `chmod 600 /srv/documenso/.env` and
carry on.

Do not paste that file, any of those five values, or any command output containing them into
this chat window. The agent path never sees them; this path hands them to a third party unless
you keep them out yourself.

Two of those keys deserve a warning of their own. `NEXT_PRIVATE_ENCRYPTION_KEY` and
`NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` are what the encrypted columns in the database are read
back with. Change either one later and the stored data stops being readable, which is why step
8 backs `.env` up beside the database dump and treats the two as one artifact.

Now add the relay password. It is the one secret here you already own, and it is typed rather
than generated. The third line waits with no prompt and echoes nothing:

```bash
umask 077
printf 'NEXT_PRIVATE_SMTP_PASSWORD=' >> /srv/documenso/.env
read -rs && printf '%s\n' "$REPLY" >> /srv/documenso/.env
unset REPLY
chmod 600 /srv/documenso/.env
awk -F= '/^NEXT_PRIVATE_SMTP_PASSWORD/ {print "recorded, length " length($2)}' /srv/documenso/.env
```

You should see: `recorded, length` and a number greater than zero. The number is the only thing
about that password that ever appears on your screen.

If you do not: no output at all means the line never landed, so run the block again. A length of
0 means you pressed Return before typing anything.

Documenso ships no signing certificate, and without one it starts, serves pages, and fails every
signature. Make a self-signed one now. Ten years rather than the one year upstream's example
uses, because a signing certificate that expires quietly turns into failed signings nobody
diagnoses:

```bash
umask 077
openssl genrsa -out /srv/documenso/private.key 2048
openssl req -new -x509 -key /srv/documenso/private.key -out /srv/documenso/certificate.crt -days 3650 -subj "/CN=<DOMAIN>/O=<DOMAIN>"
CERT_PASS=$(sed -n 's/^NEXT_PRIVATE_SIGNING_PASSPHRASE=//p' /srv/documenso/.env) openssl pkcs12 -export -out /srv/documenso/cert.p12 -inkey /srv/documenso/private.key -in /srv/documenso/certificate.crt -password env:CERT_PASS
rm -f /srv/documenso/private.key /srv/documenso/certificate.crt
sudo chown 1001:1001 /srv/documenso/cert.p12
sudo chmod 400 /srv/documenso/cert.p12
umask 022
ls -l /srv/documenso/cert.p12
```

You should see: one file, a couple of kilobytes, mode `-r--------`, owner and group `1001`.

If you do not: `unable to load Private Key` means the `genrsa` line did not run, so start the
block again from the top. `1001` is the account inside the image, and upstream documents both
that ownership and the rule that the certificate must carry a password at all. What this
certificate is, plainly: it proves a completed document has not been altered since signing, and
it carries the name you put in the subject. What it is not: a certificate Adobe Acrobat
recognises. Acrobat will show a warning that the signature cannot be verified, and only a
certificate bought from an authority on Adobe's trust list changes that.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/documenso/compose.yml <<'EOF'
# Documenso · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose deployment . https://docs.documenso.com/docs/self-hosting/deployment/docker-compose
#   signing certificate  https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/local
#
# Two services: Documenso and PostgreSQL, upstream's only supported engine, 14
# or later. Signed PDFs are rows in it, and the only state outside it is the
# signing certificate, mounted from the host because one made inside a container
# dies with it. Digests read on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    container_name: documenso-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: documenso
      POSTGRES_USER: documenso
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/documenso/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U documenso -d documenso"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  documenso:
    image: ghcr.io/documenso/documenso:v2.16.0@sha256:945bd2c04306bd5d78def0c4ceafdffb6b0a106cd6a2543db5acda9a6424b2d9
    container_name: documenso
    restart: unless-stopped
    env_file: /srv/documenso/.env
    environment:
      PORT: "3000"
      # One database, named twice: upstream wants a pooled URL and a direct URL
      # for migrations, and allows one string for both with no pooler in front.
      NEXT_PRIVATE_DATABASE_URL: postgresql://documenso:${DB_PASSWORD}@postgres:5432/documenso
      NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://documenso:${DB_PASSWORD}@postgres:5432/documenso
    volumes:
      # The signing certificate, read-only. The image runs as uid 1001, which
      # is why step 3 hands this file to 1001 first.
      - /srv/documenso/cert.p12:/opt/documenso/cert.p12:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8142.
      - "127.0.0.1:8142:3000"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/documenso && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/documenso/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/documenso/compose.yml` and paste again in one go. Upstream's own compose file
publishes 3000 on every interface and pins nothing; this one binds loopback and pins digests.

## 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-documenso
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Documenso · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://docs.documenso.com/docs/self-hosting/deployment/docker-compose
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also NEXT_PUBLIC_WEBAPP_URL in .env, and it is the address inside every
# signing link Documenso mails out, so the two have to agree.

<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: Documenso publishes an embeddable signing view, and a
	# blanket SAMEORIGIN would break it later. No body limit either, because a
	# scanned contract is often several megabytes.
	#
	# 8142 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8142
}
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-documenso /etc/caddy/Caddyfile`, reload,
and paste again. Caddy asks for the certificate on the first request and renews it on its own,
so there is nothing to schedule. Two certificates now exist in this install and they do
different jobs: this one proves the website is yours, the one from step 3 signs the documents.

## 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 `8142` or `5432`.

If you do not: delete anything for those two with `sudo ufw delete allow 8142`. 8142 is bound to
127.0.0.1 by the compose file and 5432 is never published, so the database has no host port a
rule could apply to. 80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the
only way in, 443/udp is HTTP/3. Nothing here opens 25, 465 or 587: this box sends through your
relay and accepts no mail. `Status: inactive` is a different problem, because Prompt Zero left
this firewall enabled, so `sudo ufw enable` before you go any further.

## 7. Start and verify

The container runs the database migrations on the way up, so the first start is the slow one.

```bash
cd /srv/documenso
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/health
curl -sS https://<DOMAIN>/signin | grep -c 'Sign in to your account'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/signup
```

You should see, in order: the loop reaching `200`, a JSON object containing
`"certificate":{"status":"ok"}`, then `1`, then `200`.

If you do not: `"certificate":{"status":"warning"}` is the interesting failure. It means the app
is running and cannot read your certificate, so signing would fail silently later; check the
ownership line in step 3, then run `docker compose logs --tail 20 documenso` and look for the
line about the certificate file. A loop that never reaches `200` usually means the database:
run `docker compose logs --tail 20 postgres` first and `docker compose logs --tail 40 documenso`
second. `502` from Caddy means the container is not up yet, and the first migration can take a
few minutes. A running container is not success.

The first screen at https://<DOMAIN> is the sign-in page, with the heading
`Sign in to your account`.

Now open https://<DOMAIN>/signup in a browser, create your account with `<ADMIN_EMAIL>`, open
the confirmation email Documenso sends you, click the link in it, and sign in. Do this before
the next block: until you do, anyone who finds the hostname can create the first account
instead of you.

Once you are signed in, close registration and prove it closed:

```bash
sed -i 's/^NEXT_PUBLIC_DISABLE_SIGNUP=false$/NEXT_PUBLIC_DISABLE_SIGNUP=true/' /srv/documenso/.env
docker compose up -d --force-recreate documenso
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/signup
```

You should see: `302`, not `200`. With signups disabled the app redirects /signup to the sign-in
page, and that redirect is the security check in this step.

If you do not: a `200` means the container kept its old environment, so run
`docker compose up -d --force-recreate documenso` again and re-check. Leaving it at `200` leaves
your hostname open to anyone who finds it.

## 8. First backup and restore

Two artifacts. The dump holds the accounts, the audit trail and the documents themselves,
because the default upload transport keeps every PDF in the database. The config archive holds
what rebuilds the service around it, certificate included.

```bash
cd /srv/documenso
docker compose exec -T postgres pg_dump -U documenso -d documenso | gzip > /srv/documenso/backups/documenso-db-$(date +%F).sql.gz
sudo tar -czf /srv/documenso/backups/documenso-config-$(date +%F).tar.gz -C /srv/documenso compose.yml .env cert.p12 -C /etc/caddy Caddyfile
ls -lh /srv/documenso/backups/
```

You should see: two files, the dump a few tens of kilobytes on a fresh install and the config
archive a few kilobytes. Nothing goes offline: `pg_dump` snapshots a running database
consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/documenso
scp vps:/srv/documenso/backups/* ~/backups/documenso/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/documenso/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is one empty account:

```bash
cd /srv/documenso
docker compose down
sudo rm -rf /srv/documenso/postgres
sudo install -d -m 700 /srv/documenso/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/documenso/backups/documenso-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U documenso -d documenso
docker compose up -d
sleep 30
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/signin
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `200` from the last command, and
your account still works when you sign in again.

If you do not: `role "documenso" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what the pairing means
before you skip this: the dump on its own restores nothing readable, because the encrypted
columns are read with the keys in `.env`, and a document signed by a certificate you no longer
hold cannot be signed again by the same identity.

## 9. Updating later

Versions are listed at https://github.com/documenso/documenso/releases and the image tags at
https://github.com/documenso/documenso/pkgs/container/documenso. Upstream tags and builds before
writing the notes, so the newest tag can sit one ahead of that page. Back up both artifacts,
then edit the image line in /srv/documenso/compose.yml to the new tag and digest.

```bash
cd /srv/documenso
docker compose pull
docker compose up -d
docker compose logs --tail 30 documenso
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done, and open one existing document as
well, because an instance that answers `"status":"ok"` can still have stopped rendering
signatures if a migration halted halfway.

## 10. What will probably go wrong

The confirmation mail will not arrive, and everything else will look right while it does not.
Hetzner and DigitalOcean block outbound 25, 465 and 587 on new accounts until you ask them not
to, and a relay that refuses the credential fails the same way. I sat on the sign-in page for
ten minutes typing a password that was right, being told the account was not verified, before I
read the log. Run `docker compose logs --tail 40 documenso` and look for the connection to the
relay. Until that mail lands, nobody signs in.

## 11. Out of scope

- Do not buy or install a certificate from a Certificate Authority. Step 3's self-signed one
  proves the document has not changed since signing; making Adobe Acrobat show a green check
  is your purchase, not this install's.
- Do not set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` or configure CSC signing. Both belong to the
  paid enterprise edition and need a subscription this install does not have.
- Do not configure S3 storage, switch `NEXT_PUBLIC_UPLOAD_TRANSPORT`, or add Google, Microsoft
  or OIDC sign-in. Documents stay in the database, which is what makes one dump the whole
  backup, and email with a password is a working way in.
````

## 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 Documenso 2.16.0, with the PostgreSQL it keeps documents in, under
~/selfhost/documenso, answering at http://localhost:8142.

## 1. Preflight

Say this to the user before step 2 runs. Every signing link here starts with
http://localhost:8142, which means "this computer" wherever it is read, and nothing here sends
mail. This is a private place to keep and sign your own documents, not a way to collect
anyone else's.

Detect the OS and measure the machine.

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print too, for step 2. Documenso plus PostgreSQL needs 2048 MB of
RAM available and 10 GB free on the home disk, on amd64 or arm64. If RAM is under 2048 MB or
disk under 10 GB, print both and stop.

## 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. The
  fence is guarded, a no-op on anything but a Linux with apt:

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

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- 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

```bash
mkdir -p ~/selfhost/documenso/backups
ls -la ~/selfhost/documenso
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder: everything
Documenso keeps is a row in PostgreSQL, in the volume step 5 declares.

## 4. Secrets and the signing certificate

Five secrets: three application keys, the database password, and the certificate passphrase.
Print none of them, and keep them out of your summary and out of any log.

```bash
umask 077
cat > ~/selfhost/documenso/.env <<EOF
NEXT_PUBLIC_WEBAPP_URL=http://localhost:8142
NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
NEXTAUTH_SECRET=$(openssl rand -hex 32)
NEXT_PRIVATE_ENCRYPTION_KEY=$(openssl rand -hex 32)
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
NEXT_PRIVATE_SIGNING_PASSPHRASE=$(openssl rand -hex 24)
NEXT_PRIVATE_SMTP_FROM_NAME=Documenso
NEXT_PRIVATE_SMTP_FROM_ADDRESS=documenso@localhost
NEXT_PUBLIC_DISABLE_SIGNUP=false
DOCUMENSO_DISABLE_TELEMETRY=true
EOF
chmod 600 ~/selfhost/documenso/.env
ls -l ~/selfhost/documenso/.env
```

Assert: mode `-rw-------`. On Windows those bits are advisory: NTFS does not enforce them, and
the real boundary is the user's own account. The encryption keys are how the encrypted
columns are read back: change either later and stored data is unreadable.

Documenso ships no signing certificate, and without one it starts, serves pages and fails every
signature. Make a self-signed one, good for ten years, because one that expires quietly becomes
failed signings.

```bash
cd ~/selfhost/documenso
umask 077
openssl genrsa -out private.key 2048
MSYS_NO_PATHCONV=1 openssl req -new -x509 -key private.key -out certificate.crt -days 3650 -subj "/CN=localhost/O=localhost"
CERT_PASS=$(sed -n 's/^NEXT_PRIVATE_SIGNING_PASSPHRASE=//p' ~/selfhost/documenso/.env) openssl pkcs12 -export -out cert.p12 -inkey private.key -in certificate.crt -password env:CERT_PASS
rm -f private.key certificate.crt
chmod 444 cert.p12
umask 022
ls -l cert.p12
```

Assert: `cert.p12` exists and is not empty. `MSYS_NO_PATHCONV=1` matters only in Git Bash,
where a subject starting with `/` is otherwise rewritten to a Windows path. The file is
world-readable on purpose: the container runs as uid 1001, and the key in it is encrypted.

## 5. compose.yml

```bash
cat > ~/selfhost/documenso/compose.yml <<'EOF'
# Documenso · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   compose deployment . https://docs.documenso.com/docs/self-hosting/deployment/docker-compose
#   signing certificate  https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/local
#
# Two services on the computer you are sitting at, every path relative to
# ~/selfhost/documenso/, which lets one file work on macOS, Linux and Windows.
# The database is a named volume, not a bind mount: PostgreSQL chowns its data
# directory to its own uid, which a home-directory bind mount cannot allow on
# Windows. The certificate is a bind mount, world-readable from step 4, because
# the image runs as uid 1001 and the key inside is encrypted anyway. No SMTP is
# configured here. Digests read on 2026-08-06; both images publish amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    container_name: documenso-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: documenso
      POSTGRES_USER: documenso
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - documenso-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U documenso -d documenso"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  documenso:
    image: ghcr.io/documenso/documenso:v2.16.0@sha256:945bd2c04306bd5d78def0c4ceafdffb6b0a106cd6a2543db5acda9a6424b2d9
    container_name: documenso
    restart: unless-stopped
    env_file: ./.env
    environment:
      PORT: "3000"
      # One database, named twice: upstream wants a pooled URL and a direct one.
      NEXT_PRIVATE_DATABASE_URL: postgresql://documenso:${DB_PASSWORD}@postgres:5432/documenso
      NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://documenso:${DB_PASSWORD}@postgres:5432/documenso
    volumes:
      # The signing certificate, read-only inside the container.
      - ./cert.p12:/opt/documenso/cert.p12:ro
    ports:
      # Loopback only: no other device on the wifi can reach 8142.
      - "127.0.0.1:8142:3000"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  documenso-pgdata:
EOF
cd ~/selfhost/documenso && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no
hostname to resolve and no public name to certify, and browsers treat http://localhost as a
secure context anyway, so pages needing crypto still work. Nothing is published past loopback:
8142 is bound to 127.0.0.1, which is not the user's phone, not a laptop on the same wifi, not
anyone on the internet. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/documenso/compose.yml
```

Assert: that prints `1`, the published port line. PostgreSQL publishes no host port at all.

## 7. Start and verify

The container migrates the database on the way up, so the first start is slow.

```bash
cd ~/selfhost/documenso
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8142/api/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8142/api/health
curl -sS http://localhost:8142/signin | grep -c 'Sign in to your account'
```

Assert all three and print what you got. The loop ends on `200`. The health body contains
`"certificate":{"status":"ok"}`, step 4's certificate being read rather than merely present,
where a `"warning"` would mean the container cannot open it. The `grep -c` prints `1`: the
first
screen at http://localhost:8142 is the sign-in page, heading `Sign in to your account`. If any
of the three misses, stop, run `docker compose logs --tail 40 documenso`, and name the cause: a
database that never reports healthy is step 4, and `port is already allocated` means something
else holds 8142 (`lsof -nP -iTCP:8142 -sTCP:LISTEN`). A running container is not success.

STOP: tell the user to open http://localhost:8142/signup and create their account, and wait.
Do not continue until they confirm. They cannot sign in yet: Documenso holds a sign-in until
the address is confirmed, and it confirms by sending mail. Confirm it here instead:

```bash
docker compose exec -T postgres psql -U documenso -d documenso -c 'UPDATE "User" SET "emailVerified" = NOW() WHERE "emailVerified" IS NULL;'
```

Assert: `UPDATE 1`. `UPDATE 0` means no account was created, so go back a step.

STOP: tell the user to sign in at http://localhost:8142/signin and confirm they see the
document list, and wait. Do not continue until they confirm. Then close registration:

```bash
sed -i.bak 's/^NEXT_PUBLIC_DISABLE_SIGNUP=false$/NEXT_PUBLIC_DISABLE_SIGNUP=true/' ~/selfhost/documenso/.env
rm -f ~/selfhost/documenso/.env.bak
docker compose up -d --force-recreate documenso
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8142/signup
```

Assert: this prints `302`, not `200`. With signups disabled the app redirects /signup to the
sign-in page. `sed -i.bak` is the one form both the BSD sed on macOS and the GNU sed everywhere
else accept.

## 8. First backup and restore

Two artifacts: a dump with the accounts, the audit trail and every PDF, and a config archive
with the files that rebuild the service.

```bash
cd ~/selfhost/documenso
docker compose exec -T postgres pg_dump -U documenso -d documenso | gzip > ~/selfhost/documenso/backups/documenso-db-$(date +%F).sql.gz
tar -C ~/selfhost/documenso -czf ~/selfhost/documenso/backups/documenso-config-$(date +%F).tar.gz compose.yml .env cert.p12
ls -lh ~/selfhost/documenso/backups/
```

Assert: both exist and both are non-empty. Print both sizes. Nothing stops: `pg_dump` snapshots
a running database.

Both sit on the same disk as the data, and on a laptop the disk and the machine fail together.
Ask the user for a destination off this computer, a folder their sync service watches or a USB
stick, and copy both there with `cp`. Assert: the user confirms both filenames are there, and
if they have neither, say plainly that there is no backup.

To restore, in this order: untar the config archive into ~/selfhost/documenso first, so .env is
back before any container starts, because PostgreSQL takes `DB_PASSWORD` from it the moment it
initialises an empty volume. Then `docker compose down -v`, the one place `-v` belongs,
`docker compose up -d postgres`, wait 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz`
into `docker compose exec -T postgres psql -U documenso -d documenso`, then
`docker compose up -d`. The dump alone is unreadable without the keys in `.env`.

## 9. Updating later

Versions are listed at https://github.com/documenso/documenso/releases and the image tags at
https://github.com/documenso/documenso/pkgs/container/documenso, which can run one ahead. Back
up first, then edit the image line in compose.yml:

```bash
cd ~/selfhost/documenso
docker compose pull
docker compose up -d
docker compose logs --tail 30 documenso
```

Migrations run on the way up: watch that log until it settles, then re-run step 7's check.

## 10. What will probably go wrong

I created the account, typed the right password, and was told the account was not verified. I
assumed I had mistyped it, made a second account, and got the same answer.
Nothing was broken: Documenso confirms an address by emailing a link, and this install sends no
mail. The UPDATE in step 7 is the fix, for that account and any later one.

## 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 links to http://localhost:8142, which resolves
  on this computer only.
- Do not buy a certificate from a Certificate Authority, set
  `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY`, or move `NEXT_PUBLIC_UPLOAD_TRANSPORT` to S3. Step 4's
  certificate already proves a document has not changed, the license key is the paid edition,
  and S3 moves documents out of this backup.
````

## docker-compose.yml

```yaml
# Documenso · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   compose deployment . https://docs.documenso.com/docs/self-hosting/deployment/docker-compose
#   signing certificate  https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/local
#
# Two services: Documenso and PostgreSQL, upstream's only supported engine, 14
# or later. Signed PDFs are rows in it, and the only state outside it is the
# signing certificate, mounted from the host because one made inside a container
# dies with it. Digests read on 2026-08-06; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    container_name: documenso-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: documenso
      POSTGRES_USER: documenso
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/documenso/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U documenso -d documenso"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  documenso:
    image: ghcr.io/documenso/documenso:v2.16.0@sha256:945bd2c04306bd5d78def0c4ceafdffb6b0a106cd6a2543db5acda9a6424b2d9
    container_name: documenso
    restart: unless-stopped
    env_file: /srv/documenso/.env
    environment:
      PORT: "3000"
      # One database, named twice: upstream wants a pooled URL and a direct URL
      # for migrations, and allows one string for both with no pooler in front.
      NEXT_PRIVATE_DATABASE_URL: postgresql://documenso:${DB_PASSWORD}@postgres:5432/documenso
      NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://documenso:${DB_PASSWORD}@postgres:5432/documenso
    volumes:
      # The signing certificate, read-only. The image runs as uid 1001, which
      # is why step 3 hands this file to 1001 first.
      - /srv/documenso/cert.p12:/opt/documenso/cert.p12:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8142.
      - "127.0.0.1:8142:3000"
    depends_on:
      postgres:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Documenso · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   compose deployment . https://docs.documenso.com/docs/self-hosting/deployment/docker-compose
#   signing certificate  https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/local
#
# Two services on the computer you are sitting at, every path relative to
# ~/selfhost/documenso/, which lets one file work on macOS, Linux and Windows.
# The database is a named volume, not a bind mount: PostgreSQL chowns its data
# directory to its own uid, which a home-directory bind mount cannot allow on
# Windows. The certificate is a bind mount, world-readable from step 4, because
# the image runs as uid 1001 and the key inside is encrypted anyway. No SMTP is
# configured here. Digests read on 2026-08-06; both images publish amd64 and
# arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
    container_name: documenso-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: documenso
      POSTGRES_USER: documenso
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - documenso-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U documenso -d documenso"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  documenso:
    image: ghcr.io/documenso/documenso:v2.16.0@sha256:945bd2c04306bd5d78def0c4ceafdffb6b0a106cd6a2543db5acda9a6424b2d9
    container_name: documenso
    restart: unless-stopped
    env_file: ./.env
    environment:
      PORT: "3000"
      # One database, named twice: upstream wants a pooled URL and a direct one.
      NEXT_PRIVATE_DATABASE_URL: postgresql://documenso:${DB_PASSWORD}@postgres:5432/documenso
      NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://documenso:${DB_PASSWORD}@postgres:5432/documenso
    volumes:
      # The signing certificate, read-only inside the container.
      - ./cert.p12:/opt/documenso/cert.p12:ro
    ports:
      # Loopback only: no other device on the wifi can reach 8142.
      - "127.0.0.1:8142:3000"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  documenso-pgdata:
```

## Caddyfile

```text
# Documenso · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https
# and https://docs.documenso.com/docs/self-hosting/deployment/docker-compose
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also NEXT_PUBLIC_WEBAPP_URL in .env, and it is the address inside every
# signing link Documenso mails out, so the two have to agree.

<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: Documenso publishes an embeddable signing view, and a
	# blanket SAMEORIGIN would break it later. No body limit either, because a
	# scanned contract is often several megabytes.
	#
	# 8142 is the loopback port compose publishes here. It is not a container
	# port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8142
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Documenso · 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 password. That value is never echoed
# and never reaches your shell history.
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.documenso.com/docs/self-hosting/getting-started/requirements
#   https://docs.documenso.com/docs/self-hosting/deployment/docker-compose
#   https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/local
#   https://caddyserver.com/docs/automatic-https
#
# Five secrets are generated here, on this machine: NEXTAUTH_SECRET, the two
# encryption keys, the PostgreSQL password and the passphrase on the signing
# certificate. All five go into /srv/documenso/.env at mode 600 and none of them
# is ever printed. Do not change the encryption keys later: the encrypted
# columns in the database are read back with them.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/documenso}"
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. Nobody can sign in until a confirmation mail is delivered."
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; Next.js plus PostgreSQL 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 ----------------------------------------------------

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Five generated secrets, plus the relay password you already own -------
#
# Hex rather than base64: the database password rides inside a PostgreSQL
# connection string, where + and / would have to be escaped. Read any of them
# later with
#   sudo grep NEXTAUTH_SECRET /srv/documenso/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		NEXT_PUBLIC_WEBAPP_URL=https://${DOMAIN_HOST}
		NEXT_PRIVATE_INTERNAL_WEBAPP_URL=http://localhost:3000
		NEXTAUTH_SECRET=$(openssl rand -hex 32)
		NEXT_PRIVATE_ENCRYPTION_KEY=$(openssl rand -hex 32)
		NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=$(openssl rand -hex 32)
		DB_PASSWORD=$(openssl rand -hex 32)
		NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
		NEXT_PRIVATE_SIGNING_PASSPHRASE=$(openssl rand -hex 24)
		NEXT_PRIVATE_SMTP_HOST=${RELAY_HOST}
		NEXT_PRIVATE_SMTP_PORT=${RELAY_PORT}
		NEXT_PRIVATE_SMTP_USERNAME=${RELAY_USER}
		NEXT_PRIVATE_SMTP_FROM_NAME=Documenso
		NEXT_PRIVATE_SMTP_FROM_ADDRESS=${ADMIN_EMAIL}
		NEXT_PUBLIC_DISABLE_SIGNUP=false
		DOCUMENSO_DISABLE_TELEMETRY=true
	ENVFILE
	printf 'NEXT_PRIVATE_SMTP_PASSWORD=' >> "$APP_DIR/.env"
	printf 'Relay password 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

awk -F= '/^NEXT_PRIVATE_SMTP_PASSWORD/ {print "relay password recorded, length " length($2)}' "$APP_DIR/.env"

# --- 4. The signing certificate ----------------------------------------------
#
# Documenso ships none, and without one it starts, serves pages and fails every
# signature. Self-signed, ten years: one that expires quietly turns into failed
# signings nobody diagnoses. It proves a completed document has not changed
# since signing. It will not make Adobe Acrobat show a green check; only a
# certificate from an authority on Adobe's trust list does that.

if [ ! -f "$APP_DIR/cert.p12" ]; then
	umask 077
	openssl genrsa -out "$APP_DIR/private.key" 2048
	openssl req -new -x509 -key "$APP_DIR/private.key" -out "$APP_DIR/certificate.crt" -days 3650 -subj "/CN=${DOMAIN_HOST}/O=${DOMAIN_HOST}"
	CERT_PASS="$(sed -n 's/^NEXT_PRIVATE_SIGNING_PASSPHRASE=//p' "$APP_DIR/.env")" \
		openssl pkcs12 -export -out "$APP_DIR/cert.p12" -inkey "$APP_DIR/private.key" -in "$APP_DIR/certificate.crt" -password env:CERT_PASS
	rm -f "$APP_DIR/private.key" "$APP_DIR/certificate.crt"
	umask 022
fi
sudo chown 1001:1001 "$APP_DIR/cert.p12"
sudo chmod 400 "$APP_DIR/cert.p12"
[ -s "$APP_DIR/cert.p12" ] || die "the signing certificate is empty. Remove it and run this again."

cd "$APP_DIR"
docker compose config >/dev/null

# --- 5. 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-documenso"
	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

# --- 6. Ports: two open, and neither 8142 nor 5432 is 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; 8142, 5432 and every mail port stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 7. Start it and prove it works ------------------------------------------
#
# The container migrates the database on the way up, so the first start is slow.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/health (migrations, then Caddy's certificate)"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/health" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/api/health answered ${code:-nothing}. Check: docker compose logs --tail 40 documenso"

curl -sS "https://${DOMAIN_HOST}/api/health" | grep -q '"certificate":{"status":"ok"}' \
	|| die "health reports the signing certificate is not usable. Check ownership: ls -l $APP_DIR/cert.p12"

curl -sS "https://${DOMAIN_HOST}/signin" | grep -q 'Sign in to your account' \
	|| die "the sign-in page did not contain 'Sign in to your account'. Check: docker compose logs --tail 40 documenso"

signup_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/signup" || true)"
[ "$signup_code" = "200" ] || die "/signup answered ${signup_code}, so the first account cannot be created. Check the logs."

# --- 8. The first account, then close registration ---------------------------

cat <<-SETUP

	Open https://${DOMAIN_HOST}/signup now, create the first account with
	${ADMIN_EMAIL}, click the link in the confirmation email, and sign in.
	Nobody can sign in until that mail is delivered, and until you do this,
	whoever finds this hostname can create the first account instead.

SETUP
printf 'Press Return once you are signed in. '
read -r _

sed -i 's/^NEXT_PUBLIC_DISABLE_SIGNUP=false$/NEXT_PUBLIC_DISABLE_SIGNUP=true/' "$APP_DIR/.env"
docker compose up -d --force-recreate documenso
sleep 20
signup_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/signup" || true)"
echo "==> /signup now answers ${signup_code}"
[ "$signup_code" = "302" ] || die "/signup answers ${signup_code}, not 302, so registration is still open."

# --- 9. The first backup, before day one ends --------------------------------
#
# The dump is the documents as well as the accounts: with the default upload
# transport every PDF is a row in that database.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U documenso -d documenso | gzip > "$APP_DIR/backups/documenso-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/documenso-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env cert.p12 -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/documenso-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/documenso-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

cat <<-DONE

	Documenso is answering at https://${DOMAIN_HOST}/

	  1. Registration is closed. Reopen it by setting NEXT_PUBLIC_DISABLE_SIGNUP
	     to false in $APP_DIR/.env and running
	       cd $APP_DIR && docker compose up -d --force-recreate documenso
	  2. The signature on a completed document is made with the self-signed
	     certificate in $APP_DIR/cert.p12. It proves the document has not been
	     altered since signing. Adobe Acrobat will still say the signature cannot
	     be verified, because that certificate is not on Adobe's trust list.
	  3. The two backup files travel together. The dump holds the documents; the
	     archive holds .env, whose encryption keys are what the encrypted columns
	     are read back with, and cert.p12, the identity the signatures were made
	     with. One without the other is not a restore.
	  4. Both are on the same disk as the data, which is not a backup. Copy them
	     somewhere else tonight:
	       scp vps:$APP_DIR/backups/* ~/backups/documenso/

DONE
```

## Also evaluated

Ranked below Documenso for this swap. The prompts above install Documenso only.

- **DocuSeal** — Send a PDF for signature and get it back signed, from one container, with the documents never leaving your disk. The faster answer if what you want is a PDF signed by Friday. One container, SQLite, a drag-the-fields-onto-the-page editor that is quicker to learn than Documenso's, and no database to operate. What you give up is the part above: a smaller template and API surface, and less control over the signing identity. Pick this when the counterparty is a client who trusts you already, and Documenso when the document has to argue for itself later.

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