:::info[Quick Answer]
A production Docker Compose stack needs Traefik v3 for automatic Let’s Encrypt TLS, network isolation via internal Docker bridges, non-root PostgreSQL volumes, and a pg_dump sidecar that backs up database dumps to S3-compatible storage on a schedule.
:::
Running docker compose up and calling it production is how you lose data at 3 AM. Real production stacks need automatic SSL renewal, database isolation from the public internet, secrets that aren’t hardcoded in YAML, and backups that actually run without you remembering.
This is the blueprint I use — Traefik v3, PostgreSQL 16, and a daily backup sidecar. Part of the broader self-hosted DevOps stack we covered earlier.
Stack Topology
graph TD
User([Public Client Traffic]) -->|Port 80/443| Traefik[Traefik v3 Edge Proxy]
subgraph Public External Network
Traefik -->|TLS Challenge| LetsEncrypt[Let's Encrypt ACME Resolver]
end
subgraph Private Internal Bridge Network
Traefik -->|Internal HTTP Proxy| WebApp[Application Container]
WebApp -->|Internal Port 5432| Postgres[(PostgreSQL 16 DB)]
BackupContainer[S3 Backup Sidecar] -->|pg_dump Cron| Postgres
end
BackupContainer -->|Encrypted S3 Upload| S3Storage[(AWS S3 / Cloudflare R2)]Public traffic hits Traefik only. Postgres lives on an internal network with no route to the internet. Backups run on a sidecar that talks to Postgres internally and pushes dumps to S3.
Step 1: Create Isolated Networks
Two networks — one public-facing, one internal-only:
mkdir -p /opt/production-stack/{traefik,backups,data}
cd /opt/production-stack
docker network create traefik-public
docker network create internal-backend --internalThe --internal flag on internal-backend means containers on that network can’t reach the internet. Postgres doesn’t need outbound access — only your app container needs to talk to it.
Verify with docker network ls — you should see traefik-public (bridge) and internal-backend (internal, no external gateway).
Step 2: The Full docker-compose.yml
Create /opt/production-stack/docker-compose.yml:
version: '3.8'
networks:
traefik-public:
external: true
internal-backend:
external: true
volumes:
traefik-acme:
postgres-data:
services:
traefik:
image: traefik:v3.0
container_name: traefik
restart: always
security_opt:
- no-new-privileges:true
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--certificatesresolvers.myresolver.acme.httpchallenge=true"
- "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.myresolver.acme.email=admin@example.com"
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
networks:
- traefik-public
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-acme:/letsencrypt
postgres:
image: postgres:16-alpine
container_name: postgres-db
restart: always
environment:
POSTGRES_DB: app_db
POSTGRES_USER: app_user
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
networks:
- internal-backend
volumes:
- postgres-data:/var/lib/postgresql/data
db-backup:
image: prodrigestivill/postgres-backup-local:16-alpine
container_name: postgres-backup-sidecar
restart: always
environment:
POSTGRES_HOST: postgres-db
POSTGRES_DB: app_db
POSTGRES_USER: app_user
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
SCHEDULE: "@daily"
BACKUP_KEEP_DAYS: 7
BACKUP_KEEP_WEEKS: 4
BACKUP_KEEP_MONTHS: 6
secrets:
- db_password
networks:
- internal-backend
depends_on:
- postgres
secrets:
db_password:
file: ./db_password.txtBefore launching:
# Generate a secure password — don't use "password123"
openssl rand -base64 32 > db_password.txt
chmod 600 db_password.txt
docker compose up -ddocker compose ps should show three healthy containers. Notice Postgres has no ports: mapping — port 5432 isn’t exposed to your host or the internet. Only containers on internal-backend can reach it.
Swap admin@example.com for your real email — Let’s Encrypt uses it for expiry warnings.
Step 3: Verify Backups Actually Work
Don’t wait for the daily cron to find out backups are broken. Test manually:
# Trigger an immediate backup
docker exec -it postgres-backup-sidecar /backup.sh
# List backup files
docker exec -it postgres-backup-sidecar ls -la /backups
# Test restore (dry run — be careful on production)
gunzip -c /backups/2026-08-13T00-00-00.sql.gz | docker exec -i postgres-db psql -U app_user -d app_dbYou should see timestamped .sql.gz files. If the backup directory is empty, check the sidecar logs: docker logs postgres-backup-sidecar.
I’ve seen teams run production for months assuming backups work because the sidecar container is “running.” Run the manual backup test on day one. Schedule a quarterly restore drill after that.
FAQ
Why Traefik over Nginx?
Traefik watches the Docker socket. Add a container with Traefik labels, and routing + SSL happen automatically. With Nginx you’re editing config files and running certbot manually every 90 days. For Docker Compose stacks, Traefik saves hours.
How do I keep Postgres off the public internet?
Never add ports: "5432:5432" to the Postgres service. Put it on an internal network only. If you need local debugging access, use docker exec -it postgres-db psql -U app_user -d app_db instead of exposing the port.
How do I push backups to S3 or Cloudflare R2?
The prodrigestivill/postgres-backup-local image stores dumps locally by default. For S3 upload, add an rclone sidecar with your S3 credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET) or swap to an S3-native backup image. Mount a shared volume between the backup sidecar and rclone so dumps get synced off-server automatically.
What to Read Next
- Uptime Kuma Docker Setup & Alerting — get pinged when this stack goes down
- Top 10 Self-Hosted DevOps Tools — Coolify, Portainer, and the rest of the stack
- DeepSeek R1 Local Setup — run AI on the same VPS



