Document Signing
    PostgreSQL

    Deploy Documenso on a VPS

    Self-host Documenso document signing on a RamNode VPS with PostgreSQL, a signing certificate, Docker Compose, Caddy HTTPS, and backups.

    Documenso is an open-source document signing platform. This guide runs the official app with PostgreSQL in Docker Compose, a PKCS#12 signing certificate and Caddy for HTTPS. Documents are stored in PostgreSQL, so plan disk and backup capacity for your expected volume.

    Prerequisites

    • A RamNode KVM VPS running Ubuntu 24.04 LTS (the same steps apply to 26.04 LTS), 2 GB RAM, 1–2 vCPUs and at least 40 GB disk.
    • A domain such as sign.example.com with an A record (and AAAA if using IPv6) pointing to the server.
    • Root SSH access initially, plus credentials for an authenticated SMTP relay. Documenso needs email to deliver signing requests.

    Replace all example domains, names and passwords. See Docker Compose and DNS for background.

    Prepare the server

    Run as root. Verify the new user's SSH login in a second terminal before disabling root login.

    shell
    apt update && apt -y full-upgrade
    apt install -y rsync openssl ufw unattended-upgrades
    timedatectl set-timezone UTC
    adduser deploy
    usermod -aG sudo deploy
    rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

    After successfully signing in as deploy, run as root:

    shell
    cat > /etc/ssh/sshd_config.d/99-hardening.conf <<'CONFIG'
    PermitRootLogin no
    PasswordAuthentication no
    CONFIG
    sshd -t && systemctl restart ssh
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw enable
    fallocate -l 2G /swapfile
    chmod 600 /swapfile
    mkswap /swapfile && swapon /swapfile
    echo '/swapfile none swap sw 0 0' >> /etc/fstab
    dpkg-reconfigure -plow unattended-upgrades

    If SSH uses a nonstandard port, allow that port before enabling UFW. Docker can bypass UFW for published ports, so only bind the app to 127.0.0.1. See cloud firewall.

    Install Docker Engine and Compose

    Run as deploy:

    shell
    sudo apt install -y ca-certificates curl
    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL https://download.docker.com/linux/ubuntu/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/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt update
    sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    sudo usermod -aG docker deploy
    sudo tee /etc/docker/daemon.json > /dev/null <<'JSON'
    {"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}
    JSON
    sudo systemctl restart docker

    Log out and back in to apply group membership; confirm with docker run --rm hello-world and docker compose version.

    Create the signing certificate

    A self-signed certificate works for sealing PDFs, but PDF readers will identify its issuer as untrusted. For trusted signatures obtain a document-signing certificate from a CA. Run as deploy:

    shell
    sudo mkdir -p /opt/documenso/cert
    sudo chown -R deploy:deploy /opt/documenso
    cd /opt/documenso/cert
    openssl rand -hex 24  # save this as your signing passphrase
    openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
      -keyout private.key -out certificate.crt \
      -subj "/CN=Example Corp Document Signing/O=Example Corp"
    openssl pkcs12 -export -out cert.p12 -inkey private.key -in certificate.crt \
      -passout pass:YOUR_SIGNING_PASSPHRASE
    shred -u private.key
    sudo chown 1001:1001 cert.p12
    sudo chmod 400 cert.p12

    Use a non-empty passphrase. Store the certificate and passphrase securely; they are required after a restore.

    Deploy with Docker Compose

    Generate a different value for each secret, then create /opt/documenso/.env:

    shell
    cd /opt/documenso
    openssl rand -hex 24       # database password
    openssl rand -base64 32    # NEXTAUTH_SECRET
    openssl rand -hex 32       # encryption key
    openssl rand -hex 32       # secondary encryption key
    shell
    DOMAIN=sign.example.com
    POSTGRES_PASSWORD=change-me-db
    NEXTAUTH_SECRET=change-me-nextauth
    ENCRYPTION_KEY=change-me-key-1
    ENCRYPTION_SECONDARY_KEY=change-me-key-2
    SIGNING_PASSPHRASE=change-me-signing
    SMTP_HOST=smtp.example.com
    SMTP_PORT=587
    SMTP_USERNAME=smtp-user
    SMTP_PASSWORD=smtp-password
    SMTP_FROM_NAME=Example Corp Signing
    SMTP_FROM_ADDRESS=sign@example.com
    shell
    chmod 600 /opt/documenso/.env

    Keep both encryption keys: changing or losing them can make encrypted database data unreadable. Create /opt/documenso/compose.yml:

    shell
    services:
      documenso-db:
        image: postgres:16
        restart: unless-stopped
        environment:
          POSTGRES_USER: documenso
          POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
          POSTGRES_DB: documenso
        volumes:
          - documenso-db:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U documenso -d documenso"]
          interval: 10s
          timeout: 5s
          retries: 5
    
      documenso:
        image: documenso/documenso:latest
        restart: unless-stopped
        environment:
          PORT: 3000
          NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
          NEXT_PRIVATE_ENCRYPTION_KEY: ${ENCRYPTION_KEY}
          NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY: ${ENCRYPTION_SECONDARY_KEY}
          NEXT_PUBLIC_WEBAPP_URL: https://${DOMAIN}
          NEXT_PRIVATE_INTERNAL_WEBAPP_URL: http://localhost:3000
          NEXT_PRIVATE_DATABASE_URL: postgresql://documenso:${POSTGRES_PASSWORD}@documenso-db:5432/documenso
          NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://documenso:${POSTGRES_PASSWORD}@documenso-db:5432/documenso
          NEXT_PUBLIC_UPLOAD_TRANSPORT: database
          NEXT_PRIVATE_SIGNING_TRANSPORT: local
          NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH: /opt/documenso/cert.p12
          NEXT_PRIVATE_SIGNING_PASSPHRASE: ${SIGNING_PASSPHRASE}
          NEXT_PRIVATE_SMTP_TRANSPORT: smtp-auth
          NEXT_PRIVATE_SMTP_HOST: ${SMTP_HOST}
          NEXT_PRIVATE_SMTP_PORT: ${SMTP_PORT}
          NEXT_PRIVATE_SMTP_USERNAME: ${SMTP_USERNAME}
          NEXT_PRIVATE_SMTP_PASSWORD: ${SMTP_PASSWORD}
          NEXT_PRIVATE_SMTP_FROM_NAME: ${SMTP_FROM_NAME}
          NEXT_PRIVATE_SMTP_FROM_ADDRESS: ${SMTP_FROM_ADDRESS}
        volumes:
          - ./cert/cert.p12:/opt/documenso/cert.p12:ro
        depends_on:
          documenso-db:
            condition: service_healthy
        ports:
          - "127.0.0.1:3000:3000"
    
    volumes:
      documenso-db:
    shell
    cd /opt/documenso
    docker compose config --quiet
    docker compose up -d
    docker compose logs -f documenso

    For SMTP port 465 (implicit TLS), add NEXT_PRIVATE_SMTP_SECURE: "true" under the app environment. Pin the app image to a tested release tag once confirmed.

    Configure Caddy and HTTPS

    shell
    sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
    curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
    sudo apt update && sudo apt install -y caddy

    Put this in /etc/caddy/Caddyfile:

    shell
    sign.example.com {
        encode zstd gzip
        request_body {
            max_size 50MB
        }
        reverse_proxy 127.0.0.1:3000
    }
    shell
    sudo caddy validate --config /etc/caddy/Caddyfile
    sudo systemctl reload caddy

    Caddy obtains an HTTPS certificate when DNS resolves and ports 80/443 are reachable. Increase max_size for larger PDFs.

    First-run setup

    Open https://sign.example.com/signup. Create an account, verify its email (also tests SMTP), and enable two-factor authentication in Settings > Security. Upload a test PDF, send yourself a signature request, sign and inspect the resulting PDF signature. A self-signed issuer warning is expected.

    After creating your team's accounts, add NEXT_PUBLIC_DISABLE_SIGNUP: "true" to the app environment and run docker compose up -d; verify /signup no longer accepts registrations. Create a team for shared templates and set email branding as needed.

    Backups and restore

    The PostgreSQL dump holds documents, signatures and audit logs. Back it up together with .env, compose.yml and cert/cert.p12. Create /opt/documenso/backup.sh:

    shell
    #!/usr/bin/env bash
    set -euo pipefail
    cd /opt/documenso
    TS=$(date +%F-%H%M)
    DEST=/var/backups/documenso
    mkdir -p "$DEST"
    docker compose exec -T documenso-db pg_dump -U documenso -Fc documenso > "$DEST/db-$TS.dump"
    tar czf "$DEST/config-$TS.tar.gz" .env compose.yml cert/cert.p12
    chmod 600 "$DEST"/*
    find "$DEST" -type f -mtime +14 -delete

    Run as root to read the certificate:

    shell
    sudo chmod 700 /opt/documenso/backup.sh
    sudo /opt/documenso/backup.sh
    sudo crontab -e
    # Add: 45 3 * * * /opt/documenso/backup.sh

    Copy the backups off the VPS; for signed contracts choose retention based on legal requirements. See backups and object storage. To restore, unpack the config archive into /opt/documenso, set chown 1001:1001 cert/cert.p12, then:

    shell
    cd /opt/documenso
    docker compose up -d documenso-db
    docker compose exec -T documenso-db pg_restore -U documenso -d documenso --clean --if-exists < /var/backups/documenso/db-YYYY-MM-DD-HHMM.dump
    docker compose up -d

    Updating

    Read release notes before skipping versions. Back up first, update the image tag in compose.yml if pinned, then:

    shell
    cd /opt/documenso
    sudo ./backup.sh
    docker compose pull
    docker compose up -d
    docker compose logs -f documenso

    To roll back, restore the pre-update database and previous image tag. Renew the self-signed certificate before expiry; earlier signed PDFs retain their original signature.

    Troubleshooting

    SymptomCheck
    Caddy certificate failsCheck DNS with dig +short sign.example.com, port 80 and Caddy logs with journalctl -u caddy -f.
    502 responseCheck docker compose ps and docker compose logs documenso; migrations may still be running.
    Signatures remain pendingConfirm .p12 path, UID 1001 ownership and non-empty passphrase.
    No verification/signing emailsVerify SMTP credentials; port 465 requires NEXT_PRIVATE_SMTP_SECURE: "true". See outgoing SMTP policy.
    Email links use localhostSet NEXT_PUBLIC_WEBAPP_URL to the public HTTPS domain and recreate the app container.
    Large PDFs failRaise Caddy's request_body max_size.
    PDF reader says issuer untrustedExpected for a self-signed certificate; use a CA-issued document-signing certificate for trust.