When you run multiple Docker applications on a single server, you need a reverse proxy to route incoming traffic to the right container. Traefik is purpose-built for this: it integrates natively with Docker, discovers services automatically through container labels, provisions Let's Encrypt SSL certificates without manual intervention, and provides a real-time monitoring dashboard — all from a single Docker container.

This guide walks through the complete Traefik setup on an Ubuntu VPS: installing Traefik as a Docker container, configuring automatic HTTPS with Let's Encrypt, deploying multiple applications with Docker labels for service discovery, setting up the monitoring dashboard, implementing rate limiting and security middleware, and hardening the entire stack for production use.

Prerequisites

Before starting, you need:

MassiveGRID Ubuntu VPS — Ubuntu 24.04 LTS pre-installed, Proxmox HA cluster with automatic failover, Ceph 3x replicated NVMe storage, independent CPU/RAM/storage scaling, 12 Tbps DDoS protection, 4 global datacenter locations, 100% uptime SLA, and 24/7 human support rated 9.5/10. Deploy a Cloud VPS from $1.99/mo.

Why Traefik Over Nginx for Docker

Both Nginx and Traefik work as reverse proxies, but they differ fundamentally in how they discover and route to backend services.

With Nginx as a reverse proxy (covered in our Nginx reverse proxy guide), you manually write configuration files for each application, obtain SSL certificates with Certbot, and reload Nginx whenever you add or remove a service. This works well for static deployments but becomes tedious when you frequently deploy, scale, or replace containers.

With Traefik, you define routing rules as Docker labels on each container. When a container starts, Traefik detects it automatically and begins routing traffic. When a container stops, Traefik removes the route. SSL certificates are provisioned and renewed without any manual steps. No configuration file edits, no reloads.

Aspect Nginx + Certbot Traefik
Service discovery Manual config files Automatic via Docker labels
SSL certificates Certbot (separate tool) Built-in Let's Encrypt
Adding a new app Write config, get cert, reload Add labels, start container
Removing an app Delete config, reload Stop container (automatic)
Dashboard Requires separate setup Built-in web UI
Middleware Config directives Composable middleware chain
Best for Static deployments, high performance Dynamic Docker environments

Use Traefik when you run multiple Docker containers that change frequently. Use Nginx when you have a fixed set of services and need maximum raw performance or fine-grained configuration control.

Project Directory Structure

Create a clean directory structure for Traefik and your applications:

sudo mkdir -p /opt/traefik
sudo mkdir -p /opt/apps

All Traefik configuration will live in /opt/traefik, and individual application compose files in /opt/apps/ subdirectories.

Creating the Docker Network

Traefik and your application containers need to share a Docker network. Create an external network that all compose files will reference:

docker network create web

This network allows Traefik to communicate with any container attached to it, regardless of which Docker Compose project the container belongs to.

Traefik Configuration

Traefik uses two types of configuration: static configuration (loaded once at startup) and dynamic configuration (discovered from Docker labels or file providers at runtime).

Create the static configuration file:

sudo nano /opt/traefik/traefik.yml
# Traefik static configuration

# API and dashboard
api:
  dashboard: true
  insecure: false

# Entry points (ports Traefik listens on)
entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"
    http:
      tls:
        certResolver: letsencrypt

# Certificate resolvers
certificatesResolvers:
  letsencrypt:
    acme:
      email: admin@yourdomain.com
      storage: /letsencrypt/acme.json
      httpChallenge:
        entryPoint: web

# Docker provider
providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: web

# Logging
log:
  level: INFO

accessLog:
  filePath: "/var/log/traefik/access.log"
  bufferingSize: 100

Key configuration options explained:

Create the certificate storage file with the correct permissions:

sudo mkdir -p /opt/traefik/letsencrypt
sudo touch /opt/traefik/letsencrypt/acme.json
sudo chmod 600 /opt/traefik/letsencrypt/acme.json

Create the log directory:

sudo mkdir -p /var/log/traefik

Deploying Traefik with Docker Compose

Create the Traefik Docker Compose file:

sudo nano /opt/traefik/docker-compose.yml
services:
  traefik:
    image: traefik:v3.3
    container_name: traefik
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yml:/traefik.yml:ro
      - ./letsencrypt:/letsencrypt
      - /var/log/traefik:/var/log/traefik
    networks:
      - web
    labels:
      # Enable Traefik for itself (for the dashboard)
      - "traefik.enable=true"

      # Dashboard router
      - "traefik.http.routers.dashboard.rule=Host(`traefik.yourdomain.com`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dashboard.service=api@internal"

      # Dashboard authentication (basic auth)
      - "traefik.http.routers.dashboard.middlewares=dashboard-auth"
      - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$10$$your-bcrypt-hash-here"

