(302) 414-9101
1001 S Main St, STE 600, Kalispell, MT 59901
contact@zarghamlabs.com

Docker + Traefik: Production Deployment Guide for SaaS Applications in 2026

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

Internet → Traefik (80/443) → [FastAPI Backend, Next.js Frontend, n8n, Grafana]

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.

Leave A Comment