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.
TL;DR
- Keep
webserveron bothproxyandpaperless, and keepdbplusbrokeronpaperlessonly. - Route through Traefik labels to container port
8000; do not publish Paperless host ports. deploy.shhas 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.yamldocker/paperless-ngx/docker-compose.envscripts/deploy.shdocs/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 (
webserveronproxy). - Postgres and Redis are not attached to
proxy. paperlessisinternal: 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/shmon Linux,$TMPDIRon 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:
webserverattached to bothproxyandpaperless.dbandbrokerattached only topaperless.
Security notes
-
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.
-
The main residual risk
webserveris the bridge between both networks. If Paperless is compromised, the attacker has a direct path to Redis/Postgres. -
Current config mismatch that matters In this snapshot,
deploy.shfetchesPAPERLESS_DBPASSandPAPERLESS_SECRET_KEY, but the Paperless Compose config still includes:POSTGRES_PASSWORD: paperlessPAPERLESS_SECRET_KEY=change-meindocker-compose.env
Result: secret retrieval is implemented, but Paperless secret consumption is not fully wired.
-
Tag stability
ghcr.io/paperless-ngx/paperless-ngx:latest,redis:8, andpostgres:18are mutable tag strategies. Good for convenience, weaker for reproducibility and controlled rollbacks. -
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
- Network segmentation is simple to declare and easy to verify, so it should be a default.
- Secret retrieval is not secret usage. Fetching from a vault is useless unless Compose consumes those values.
- The deploy script already handles hard parts well: preflight checks, idempotent context setup, and cleanup on exit.
- 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.