Can I self-host PandaDoc?
YES, BUT · ONE WEEKEND— setup effort 3 of 4YES, BUT — it's called Documenso. It takes one prompt, a 2048 MB VPS, and about 240 minutes. That is $195 a month you stop paying PandaDoc — $2,340 a year on the Business plan, 3 seats assumed.
Why people pay for PandaDoc
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
PandaDoc is not really a signature button, it is the whole path a proposal takes: a template with your pricing tables in it, a content library the sales team pulls from, a CRM that fills in the customer's name, a notification when the buyer opens page four, and a signature at the end. The signature is the cheap part. What people are paying for is that the quote, the negotiation and the executed contract are one object with one audit trail, and that a new hire can send a correct one on their second day.
| Plan | List price | What it buys |
|---|---|---|
| Free eSign | free | Free tier, reported as capped at 60 documents a year, with the editor and basic tracking. |
| Starter | $35/mo per seat | Per user per month billed monthly. $19 per user per month on the annual plan. Unlimited documents and signatures, no CRM integrations, no custom branding. |
| Businessthe plan this page prices against | $65/mo per seat | Per user per month billed monthly. $49 per user per month on the annual plan. Adds CRM integrations, custom branding, the content library and approval workflows. |
| Enterprise | quote only | Quote only. Configure-price-quote, single sign-on, notary and API access sit here. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: low
Replaced by Documenso
One project, named before the prompt, so you know what you are about to install.
E-signing where the key that seals the document is one you generated and keep, on a server you run.
The one that takes the signature itself seriously. Every completed document is sealed with an X.509 certificate you generate and hold, so the integrity claim is yours rather than a vendor's, and there are templates, an API, teams and an audit trail around it. The price is honesty about two things: it needs PostgreSQL and a working mail relay before anyone can even sign in, and a self-signed certificate makes Adobe Acrobat warn your counterparty that the signature cannot be verified until you buy one from an authority on Adobe's trust list.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Documensoour pickONE WEEKEND— setup effort 3 of 4
E-signing where the key that seals the document is one you generated and keep, on a server you run.
The one that takes the signature itself seriously. Every completed document is sealed with an X.509 certificate you generate and hold, so the integrity claim is yours rather than a vendor's, and there are templates, an API, teams and an audit trail around it. The price is honesty about two things: it needs PostgreSQL and a working mail relay before anyone can even sign in, and a self-signed certificate makes Adobe Acrobat warn your counterparty that the signature cannot be verified until you buy one from an authority on Adobe's trust list.
- DocuSealONE EVENING— setup effort 2 of 4
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 swap
You'd run
Documenso
ONE WEEKEND · ~240 min to running · 2048 MB RAM
PandaDoc Business · 3 seats assumed · vendor list price · checked 2026-08-06 · source · confidence: low
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~240 min3–24 hours, through the first backup
The prompt
Two paths to the same Documenso: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
341 lines · 14,983 bytes
What this prompt will do
- Preflight
- Layout
- Secrets and the signing certificate
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install 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.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.
You are installing 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.330 lines · 14,966 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets and the signing certificate
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's 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.compose.local.ymlthe services, pinned · local layout55 lines
# 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:agent-readable mirror: /self-host/pandadoc.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned50 lines
# 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_healthyCaddyfilethe hostname and TLS28 lines
# 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.shthe same install, no agent219 lines
#!/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/
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel PandaDoc.
- The signature is only as recognised as the certificate behind it. This install generates a self-signed one, which proves the document has not been altered since signing and carries the name you put in it. Adobe Acrobat will still tell your counterparty the signature cannot be verified, because that certificate is not on Adobe's trust list, and the only fix is buying one from an authority that is.
- Mail is the front door, not a feature. Documenso refuses a sign-in until the account's email address has been confirmed through a link it emails, so a broken relay locks out the person who installed it. New Hetzner and DigitalOcean accounts block outbound mail ports until you ask them not to.
- You own the backups, and three things have to travel together: the database dump, the encryption keys in .env that the encrypted columns are read back with, and the certificate the signatures were made with. Any one of them missing turns a restore into an archive you cannot use.
- Documents live in the database by default, so the dump grows with every PDF you upload and every one you sign. That keeps the backup to one file, and it makes that file the thing you guard.
- AGPL-3.0, with a paid Enterprise edition upstream. Qualified signatures through a trust service provider, single sign-on and embed white-labelling live behind a license key, and the admin role for your own first account is granted by editing a database row.
Where this came from
“Documenso does not ship with a signing certificate. Without one, the application starts normally but all document signing will fail.”
- A self-hosted instance needs a signing certificate you provide, PostgreSQL 14 or later, and a working SMTP server; no other database engine is supported. source
- A self-signed certificate is free and still guarantees document integrity, but Adobe Acrobat will not show a green check and recipients see a warning that the signature cannot be verified. source
- The container reads the certificate from /opt/documenso/cert.p12, runs as uid 1001, listens on port 3000, and exposes /api/health and /api/certificate-status. source
- Sign-in is refused with an unverified-email error until the account's address has been confirmed, so a fresh instance without working mail locks out its own installer. source
- New accounts get the USER role only, the first admin is granted by editing that user's roles directly in the database, and CSC signing, SSO and white-labelling need a paid Enterprise license key. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host PandaDoc?
Not PandaDoc itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Documenso. E-signing where the key that seals the document is one you generated and keep, on a server you run. The install is one weekend: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 240 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces PandaDoc?
Documenso. E-signing where the key that seals the document is one you generated and keep, on a server you run. The one that takes the signature itself seriously. Every completed document is sealed with an X.509 certificate you generate and hold, so the integrity claim is yours rather than a vendor's, and there are templates, an API, teams and an audit trail around it. The price is honesty about two things: it needs PostgreSQL and a working mail relay before anyone can even sign in, and a self-signed certificate makes Adobe Acrobat warn your counterparty that the signature cannot be verified until you buy one from an authority on Adobe's trust list. Also evaluated and ranked below it: DocuSeal. The prompt on this page installs Documenso only. Documenso is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to PandaDoc?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Documenso itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: PandaDoc Business, $195/mo — $2,340 a year, 3 seats assumed.
How hard is it really?
ONE WEEKEND — 3–24 hours. The rule that produced that verdict: a database plus one outside integration. A database and an outside service fail in completely different ways, and you have to learn both failure modes before you trust the thing with real data. Budget the second day for whichever one surprises you. The tier is derived from seven countable facts about the Documenso install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Documenso on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Documenso on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Signing links made here begin with http://localhost:8142 and this install sends no mail, so it signs and stores your own documents and cannot ask anyone else to sign one. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.