Skip to content
Get Started

How to Self-Host Paperless-ngx with Docker

Paperless-ngx turns scans and PDFs into a searchable document archive. This tutorial deploys version 2.20.15 with Docker Compose on a fresh Debian or Ubuntu VPS, with PostgreSQL 18, Redis 8, and Caddy. The application port is published only on 127.0.0.1:8000, and the first superuser is created from the command line before Caddy exposes the site. Paperless-ngx is licensed under GPL-3.0.

Version 2.20.15 was the current stable release when this article was checked on July 15, 2026. It fixes GHSA-8c6x-pfjq-9gr7, a low-severity flaw that let non-superusers delete superusers (releases before 2.20.14 are affected, 2.20.15 has the fix), and upstream recommends that all users upgrade. Read the 2.20.15 release notes and take a tested backup before upgrading.

A self-hosted Paperless-ngx server is an unmanaged deployment, so you run the operating system, TLS, access control, updates, monitoring, and recovery yourself and keep full control of the stack. Worth knowing up front: Paperless does not encrypt documents, extracted text, or filenames at rest. HTTPS protects traffic in transit; storage and backup encryption are separate decisions.

Prepare the VPS, DNS, and firewall

You need a VPS with root or sudo access, an A record for paperless.example.com, and off-server backup storage. Add an AAAA record only if IPv6 works through the host firewall and Caddy. Allow TCP 80 and 443 for HTTPS, plus the port used by administrative SSH. PostgreSQL, Redis, and Paperless port 8000 must never be public.

Install Docker Engine and the Compose plugin with Docker’s convenience script (or your distribution’s packages, if you’d rather not run a downloaded script as root):

curl -fsSL https://get.docker.com | sudo bash
sudo docker compose version

The remaining commands assume your account can run Docker. Otherwise, prefix each Docker command with sudo or follow Docker’s documented post-install procedure.

Install Caddy, UFW, and OpenSSL. Caddy also provides official package instructions if your distribution needs its repository.

sudo apt update
sudo apt install -y caddy ufw openssl
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

Replace 22 before enabling UFW if SSH uses another port (our SSH hardening guide explains how to move it safely), and mirror the rules in the provider firewall. Docker can bypass some host firewall paths when a port is published, which is another reason to keep the application binding explicitly on 127.0.0.1.

Generate secrets without printing them

Create the project and its two host directories. Paperless watches consume for incoming files and writes application exports under export.

sudo install -d -m 0750 -o "$USER" -g "$USER" /opt/paperless
sudo install -d -m 0750 -o "$USER" -g "$USER" \
  /opt/paperless/consume /opt/paperless/export
cd /opt/paperless

Generate a 256-bit Paperless secret and a 192-bit PostgreSQL password without displaying either value:

umask 077
{
  printf 'PAPERLESS_SECRET_KEY=%s\n' "$(openssl rand -hex 32)"
  printf 'PAPERLESS_DB_PASSWORD=%s\n' "$(openssl rand -hex 24)"
  printf 'USERMAP_UID=%s\n' "$(id -u)"
  printf 'USERMAP_GID=%s\n' "$(id -g)"
} > .env
chmod 600 .env

Keep .env unchanged across restarts and restores, and keep it out of tickets, terminal recordings, and Git. Paperless supports Docker secret files through _FILE variables if your secret-management system provides them; a mode-0600 file keeps this tutorial portable.

Create the Paperless Compose project

Create /opt/paperless/compose.yaml with the following content. It is a hardened single-VPS version of Paperless-ngx’s official PostgreSQL Compose file. The service roles and mount points follow upstream; the random database password, loopback port, webhook restrictions, health dependencies, and log limits are tutorial choices.

