Deploy Traefik v3 Reverse Proxy on Docker with Automatic Let’s Encrypt SSL and Docker Socket Proxy

Cloud infrastructure engineer analyzing Traefik v3 reverse proxy routing and Let's Encrypt TLS certificates in a modern datacenter homelab
Deploy Traefik v3 Reverse Proxy on Docker with Automatic Let's Encrypt SSL and Docker Socket Proxy 3

In modern containerized environments, Traefik has long been recognized as the standard cloud-native edge router. Its ability to dynamically discover Docker containers through container labels eliminates the need to manually reload configuration files whenever a new service is spun up or torn down. With the release of Traefik v3, the platform introduced official support for WebAssembly (Wasm) plugins, OpenTelemetry tracing, HTTP/3, and refined routing syntax.

However, standard tutorials advocate a dangerous architectural flaw: mounting the host’s raw Docker socket (/var/run/docker.sock) directly into the Traefik container. Because the Docker daemon runs with root privileges on the Linux host, any container with direct socket access can escape its namespace, mount root filesystem partitions, and execute arbitrary root-level code on the host machine. If Traefik is compromised via an edge vulnerability or misconfigured middleware, the entire server is compromised.

To eliminate this critical attack vector, production deployments must decouple Traefik from the Docker daemon using a hardened Docker Socket Proxy (such as Tecnativa’s HAProxy-based implementation). In this architecture, Traefik only communicates with an unprivileged proxy that exposes exclusively read-only container event endpoints, blocking all write, execution, and volume-manipulation commands.

In this guide, you will deploy a battle-hardened Traefik v3 reverse proxy stack on Docker, enforce automated Let’s Encrypt TLS certificate provisioning, lock down the Docker socket with an unprivileged proxy, and configure reusable security middlewares.

Architecture: Zero-Trust Docker Ingress with Socket Isolation

By placing a security boundary between Traefik and the Docker engine, Traefik never mounts host binaries or unix domain sockets. Instead, Traefik queries the socket proxy over an isolated, internal-only Docker network, requesting only container metadata.

+-----------------------------------------------------------------------+
|                         Public Internet / Clients                     |
+-----------------------------------------------------------------------+
                                    |
                            Ports 80 / 443 (HTTP/3)
                                    v
+-----------------------------------------------------------------------+
|  Traefik v3 Edge Router (Public Network)                              |
|  - EntryPoints: web (80), websecure (443), traefik (Dashboard)        |
|  - Automatic ACME Let's Encrypt SSL Management                        |
|  - Middlewares: HSTS, Rate Limiting, Secure Headers, BasicAuth        |
+--------------------+------------------------------+-------------------+
                     |                              |
                     | Docker Labels / Metadata     | Proxied Traffic
                     v                              v
+-----------------------------------+ +---------------------------------+
| Docker Socket Proxy (Tecnativa)   | | Backend Docker Services         |
| - Read-Only: CONTAINERS, SERVICES | | - Web Apps, API Servers         |
| - Denied: EXEC, POST, VOLUMES     | | - Discovered via Docker Labels  |
| - Isolated 'socket_proxy' Network | | - Connected via 'traefik_proxy' |
+--------------------+--------------+ +---------------------------------+
                     |
         Local Unix Domain Socket
                     v
+-----------------------------------+
| Host Docker Engine Daemon         |
| /var/run/docker.sock (Root)       |
+-----------------------------------+

