Running Paperless-ngx Behind Traefik with Internal Network Segmentation (Redis + Postgres)

A homelab-backed Paperless-ngx + Traefik deployment with segmented Redis/Postgres networks, concrete checks, and security hardening lessons.

Tools

TL;DR

  • Keep webserver on both proxy and paperless, and keep db plus broker on paperless only.
  • Route through Traefik labels to container port 8000; do not publish Paperless host ports.
  • deploy.sh has strong deploy hygiene, but this snapshot still hardcodes weak Paperless defaults in Compose/env files.
  • Treat this as production-ready only after wiring deploy-time secrets into Compose and pinning image versions.

The evidence behind this post

This walkthrough is based on four files in my homelab repo:

  • docker/paperless-ngx/compose.yaml
  • docker/paperless-ngx/docker-compose.env
  • scripts/deploy.sh
  • docs/deployment-guide.md

Everything below maps directly to those files. Anything opinionated is clearly called out as a recommendation.

Architecture: dual-homed app, isolated stateful services

Current Compose shape (trimmed to relevant lines):

services:
  broker:
    image: redis:8
    networks:
      - paperless

  db:
    image: postgres:18
    networks:
      - paperless
    environment:
      POSTGRES_PASSWORD: paperless

  webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:latest
    env_file:
      - docker-compose.env
    environment:
      PAPERLESS_REDIS: redis://broker:6379
      PAPERLESS_DBHOST: db
    networks:
      - proxy
      - paperless
    labels:
      - traefik.enable=true
      - traefik.http.routers.paperless.rule=Host(`paperless.subdepthtech.org`)
      - traefik.http.routers.paperless.entrypoints=websecure
      - traefik.http.routers.paperless.tls=true
      - traefik.http.routers.paperless.tls.certresolver=cloudflare
      - traefik.http.services.paperless.loadbalancer.server.port=8000

networks:
  proxy:
    external: true
  paperless:
    internal: true

This gives you a clean boundary:

  • Traefik can reach Paperless (webserver on proxy).
  • Postgres and Redis are not attached to proxy.
  • paperless is internal: true, so Docker does not provide external connectivity for that network.

Walkthrough: deploy and verify the segmented setup

1) Keep non-secret app settings in docker-compose.env

Current repo values:

PAPERLESS_TIME_ZONE=America/New_York
PAPERLESS_OCR_LANGUAGE=eng
PAPERLESS_URL=https://paperless.subdepthtech.org
PAPERLESS_SECRET_KEY=change-me
USERMAP_UID=1000
USERMAP_GID=1000

PAPERLESS_URL, timezone, and OCR settings belong here. PAPERLESS_SECRET_KEY=change-me is fine for local bootstrap, not for a hardened deployment.

2) Let the deploy script handle secret materialization

scripts/deploy.sh does several important things correctly:

  • fetches secrets from Proton Pass (pass://homelab/...)
  • writes temporary secrets into a secure temp dir (/dev/shm on Linux, $TMPDIR on macOS)
  • overwrites and deletes temp files on exit
  • deploys with a remote SSH Docker context

Key deployment command excerpt:

docker --context "$CONTEXT_NAME" compose \
  -f "$compose_file" \
  --env-file "$merged_env" \
  up -d --remove-orphans

This is the right pattern for remote deployments without committing secrets to Git.

Important caveat for this specific service: the script currently fetches PAPERLESS_DBPASS and PAPERLESS_SECRET_KEY, but docker/paperless-ngx/compose.yaml does not consume those variables yet.

3) Dry-run and deploy Paperless only

./scripts/deploy.sh paperless-ngx --dry-run
./scripts/deploy.sh paperless-ngx

The same script also ensures the external proxy network exists on the remote Docker host before deployment.

4) Verify runtime state

docker --context homelab-remote compose -f docker/paperless-ngx/compose.yaml ps
docker --context homelab-remote compose -f docker/paperless-ngx/compose.yaml logs --tail 50

Check network attachment explicitly:

WEB_ID=$(docker --context homelab-remote compose -f docker/paperless-ngx/compose.yaml ps -q webserver)
DB_ID=$(docker --context homelab-remote compose -f docker/paperless-ngx/compose.yaml ps -q db)
BROKER_ID=$(docker --context homelab-remote compose -f docker/paperless-ngx/compose.yaml ps -q broker)

docker --context homelab-remote inspect "$WEB_ID" --format '{{json .NetworkSettings.Networks}}'
docker --context homelab-remote inspect "$DB_ID" --format '{{json .NetworkSettings.Networks}}'
docker --context homelab-remote inspect "$BROKER_ID" --format '{{json .NetworkSettings.Networks}}'

Expected result:

  • webserver attached to both proxy and paperless.
  • db and broker attached only to paperless.

Security notes

  1. What this segmentation protects The internal network keeps Postgres and Redis off the public-facing proxy path. That removes a common exposure class where stateful backends accidentally end up routable.

  2. The main residual risk webserver is the bridge between both networks. If Paperless is compromised, the attacker has a direct path to Redis/Postgres.

  3. Current config mismatch that matters In this snapshot, deploy.sh fetches PAPERLESS_DBPASS and PAPERLESS_SECRET_KEY, but the Paperless Compose config still includes:

    • POSTGRES_PASSWORD: paperless
    • PAPERLESS_SECRET_KEY=change-me in docker-compose.env

    Result: secret retrieval is implemented, but Paperless secret consumption is not fully wired.

  4. Tag stability ghcr.io/paperless-ngx/paperless-ngx:latest, redis:8, and postgres:18 are mutable tag strategies. Good for convenience, weaker for reproducibility and controlled rollbacks.

  5. Practical mitigations

    • use variable substitution in Compose for DB password and secret key
    • remove secret values from tracked env files
    • pin image tags (or digests) and update intentionally
    • keep remote Docker access constrained to a dedicated deploy identity

Lessons learned

  1. Network segmentation is simple to declare and easy to verify, so it should be a default.
  2. Secret retrieval is not secret usage. Fetching from a vault is useless unless Compose consumes those values.
  3. The deploy script already handles hard parts well: preflight checks, idempotent context setup, and cleanup on exit.
  4. The highest-risk failures here are boring defaults (change-me, static DB passwords, floating tags), not exotic exploits.

What I’d do differently

First change: wire deploy-time secrets into compose.yaml so missing values fail fast.

db:
  environment:
    POSTGRES_DB: paperless
    POSTGRES_USER: paperless
    POSTGRES_PASSWORD: ${PAPERLESS_DBPASS:?set_by_deploy}

webserver:
  environment:
    PAPERLESS_DBHOST: db
    PAPERLESS_DBPASS: ${PAPERLESS_DBPASS:?set_by_deploy}
    PAPERLESS_SECRET_KEY: ${PAPERLESS_SECRET_KEY:?set_by_deploy}
    PAPERLESS_REDIS: redis://broker:6379

Second change: stop shipping PAPERLESS_SECRET_KEY=change-me in tracked env files and keep that value vault-only.

Third change: pin deployable image versions (or digests) and promote updates intentionally, not because latest changed overnight.