services:
  broker:
    image: redis:8
    restart: unless-stopped
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    logging:
      options:
        max-size: "10m"
        max-file: "3"

  db:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_DB: paperless
      POSTGRES_USER: paperless
      POSTGRES_PASSWORD: "${PAPERLESS_DB_PASSWORD:?set PAPERLESS_DB_PASSWORD}"
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U paperless -d paperless"]
      interval: 10s
      timeout: 5s
      retries: 5
    logging:
      options:
        max-size: "10m"
        max-file: "3"

  webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:2.20.15
    restart: unless-stopped
    depends_on:
      broker:
        condition: service_healthy
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:${PAPERLESS_HOST_PORT:-8000}:8000"
    volumes:
      - data:/usr/src/paperless/data
      - media:/usr/src/paperless/media
      - ./export:/usr/src/paperless/export
      - ./consume:/usr/src/paperless/consume
    environment:
      PAPERLESS_REDIS: redis://broker:6379
      PAPERLESS_DBHOST: db
      PAPERLESS_DBNAME: paperless
      PAPERLESS_DBUSER: paperless
      PAPERLESS_DBPASS: "${PAPERLESS_DB_PASSWORD:?set PAPERLESS_DB_PASSWORD}"
      PAPERLESS_URL: "${PAPERLESS_EXTERNAL_URL:-https://paperless.example.com}"
      PAPERLESS_ALLOWED_HOSTS: "${PAPERLESS_ALLOWED_HOSTS:-paperless.example.com}"
      PAPERLESS_SECRET_KEY: "${PAPERLESS_SECRET_KEY:?set PAPERLESS_SECRET_KEY}"
      PAPERLESS_TIME_ZONE: UTC
      PAPERLESS_OCR_LANGUAGE: eng
      PAPERLESS_TASK_WORKERS: "1"
      PAPERLESS_THREADS_PER_WORKER: "1"
      PAPERLESS_PROXY_SSL_HEADER: '["HTTP_X_FORWARDED_PROTO", "https"]'
      PAPERLESS_WEBHOOKS_ALLOWED_PORTS: "80,443"
      PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS: "false"
      USERMAP_UID: "${USERMAP_UID:?set USERMAP_UID}"
      USERMAP_GID: "${USERMAP_GID:?set USERMAP_GID}"
    logging:
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  data:
  media:
  pgdata:
  redisdata:

Replace every paperless.example.com before starting. PAPERLESS_URL must have no trailing slash or path. The explicit allowed host removes the insecure public default of *. Caddy overwrites X-Forwarded-Proto, and only the loopback proxy can reach port 8000, so trusting that header is appropriate for this layout. PostgreSQL and Redis use the major-version tags from the upstream 2.20.15 template, so they pick up patch releases automatically.

Start the private stack and check the listener:

cd /opt/paperless
docker compose up -d --wait --wait-timeout 180
curl -fsSL http://127.0.0.1:8000/ > /dev/null
sudo ss -lntp | grep ':8000'

The listener must show 127.0.0.1:8000, not 0.0.0.0:8000.

Create the superuser before public access

Paperless can create an administrator from environment variables, but that leaves a plaintext password in long-lived configuration. Use the interactive management command from the official setup documentation instead:

cd /opt/paperless
docker compose exec webserver createsuperuser

Choose a non-obvious username, a real administrative email address, and a unique password from a password manager. The command hashes the password in the database and does not add it to .env or shell history. Do not add the public Caddy route until this command succeeds.

Put Caddy in front of Paperless

Paperless requires a TLS reverse proxy for Internet exposure. Its interface uses WebSocket connections, and uploads can exceed the small request-body defaults in some proxy examples. Caddy handles WebSocket upgrades and the standard forwarded headers automatically. Keep this site in a separate imported file so adding Paperless does not erase existing hosts. This block adds an explicit 100 MB proxy-side upload ceiling:

sudo install -d -m 755 /etc/caddy/sites-enabled
sudo tee /etc/caddy/sites-enabled/paperless.caddy > /dev/null <<'CADDY'
paperless.example.com {
    request_body {
        max_size 100MB
    }
    reverse_proxy 127.0.0.1:8000
}
CADDY

if ! sudo grep -Eq \
  '^[[:space:]]*import[[:space:]]+/etc/caddy/sites-enabled/\*' \
  /etc/caddy/Caddyfile; then
  printf '\nimport /etc/caddy/sites-enabled/*\n' | \
    sudo tee -a /etc/caddy/Caddyfile > /dev/null
fi

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -fsSI https://paperless.example.com/

Change the ceiling to match the largest document you intend to accept. A 413 Request Entity Too Large points to the proxy or an application upload limit, while a stalled interface can mean broken WebSocket forwarding. Nginx users must explicitly configure HTTP/1.1 upgrade headers and raise client_max_body_size; a bare proxy_pass is incomplete.

Sign in over HTTPS with the CLI-created account. Enable multi-factor authentication if your identity setup supports it, and do not create broad user accounts merely to feed the consume directory.

Verify a real upload and OCR result

A green health check only proves that the web process answers HTTP. Test the workflow with a harmless image-only scan that contains a unique phrase, such as paperless ocr test 20260714. A phone photograph saved as JPEG works, and it forces Paperless to run OCR rather than read an existing PDF text layer.