Benefits of this design:

  • Blast Radius Containment: Even in the event of Remote Code Execution (RCE) within Traefik, an attacker cannot manipulate the host’s Docker daemon or escape to root.
  • Granular API Filtering: Only GET /containers/* and GET /events are permitted; all administrative API endpoints (such as POST /containers/create or DELETE) return 403 Forbidden.
  • Separation of Concerns: Routing rules, TLS challenges, and backend container networks remain cleanly separated.

Prerequisites & Host Setup

Ensure your system meets the baseline requirements:

  • Operating System: Debian 12, Ubuntu 24.04 LTS, or Red Hat Enterprise Linux 9+.
  • Software: Docker Engine 26.0+ and Docker Compose v2.20+.
  • DNS Configuration: Public A/AAAA records for your primary domain and subdomains (e.g., traefik.example.com and whoami.example.com) pointing to your server’s public IP address.
  • Ports: Port 80 and 443 open and forwarded to the host.

Create the stack directory structure:

sudo mkdir -p /opt/traefik-stack/{config,certs}
cd /opt/traefik-stack

Let’s Encrypt requires strict permissions on the certificate storage file. If the file has group or world permissions, Traefik will refuse to write certificates for security reasons:

sudo touch /opt/traefik-stack/certs/acme.json
sudo chmod 600 /opt/traefik-stack/certs/acme.json

Step 1: Environment Variables & BasicAuth Hash

The Traefik v3 dashboard provides valuable operational metrics, but it must be protected behind BasicAuth. Generate an encrypted password hash using htpasswd (or an inline Docker container):

# Generate htpasswd hash for user 'admin'
docker run --rm httpd:alpine htpasswd -nbB admin "ReplaceWithYourStrongPassword"

The output looks similar to: admin:$2y$05$1234567890abcdef.... Note: In Docker Compose, double dollar signs ($$) must be used to escape variable interpolation.

Create /opt/traefik-stack/.env:

DOMAIN_NAME=example.com
TRAEFIK_DASHBOARD_SUBDOMAIN=traefik.example.com
ACME_EMAIL=admin@example.com
TRAEFIK_BASIC_AUTH=admin:$$2y$$05$$replace_with_escaped_hash

Step 2: Static Configuration (traefik.yml)

Traefik separates its settings into static configuration (entrypoints, providers, certificate resolvers loaded at startup) and dynamic configuration (routers, services, middlewares updated on the fly). Create /opt/traefik-stack/config/traefik.yml:

global:
  checkNewVersion: false
  sendAnonymousUsage: false

api:
  dashboard: true
  insecure: false

log:
  level: INFO
  format: json

accessLog:
  format: json
  bufferingSize: 100
  filters:
    statusCodes:
      - "400-599"

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
          permanent: true

  websecure:
    address: ":443"
    http:
      tls:
        certResolver: letsencrypt
        options: default

providers:
  docker:
    endpoint: "tcp://socket_proxy:2375"
    exposedByDefault: false
    network: traefik_public
    watch: true
  file:
    directory: "/etc/traefik/dynamic"
    watch: true

certificatesResolvers:
  letsencrypt:
    acme:
      email: "admin@example.com"
      storage: "/etc/traefik/certs/acme.json"
      httpChallenge:
        entryPoint: web

Update the email in traefik.yml with your actual administrator email for SSL expiry notices:

sed -i "s|admin@example.com|$(grep ACME_EMAIL .env | cut -d '=' -f2)|g" /opt/traefik-stack/config/traefik.yml

Step 3: Dynamic Security Configuration & Middlewares

Create a directory for dynamic file configurations and define /opt/traefik-stack/config/dynamic/security.yml. This file configures strict TLS 1.3/1.2 ciphers and reusable security headers conforming to modern browser standards:

sudo mkdir -p /opt/traefik-stack/config/dynamic
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

http:
  middlewares:
    security-headers:
      headers:
        sslRedirect: true
        stsSeconds: 31536000
        stsIncludeSubdomains: true
        stsPreload: true
        forceSTSHeader: true
        contentTypeNosniff: true
        browserXssFilter: true
        frameDeny: true
        referrerPolicy: "strict-origin-when-cross-origin"
        permissionsPolicy: "camera=(), microphone=(), geolocation=()"

    rate-limit:
      rateLimit:
        average: 100
        burst: 50
        period: 1m

Step 4: Docker Compose Specification

Create /opt/traefik-stack/docker-compose.yml. This Compose file establishes two distinct bridge networks: traefik_public for web traffic ingress and socket_internal for communication between Traefik and the Docker Socket Proxy.

services:
  socket_proxy:
    image: tecnativa/docker-socket-proxy:latest
    container_name: traefik_socket_proxy
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      # Strictly limit exposed API endpoints
      - CONTAINERS=1
      - SERVICES=1
      - TASKS=1
      - NETWORKS=1
      - NODES=1
      - INFO=1
      - VERSION=1
      # Explicitly disable write / execution operations
      - POST=0
      - DELETE=0
      - EXEC=0
      - VOLUMES=0
      - SWARM=0
      - SECRETS=0
      - PLUGINS=0
    networks:
      - socket_internal
    deploy:
      resources:
        limits:
          cpus: '0.25'
          memory: 128M

  traefik:
    image: traefik:v3.1
    container_name: traefik_router
    restart: unless-stopped
    depends_on:
      - socket_proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./config/traefik.yml:/etc/traefik/traefik.yml:ro
      - ./config/dynamic:/etc/traefik/dynamic:ro
      - ./certs/acme.json:/etc/traefik/certs/acme.json:rw
    networks:
      - traefik_public
      - socket_internal
    labels:
      - "traefik.enable=true"
      # Dashboard Router
      - "traefik.http.routers.dashboard.rule=Host(`${TRAEFIK_DASHBOARD_SUBDOMAIN}`)"
      - "traefik.http.routers.dashboard.entrypoints=websecure"
      - "traefik.http.routers.dashboard.service=api@internal"
      - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt"
      # Middlewares
      - "traefik.http.routers.dashboard.middlewares=dashboard-auth,security-headers@file"
      - "traefik.http.middlewares.dashboard-auth.basicauth.users=${TRAEFIK_BASIC_AUTH}"
    deploy:
      resources:
        limits:
          cpus: '1.50'
          memory: 512M

  # Sample Test Service
  whoami:
    image: traefik/whoami:latest
    container_name: traefik_test_whoami
    restart: unless-stopped
    networks:
      - traefik_public
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`whoami.${DOMAIN_NAME}`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=letsencrypt"
      - "traefik.http.routers.whoami.middlewares=security-headers@file,rate-limit@file"
      - "traefik.http.services.whoami.loadbalancer.server.port=80"

networks:
  traefik_public:
    name: traefik_public
    driver: bridge
  socket_internal:
    driver: bridge
    internal: true

Step 5: Launching and Verifying Ingress Routing

Pull the images and initialize the stack:

docker compose pull
docker compose up -d

Verify that both Traefik and the socket proxy are running normally:

docker compose ps

Inspect the startup logs to ensure Traefik connected to the socket proxy and initiated the ACME challenge:

docker compose logs -f traefik

You should see log entries confirming:

  • Provider connection established with docker
  • Configuration received from provider docker
  • Testing certificate renew... followed by successful certificate creation in acme.json.

Test the whoami endpoint from the command line:

curl -I "https://whoami.example.com"

Verify that HTTP 200 OK is returned, accompanied by the strict security headers (strict-transport-security, x-frame-options: DENY, x-content-type-options: nosniff) defined in your dynamic configuration.

Step 6: Connecting Additional Docker Services

When launching new Docker Compose stacks on the same host (e.g. Nextcloud, Vaultwarden, or n8n), do not expose host ports (ports: - "8080:80"). Instead, connect the container to the external traefik_public network and attach Traefik labels:

services:
  myapp:
    image: myapp:latest
    container_name: myapp_service
    networks:
      - traefik_public
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.myapp.rule=Host(`myapp.example.com`)"
      - "traefik.http.routers.myapp.entrypoints=websecure"
      - "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
      - "traefik.http.routers.myapp.middlewares=security-headers@file"
      - "traefik.http.services.myapp.loadbalancer.server.port=3000"

networks:
  traefik_public:
    external: true

Troubleshooting Common Failure Modes

Issue 1: Traefik v3 Syntax Deprecation Warnings & Router Failures

Symptom: Traefik logs display error while parsing rule Host(...) or ignore routers entirely upon upgrading from Traefik v2 to v3.

Root Cause: In Traefik v3, regex and rule syntax strictly require backtick delimiters (e.g. Host(`example.com`) instead of Host('example.com')). Furthermore, HostHeader() has been fully removed in favor of standard Host() matching.

Resolution: Always wrap hostnames in backticks within label rules: traefik.http.routers.app.rule=Host(`app.example.com`). If using regex, use HostRegexp(`{sub:[a-z]+}.example.com`).

Issue 2: ACME HTTP-01 Challenge Fails with Timeout

Symptom: Traefik logs report Error obtaining certificate: [domain] acme: error: 400: urn:ietf:params:acme:error:connection: Fetching http://domain/.well-known/acme-challenge/...: Timeout.

Root Cause: Port 80 is blocked by your firewall or cloud security group, or the HTTP-to-HTTPS redirect middleware intercepts and drops the ACME validation request before Traefik handles it.

Resolution: Traefik’s internal entrypoint redirect (configured in traefik.yml under entryPoints.web.http.redirections) automatically bypasses ACME challenges. Ensure port 80 is forwarded and allowed through UFW: sudo ufw allow 80/tcp && sudo ufw allow 443/tcp.

Issue 3: Docker Socket Proxy Denies Container Discovery

Symptom: Traefik starts cleanly, but no backend services are discovered, and logs show HTTP 403 Forbidden when Traefik contacts socket_proxy:2375.

Root Cause: The required read-only environment variables in the socket_proxy container are missing or set to 0.

Resolution: Ensure CONTAINERS=1, SERVICES=1, TASKS=1, NETWORKS=1, and VERSION=1 are set to 1 in the traefik_socket_proxy environment definition. Restart the proxy container with docker compose up -d socket_proxy.

Conclusion

By deploying Traefik v3 behind a hardened Docker Socket Proxy, you obtain the full dynamic discovery advantages of cloud-native routing without opening a lethal root-escalation backdoor into your host operating system. The resulting infrastructure is modular, strictly encrypted with Let’s Encrypt TLS, and protected by reusable security headers.

As next steps, you can configure DNS-01 challenges for wildcard domain certificates or install CrowdSec bouncers as Traefik plugins to block malicious IPs dynamically before they reach your internal services.