Why Docker + Traefik Is the Go-To Stack for SaaS Deployments
When you’re a solo founder or small team deploying a SaaS application to a single VPS or dedicated server, you need a setup that: automatically provisions SSL certificates, routes requests to the right service, handles zero-downtime container restarts, and is understandable enough to debug at 2am when something breaks. Docker Compose with Traefik delivers all of this with minimal configuration — which is why it powers production deployments for thousands of indie SaaS products, including Messenjo.
This guide covers the complete production setup: Traefik configuration, service routing, automatic HTTPS via Let’s Encrypt, health checks, and deployment workflows.
Architecture Overview
Traefik responsibilities:
– SSL termination (Let’s Encrypt auto-renewal)
– Route /api/* → fastapi:8000
– Route app.domain.com → nextjs:3000
– Route n8n.domain.com → n8n:5678
– Health checks + automatic container restart
Project Structure
myapp/
├── docker-compose.yml
├── docker-compose.prod.yml
├── traefik/
│ ├── traefik.yml # Static config
│ └── dynamic/
│ └── middlewares.yml # Reusable middleware definitions
├── backend/
│ ├── Dockerfile
│ └── ...
├── frontend/
│ ├── Dockerfile
│ └── ...
└── .env.prod # Production secrets (not in git)
Traefik Static Configuration
# traefik/traefik.yml
global:
checkNewVersion: false
sendAnonymousUsage: false
log:
level: INFO
format: json
accessLog:
format: json
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
http:
tls:
certResolver: letsencrypt
certificatesResolvers:
letsencrypt:
acme:
email: your@email.com
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false # Only expose containers with traefik.enable=true
network: traefik_proxy
file:
directory: /traefik/dynamic
watch: true
api:
dashboard: true
insecure: false # Dashboard only via HTTPS
Docker Compose Configuration
# docker-compose.yml
version: "3.9"
networks:
traefik_proxy:
external: true
internal:
driver: bridge
volumes:
letsencrypt:
postgres_data:
redis_data:
services:
traefik:
image: traefik:v3.0
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./traefik/traefik.yml:/traefik/traefik.yml:ro
- ./traefik/dynamic:/traefik/dynamic:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
- traefik_proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.yourdomain.com`)"
- "traefik.http.routers.dashboard.tls=true"
- "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.dashboard.service=api@internal"
- "traefik.http.routers.dashboard.middlewares=auth"
- "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$..."
backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
restart: unless-stopped
env_file: .env.prod
networks:
- traefik_proxy
- internal
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "traefik.enable=true"
- "traefik.http.routers.backend.rule=Host(`api.yourdomain.com`)"
- "traefik.http.routers.backend.tls=true"
- "traefik.http.routers.backend.tls.certresolver=letsencrypt"
- "traefik.http.services.backend.loadbalancer.server.port=8000"
- "traefik.http.routers.backend.middlewares=security-headers,rate-limit"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
restart: unless-stopped
env_file: .env.prod
networks:
- traefik_proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.frontend.rule=Host(`app.yourdomain.com`)"
- "traefik.http.routers.frontend.tls=true"
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.frontend.loadbalancer.server.port=3000"
postgres:
image: postgres:15-alpine
restart: unless-stopped
env_file: .env.prod
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- internal
healthcheck:
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "$REDIS_PASSWORD", "ping"]
interval: 10s
timeout: 5s
retries: 5
Reusable Middleware Configuration
# traefik/dynamic/middlewares.yml
http:
middlewares:
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"
customResponseHeaders:
X-Powered-By: ""
Server: ""
rate-limit:
rateLimit:
average: 100
burst: 50
period: 1m
compress:
compress: {}
redirect-www:
redirectRegex:
regex: "^https://www\\.(.*)"
replacement: "https://${1}"
permanent: true
Zero-Downtime Deployment Script
#!/bin/bash
# deploy.sh — pull, build, and restart with zero downtime
set -e
echo "🚀 Starting deployment..."
# Pull latest code
git pull origin main
# Build new images
docker compose -f docker-compose.yml build backend frontend
# Rolling restart — Traefik handles traffic during restart
docker compose -f docker-compose.yml up -d --no-deps backend
sleep 10 # Wait for health check to pass
docker compose -f docker-compose.yml up -d --no-deps frontend
# Cleanup old images
docker image prune -f
echo "✅ Deployment complete"
# Verify health
curl -f https://api.yourdomain.com/health || echo "⚠️ Health check failed!"
Server Setup Prerequisites
# On a fresh Ubuntu 22.04 VPS:
# Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker $USER
# Create the external Traefik network (create once, referenced by all stacks)
docker network create traefik_proxy
# Create acme.json for Let's Encrypt (must be 600 permissions)
mkdir -p traefik/letsencrypt
touch traefik/letsencrypt/acme.json
chmod 600 traefik/letsencrypt/acme.json
# Point your DNS A records before starting:
# api.yourdomain.com → server IP
# app.yourdomain.com → server IP
# traefik.yourdomain.com → server IP
# Start everything
docker compose up -d
Common Issues and Fixes
| Issue | Cause | Fix |
|---|---|---|
| SSL cert not issued | DNS not propagated or port 80 blocked | Check DNS, ensure port 80 open in firewall |
| 502 Bad Gateway | Container not on traefik_proxy network | Add networks: - traefik_proxy to service |
| acme.json permission error | File permissions not 600 | chmod 600 acme.json |
| Service not appearing in Traefik | Missing traefik.enable=true label |
Add label to container |
| Let’s Encrypt rate limit hit | Too many cert requests (5/week per domain) | Use staging CA for testing first |
Scaling from Single Server to Kubernetes
The Docker Compose + Traefik setup described here scales comfortably to handling thousands of concurrent users on a single $40–80/month VPS. When you outgrow a single server, the migration path to Kubernetes is straightforward — Traefik has a first-class Kubernetes Ingress controller that uses the same concepts. Your service labels become Ingress annotations, and your Compose services become Kubernetes Deployments. The architectural knowledge transfers directly.
For the full infrastructure picture, see our guides on multi-tenant SaaS architecture, Celery + Redis background task queues, and self-hosting n8n on a VPS.