Upload the image from the web interface. Watch Tasks until consumption finishes, then open the document and confirm all of the following:

  1. The original image opens and downloads.
  2. The document page contains extracted text with the unique phrase.
  3. Searching for that phrase returns the document.
  4. The archived representation and thumbnail render correctly.

If processing fails, read the consumer and OCR output before raising resource limits:

cd /opt/paperless
docker compose logs --tail=250 webserver

OCR is CPU and memory intensive. Multiple languages increase processing time, and untrusted PDFs or oversized images can exhaust resources or hit bugs in parsers such as Ghostscript and ImageMagick. Keep PAPERLESS_TASK_WORKERS * PAPERLESS_THREADS_PER_WORKER at or below the VPS’s logical CPU count. The conservative one-by-one setting above is intentional. Do not disable image-pixel safeguards or allow anonymous uploads.

Back up with document_exporter

Provider snapshots help with whole-host recovery, but a snapshot taken while PostgreSQL and Paperless are writing may not be application consistent. Paperless’s backup documentation provides document_exporter to export documents, thumbnails, settings, and database content. API tokens are not included and must be recreated after recovery.

Pause watched mailboxes, scanners, and uploads, then wait for the Tasks view to become idle. Run the official exporter through Compose and checksum the result:

cd /opt/paperless
backup="backup-$(date +%F-%H%M%S)"
docker compose exec -T webserver \
  document_exporter "../export/$backup" --no-progress-bar
find "export/$backup" -type f -print0 \
  | sort -z \
  | xargs -0 -r sha256sum > "export/$backup.sha256"

Record the Paperless version (2.20.15) alongside the export; the importer requires an exact version match. Copy the export, checksum, compose.yaml, Caddy configuration, and encrypted secret material off the VPS. The export contains the documents you were trying to protect, so encrypt it and restrict access at the destination. A copy on the same disk is not a backup.

Rehearse an isolated same-version restore

Paperless requires document_importer to run against an empty installation of exactly the same version. The drill below builds that empty installation beside production, in its own Compose project on an alternate loopback port. A separate test VPS is safer, since a same-host drill shares the Docker daemon, RAM, disk, and port space with production; if you use one, copy the Compose file, .env, and the latest export there first and adjust the source paths.

For a same-host drill, keep all commands in one shell:

set -Eeuo pipefail
restore_project="paperless-restore-$(date +%Y%m%d%H%M%S)"
restore_dir="/opt/$restore_project"
backup_dir=$(find /opt/paperless/export -mindepth 1 -maxdepth 1 \
  -type d -name 'backup-*' | sort | tail -n 1)
backup_name=$(basename "$backup_dir")
sudo install -d -m 0700 -o "$USER" -g "$USER" \
  "$restore_dir/consume" "$restore_dir/export"
sudo install -m 0600 -o "$USER" -g "$USER" \
  /opt/paperless/.env "$restore_dir/.env"
sudo install -m 0640 -o "$USER" -g "$USER" \
  /opt/paperless/compose.yaml "$restore_dir/compose.yaml"
sudo cp -a "$backup_dir" "$restore_dir/export/"
sudo chown -R "$USER":"$USER" "$restore_dir"
cd "$restore_dir"
export PAPERLESS_HOST_PORT=18000
export PAPERLESS_EXTERNAL_URL=http://127.0.0.1:18000
export PAPERLESS_ALLOWED_HOSTS=127.0.0.1,localhost

docker compose -p "$restore_project" up -d --wait --wait-timeout 180
docker compose -p "$restore_project" exec webserver \
  document_importer "../export/$backup_name"
docker compose -p "$restore_project" exec webserver document_sanity_checker
curl -fsSL http://127.0.0.1:18000/ > /dev/null

The timestamped project name gives the drill its own containers, volumes, and network, so document_importer runs against a fresh, empty database. The importer still requires the export and target to use exactly the same Paperless version; an empty newer installation is not compatible.

Tunnel port 18000 over SSH, log in with a restored account, and inspect several originals, archived files, thumbnails, metadata, permissions, and searches. Recreate an API token if an integration needs one. The sanity checker catches internal inconsistencies, but it cannot prove that every document is readable or that an external integration still works.

After the rehearsal, remove the restore project and its isolated volumes:

cd "$restore_dir"
docker compose -p "$restore_project" down -v

Never run the importer over the production installation, and never use down -v in /opt/paperless; there it deletes the production volumes.

Upgrade and roll back deliberately