networks:
  web:
    external: true

Generating the Dashboard Password

The dashboard should be protected with authentication. Generate a bcrypt-hashed password using htpasswd:

sudo apt install -y apache2-utils
htpasswd -nbB admin your-secure-password

This outputs something like:

admin:$2y$05$Gf3aX...long-hash...

In the Docker Compose file, escape every $ sign by doubling it ($$). Replace the basicauth.users label value with your generated hash (with escaped dollar signs).

Starting Traefik

cd /opt/traefik
docker compose up -d

Verify Traefik is running:

docker compose logs -f traefik

You should see Traefik starting up, connecting to Docker, and listening on ports 80 and 443. If everything is working, the dashboard will be accessible at https://traefik.yourdomain.com (after DNS propagation and certificate issuance).

Deploying Multiple Applications

Now let's deploy several applications behind Traefik. Each application gets its own Docker Compose file with Traefik labels defining the routing rules.

Application 1: WordPress

sudo mkdir -p /opt/apps/wordpress
sudo nano /opt/apps/wordpress/docker-compose.yml
services:
  wordpress:
    image: wordpress:6.7-php8.3-apache
    container_name: wordpress
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: wordpress-db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: your-db-password
      WORDPRESS_DB_NAME: wordpress
    volumes:
      - wordpress_data:/var/www/html
    networks:
      - web
      - wordpress-internal
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.wordpress.rule=Host(`blog.yourdomain.com`)"
      - "traefik.http.routers.wordpress.entrypoints=websecure"
      - "traefik.http.routers.wordpress.tls.certresolver=letsencrypt"
      - "traefik.http.services.wordpress.loadbalancer.server.port=80"

  wordpress-db:
    image: mariadb:11
    container_name: wordpress-db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: your-root-password
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: your-db-password
    volumes:
      - wordpress_db_data:/var/lib/mysql
    networks:
      - wordpress-internal

volumes:
  wordpress_data:
  wordpress_db_data:

networks:
  web:
    external: true
  wordpress-internal:
    internal: true

Start the WordPress stack:

cd /opt/apps/wordpress
docker compose up -d

Traefik automatically detects the WordPress container, provisions an SSL certificate for blog.yourdomain.com, and routes HTTPS traffic to it. The MariaDB container is on an internal network and is not exposed to Traefik.

Application 2: REST API (Node.js)

sudo mkdir -p /opt/apps/api
sudo nano /opt/apps/api/docker-compose.yml
services:
  api:
    image: node:22-alpine
    container_name: api
    restart: unless-stopped
    working_dir: /app
    command: node server.js
    volumes:
      - ./app:/app
    environment:
      NODE_ENV: production
      PORT: 3000
    networks:
      - web
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`api.yourdomain.com`)"
      - "traefik.http.routers.api.entrypoints=websecure"
      - "traefik.http.routers.api.tls.certresolver=letsencrypt"
      - "traefik.http.services.api.loadbalancer.server.port=3000"

      # Rate limiting for API
      - "traefik.http.routers.api.middlewares=api-ratelimit"
      - "traefik.http.middlewares.api-ratelimit.ratelimit.average=100"
      - "traefik.http.middlewares.api-ratelimit.ratelimit.burst=50"
      - "traefik.http.middlewares.api-ratelimit.ratelimit.period=1m"

networks:
  web:
    external: true
cd /opt/apps/api
docker compose up -d

Application 3: Static Site (Nginx)

sudo mkdir -p /opt/apps/static-site
sudo mkdir -p /opt/apps/static-site/html
sudo nano /opt/apps/static-site/docker-compose.yml
services:
  static-site:
    image: nginx:1.27-alpine
    container_name: static-site
    restart: unless-stopped
    volumes:
      - ./html:/usr/share/nginx/html:ro
    networks:
      - web
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.static-site.rule=Host(`www.yourdomain.com`)"
      - "traefik.http.routers.static-site.entrypoints=websecure"
      - "traefik.http.routers.static-site.tls.certresolver=letsencrypt"
      - "traefik.http.services.static-site.loadbalancer.server.port=80"

      # Cache headers for static content
      - "traefik.http.routers.static-site.middlewares=static-headers"
      - "traefik.http.middlewares.static-headers.headers.customresponseheaders.Cache-Control=public, max-age=86400"

