MeshWorld India LogoMeshWorld.

Production Docker Compose Stack: Traefik SSL, PostgreSQL & Backups

Vishnu
By Vishnu
|Updated: Aug 13, 2026
Production Docker Compose Stack: Traefik SSL, PostgreSQL & Backups

:::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:

bash
mkdir -p /opt/production-stack/{traefik,backups,data}
cd /opt/production-stack
docker network create traefik-public
docker network create internal-backend --internal

The --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:

yaml
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.txt

Before launching:

bash
# Generate a secure password — don't use "password123"
openssl rand -base64 32 > db_password.txt
chmod 600 db_password.txt

docker compose up -d

docker 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:

bash
# 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_db

You 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.


Share_This Twitter / X
Vishnu
Written By

Vishnu

Founder & Principal Architect at MeshWorld. Senior engineer and instructor specializing in AI agent systems, scalable web architecture, and modern development workflows.

Enjoyed this article?

Support MeshWorld and help us create more technical content