Before an upgrade, stop new ingestion, wait for active tasks, run document_sanity_checker, create a fresh exporter backup, and read every release note between the installed and target versions. Then update the version tag in compose.yaml.

Paperless’s official Docker procedure stops the stack, pulls, and starts it in the foreground so database migrations remain visible:

cd /opt/paperless
docker compose exec webserver document_sanity_checker
docker compose down
docker compose pull
docker compose up

Watch for migration or worker errors. In another SSH session, test login, search, and a fresh OCR upload. Then stop the foreground run with Ctrl+C and return it to detached mode:

cd /opt/paperless
docker compose up -d --wait --wait-timeout 180
docker compose exec webserver document_sanity_checker

Do not roll back by pointing an older image at a database migrated by a newer release. A safe rollback uses the pre-upgrade export with the old, exact Paperless version in a new empty deployment. Rehearse it on an alternate port, then switch Caddy only after documents, search, permissions, and OCR pass. PostgreSQL major upgrades need PostgreSQL’s own migration or a logical dump and restore; do not bundle them into a routine Paperless update.

Keep Tika and Gotenberg optional

The baseline handles the normal PDF and image workflow without Tika or Gotenberg. Enable the pair only if you need Office documents or email formats that require conversion. Paperless’s official Tika Compose template adds apache/tika and Gotenberg, then sets PAPERLESS_TIKA_ENABLED=1 and internal endpoints for both services.

That template uses gotenberg/gotenberg:8.25 and apache/tika:latest. The latter is a mutable tag; pin a specific version if you want repeatable deployments. Do not publish ports 3000 or 9998. Preserve the upstream Gotenberg arguments --chromium-disable-javascript=true and --chromium-allow-list=file:///tmp/.*; they reduce remote-content and JavaScript exposure while converting email. Tika and Gotenberg parse more untrusted formats and use more CPU and RAM, so they expand both the attack surface and the sizing requirement.

Choosing a Riven Cloud VPS for Paperless-ngx

For a personal document archive with the conservative one-worker OCR settings above, our Premium plan (2 vCPU, 4 GB RAM, 40 GB NVMe, 1 TB monthly transfer) is a comfortable starting point. Move up to Ultra (2 vCPU, 8 GB RAM, 80 GB NVMe, 2 TB transfer) if you want faster OCR on a large backlog or plan to enable Tika and Gotenberg, and to Max (4 vCPU, 16 GB RAM, 160 GB NVMe, 4 TB transfer) when the originals, archived files, thumbnails, and PostgreSQL start crowding the disk. Storage tends to grow long after the initial import, while an OCR backlog only needs CPU for a while, so watching disk usage usually tells you first when it is time to resize. If the archive will share its VPS with other services, our roundup of self-hosted apps is a good place to gauge what co-hosts comfortably alongside an OCR workload.

All three plans include a 1 Gbps port, full root access, daily backups, and a choice of Tokyo or Singapore. The daily backups are a good safety net for whole-host recovery; keep the application-consistent exporter backups and the restore drill above in place as well. If you or your users connect from mainland China, take a moment to test the Tokyo Looking Glass and Singapore Looking Glass from your own network before choosing a location, then compare the current Riven Cloud VPS plans.

Security and privacy limits

Paperless removed its old built-in document encryption. Original and archived files are readable by the application, extracted document text is stored in plaintext in PostgreSQL, and filenames are not encrypted. Use an encrypted filesystem or encrypted block volume if you need protection from physical-media access, and always encrypt off-server exports. Access controls, TLS, and disk encryption solve different problems.

Workflow webhooks also deserve attention. Upstream defaults allow HTTP and HTTPS on any port and permit internal requests. The environment values above restrict ports to 80 and 443 and disable internal webhook requests, which reduces server-side request forgery risk. Treat those settings as one layer; container egress rules should also block cloud metadata and management networks, especially when less-trusted users can create workflows.

Do not accept uploads from anonymous or untrusted users. OCR and document parsers handle complex, attacker-controlled files and have no blanket sandbox guarantee from Paperless. Keep Paperless, the host, and every parser image patched; retain image-pixel limits; cap proxy upload size; and monitor CPU, memory, disk, and task queues.

Self-hosting Paperless-ngx works well when someone owns those controls, application-aware backups, restore tests, and security updates. If the archive needs built-in per-document encryption, contractual high availability, or formal records controls, a managed document system is the better home for it; a single unmanaged VPS is not designed to provide those guarantees.

Share