networks:
  web:
    external: true
cd /opt/apps/static-site
docker compose up -d

Application 4: Adminer (Database Management)

sudo mkdir -p /opt/apps/adminer
sudo nano /opt/apps/adminer/docker-compose.yml
services:
  adminer:
    image: adminer:latest
    container_name: adminer
    restart: unless-stopped
    networks:
      - web
      - wordpress-internal
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.adminer.rule=Host(`db.yourdomain.com`)"
      - "traefik.http.routers.adminer.entrypoints=websecure"
      - "traefik.http.routers.adminer.tls.certresolver=letsencrypt"
      - "traefik.http.services.adminer.loadbalancer.server.port=8080"

      # IP whitelist — only allow from your IP
      - "traefik.http.routers.adminer.middlewares=adminer-ipwhitelist"
      - "traefik.http.middlewares.adminer-ipwhitelist.ipallowlist.sourcerange=YOUR.PUBLIC.IP.ADDRESS/32"

networks:
  web:
    external: true
  wordpress-internal:
    external: true
    name: wordpress_wordpress-internal
cd /opt/apps/adminer
docker compose up -d

Understanding Docker Labels for Traefik

Every application container uses Docker labels to tell Traefik how to route traffic. Here's a breakdown of the essential labels:

Label Purpose
traefik.enable=true Opt this container in for Traefik routing
traefik.http.routers.NAME.rule Match condition (Host, Path, Headers, etc.)
traefik.http.routers.NAME.entrypoints Which entry point to listen on (web, websecure)
traefik.http.routers.NAME.tls.certresolver Which certificate resolver to use
traefik.http.services.NAME.loadbalancer.server.port Container port to forward traffic to
traefik.http.routers.NAME.middlewares Comma-separated list of middleware to apply

Advanced Routing Rules

Traefik's routing rules go beyond simple host matching:

# Host-based routing
- "traefik.http.routers.app.rule=Host(`app.yourdomain.com`)"

# Path-based routing
- "traefik.http.routers.app.rule=Host(`yourdomain.com`) && PathPrefix(`/api`)"

# Multiple hosts
- "traefik.http.routers.app.rule=Host(`yourdomain.com`) || Host(`www.yourdomain.com`)"

# Path stripping (remove /api prefix before forwarding)
- "traefik.http.routers.app.rule=Host(`yourdomain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.app.middlewares=strip-api"
- "traefik.http.middlewares.strip-api.stripprefix.prefixes=/api"

# Header-based routing
- "traefik.http.routers.app.rule=Host(`api.yourdomain.com`) && Headers(`X-Api-Version`, `v2`)"

Automatic SSL with Let's Encrypt

Traefik's built-in ACME client handles the entire certificate lifecycle automatically. When a new router with tls.certresolver=letsencrypt is detected, Traefik:

  1. Checks if a certificate for the domain already exists in acme.json
  2. If not, initiates the HTTP-01 challenge on port 80
  3. Stores the obtained certificate in acme.json
  4. Automatically renews certificates before they expire

All certificates are stored in a single file (acme.json). This file must have mode 600:

ls -la /opt/traefik/letsencrypt/acme.json
# -rw------- 1 root root ... acme.json

Using DNS Challenge for Wildcard Certificates

For wildcard certificates, switch from HTTP-01 to DNS-01 challenge. Update traefik.yml:

certificatesResolvers:
  letsencrypt:
    acme:
      email: admin@yourdomain.com
      storage: /letsencrypt/acme.json
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "8.8.8.8:53"

Add Cloudflare API credentials to the Traefik container's environment in docker-compose.yml:

    environment:
      - CF_DNS_API_TOKEN=your-cloudflare-api-token

Then use a wildcard certificate on any router:

labels:
  - "traefik.http.routers.app.tls.certresolver=letsencrypt"
  - "traefik.http.routers.app.tls.domains[0].main=yourdomain.com"
  - "traefik.http.routers.app.tls.domains[0].sans=*.yourdomain.com"

For more details on Let's Encrypt certificates, see our Let's Encrypt SSL guide.

Dashboard and Monitoring

Traefik's built-in dashboard provides a real-time view of all routers, services, and middleware. With the configuration above, the dashboard is accessible at https://traefik.yourdomain.com with basic authentication.

The dashboard shows:

Prometheus Metrics

Traefik can expose Prometheus-compatible metrics. Add to traefik.yml:

metrics:
  prometheus:
    entryPoint: websecure
    addEntryPointsLabels: true
    addServicesLabels: true
    addRoutersLabels: true
    buckets:
      - 0.1
      - 0.3
      - 1.2
      - 5.0

The metrics endpoint is available at /metrics on the Traefik container. Connect this to Prometheus and Grafana for historical monitoring. For a complete monitoring setup, see our Ubuntu VPS monitoring guide.

Access Logs

Access logs are configured in traefik.yml and written to /var/log/traefik/access.log on the host. Rotate these logs with a cron job or logrotate configuration (see our cron jobs and task scheduling guide):

sudo nano /etc/logrotate.d/traefik
/var/log/traefik/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    postrotate
        docker kill --signal=USR1 traefik 2>/dev/null || true
    endscript
}

Middleware: Rate Limiting, Headers, and More

Traefik middleware processes requests between the entry point and the backend service. Middleware is composable — you can chain multiple middleware together.

Rate Limiting

labels:
  - "traefik.http.routers.api.middlewares=api-ratelimit"
  - "traefik.http.middlewares.api-ratelimit.ratelimit.average=100"
  - "traefik.http.middlewares.api-ratelimit.ratelimit.burst=50"
  - "traefik.http.middlewares.api-ratelimit.ratelimit.period=1m"

This allows 100 requests per minute per source IP, with a burst capacity of 50 additional requests.

Security Headers

labels:
  - "traefik.http.routers.app.middlewares=security-headers"
  - "traefik.http.middlewares.security-headers.headers.stsSeconds=63072000"
  - "traefik.http.middlewares.security-headers.headers.stsIncludeSubdomains=true"
  - "traefik.http.middlewares.security-headers.headers.stsPreload=true"
  - "traefik.http.middlewares.security-headers.headers.forceSTSHeader=true"
  - "traefik.http.middlewares.security-headers.headers.contentTypeNosniff=true"
  - "traefik.http.middlewares.security-headers.headers.frameDeny=true"
  - "traefik.http.middlewares.security-headers.headers.browserXssFilter=true"
  - "traefik.http.middlewares.security-headers.headers.referrerPolicy=strict-origin-when-cross-origin"
  - "traefik.http.middlewares.security-headers.headers.permissionsPolicy=camera=(), microphone=(), geolocation=()"

IP Allowlisting

labels:
  - "traefik.http.routers.admin.middlewares=admin-ipallow"
  - "traefik.http.middlewares.admin-ipallow.ipallowlist.sourcerange=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,YOUR.PUBLIC.IP/32"

Basic Authentication

labels:
  - "traefik.http.routers.app.middlewares=app-auth"
  - "traefik.http.middlewares.app-auth.basicauth.users=admin:$$2y$$10$$hash-here"

Redirect Regex

# Redirect www to non-www
labels:
  - "traefik.http.routers.www-redirect.rule=Host(`www.yourdomain.com`)"
  - "traefik.http.routers.www-redirect.middlewares=www-to-nonwww"
  - "traefik.http.middlewares.www-to-nonwww.redirectregex.regex=^https://www\\.(.+)"
  - "traefik.http.middlewares.www-to-nonwww.redirectregex.replacement=https://$${1}"
  - "traefik.http.middlewares.www-to-nonwww.redirectregex.permanent=true"

Chaining Multiple Middleware

Apply multiple middleware to a single router by listing them comma-separated:

labels:
  - "traefik.http.routers.app.middlewares=security-headers,api-ratelimit,compress-response"
  - "traefik.http.middlewares.compress-response.compress=true"

Shared Middleware via File Provider

If you use the same middleware across many applications, define them once in a dynamic configuration file instead of repeating labels. Add a file provider to traefik.yml:

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: web
  file:
    filename: /etc/traefik/dynamic.yml
    watch: true

Create the dynamic configuration:

sudo nano /opt/traefik/dynamic.yml
http:
  middlewares:
    default-security-headers:
      headers:
        stsSeconds: 63072000
        stsIncludeSubdomains: true
        stsPreload: true
        forceSTSHeader: true
        contentTypeNosniff: true
        frameDeny: true
        browserXssFilter: true
        referrerPolicy: "strict-origin-when-cross-origin"

    default-ratelimit:
      rateLimit:
        average: 100
        burst: 50
        period: 1m

    default-compress:
      compress: {}

Mount this file in the Traefik container by adding a volume:

    volumes:
      - ./dynamic.yml:/etc/traefik/dynamic.yml:ro

Then reference these middleware from any container's labels:

labels:
  - "traefik.http.routers.app.middlewares=default-security-headers@file,default-ratelimit@file,default-compress@file"

Production Security Hardening

Docker Socket Security

Traefik needs access to the Docker socket to discover containers, but the Docker socket grants root-level control over the host. Mitigate this risk:

Option 1: Read-only mount (already done in our compose file):

volumes:
  - /var/run/docker.sock:/var/run/docker.sock:ro

Option 2: Docker socket proxy (more secure — recommended for production):

sudo nano /opt/traefik/docker-compose.yml
services:
  socket-proxy:
    image: tecnativa/docker-socket-proxy:latest
    container_name: socket-proxy
    restart: unless-stopped
    environment:
      CONTAINERS: 1
      SERVICES: 0
      TASKS: 0
      NETWORKS: 1
      NODES: 0
      SWARM: 0
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - socket-proxy

  traefik:
    image: traefik:v3.3
    container_name: traefik
    restart: unless-stopped
    depends_on:
      - socket-proxy
    security_opt:
      - no-new-privileges:true
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./traefik.yml:/traefik.yml:ro
      - ./dynamic.yml:/etc/traefik/dynamic.yml:ro
      - ./letsencrypt:/letsencrypt
      - /var/log/traefik:/var/log/traefik
    networks:
      - web
      - socket-proxy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.dashboard.rule=Host(`traefik.yourdomain.com`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.middlewares=dashboard-auth"
      - "traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$10$$your-hash"

networks:
  web:
    external: true
  socket-proxy:
    internal: true

Update traefik.yml to use the socket proxy:

providers:
  docker:
    endpoint: "tcp://socket-proxy:2375"
    exposedByDefault: false
    network: web

The socket proxy only exposes the container listing API — it blocks dangerous endpoints like exec, volumes, and network management.

TLS Configuration

Harden TLS settings in the dynamic configuration file:

tls:
  options:
    default:
      minVersion: VersionTLS12
      cipherSuites:
        - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
        - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
        - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
        - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
        - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
      sniStrict: true

Container Security Best Practices

For more Docker security recommendations, refer to our security hardening guide.

Health Checks and High Availability

Add Docker health checks to your containers so Traefik can route traffic only to healthy instances:

services:
  api:
    image: node:22-alpine
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

Traefik respects Docker health checks. If a container becomes unhealthy, Traefik stops routing traffic to it until it recovers.

For true high availability with automatic failover, consider deploying on MassiveGRID Dedicated VPS with guaranteed CPU resources, ensuring your Traefik instance and application containers have consistent performance under load.

Troubleshooting

Container Not Appearing in Traefik

Check these common causes:

  1. Missing traefik.enable=true label — required because exposedByDefault is false
  2. Container not on the web network — Traefik can only route to containers on its network
  3. Container not running — verify with docker ps
  4. Port not specified — if the container exposes multiple ports, you must set loadbalancer.server.port
# Check Traefik logs for errors
docker logs traefik --tail 50

# Verify container labels
docker inspect wordpress --format '{{json .Config.Labels}}' | jq .

Certificate Not Issued

502 Bad Gateway

This means Traefik can reach the router but cannot connect to the backend service:

Backing Up Traefik

The critical file to back up is acme.json, which contains all your SSL certificates and ACME account keys:

cp /opt/traefik/letsencrypt/acme.json /var/backups/traefik-acme-$(date +%Y%m%d).json

Also back up your configuration files:

tar czf /var/backups/traefik-config-$(date +%Y%m%d).tar.gz \
  /opt/traefik/traefik.yml \
  /opt/traefik/dynamic.yml \
  /opt/traefik/docker-compose.yml

Automate this with a cron job (see our cron jobs guide) or include it in your server backup strategy (see our automatic backups guide).

Prefer Managed Infrastructure?

If managing Docker, Traefik, SSL certificates, security hardening, monitoring, and backups is more infrastructure work than your team wants to handle, consider MassiveGRID's Managed Dedicated Cloud Servers. The managed service handles the entire infrastructure layer — container orchestration, reverse proxy configuration, SSL management, security patches, monitoring, and 24/7 incident response — while you focus on building your applications. Every managed server runs on a Proxmox HA cluster with automatic failover and Ceph triple-replicated NVMe storage.

What's Next