Setting Up Traefik: From Local to Production

Building three different Traefik configurations - from simple local development to production-ready infrastructure with security layers.

Illustration of traffic lights

After getting my Ubuntu server up and running in my previous post, I needed a proper reverse proxy. Traefik was the obvious choice - it’s Docker-native, handles SSL certs automatically, and has solid documentation.

I ended up creating three different Traefik configurations: one for local development, one for my home server, and a hardened version for production. Here’s what I learned along the way.

TL;DR: You can find the complete, production-ready code in my self-hosted repository on GitHub.

The Docker Context File Mounting Problem

Before diving into the configurations, let me share something that cost me a few hours of debugging.

When using Docker context with a remote server, mounting local files to containers doesn’t work as expected. Instead of the file being mounted correctly, Docker creates a new directory with the same name.

This hit me when I tried to mount my Traefik middleware configuration:

volumes:
  - ./middlewares.yml:/etc/traefik/middlewares.yml # This creates a directory, not a file!

The workaround I used is simple - use a Dockerfile to copy the file during the build process:

FROM traefik:latest
COPY middlewares.yml /etc/traefik/middlewares.yml

It’s a small thing, but it’ll save you from wondering why your middleware configuration isn’t loading.

Three Configurations for Three Use Cases

Local Development: Keep It Simple

The local setup is barebones - just Traefik handling HTTP traffic on port 80. No SSL, no security headers, no complications. Perfect for testing applications before deploying them.

services:
  traefik:
    image: traefik:v3.0
    container_name: traefik
    restart: unless-stopped

    # Environment configuration
    env_file:
      - .env

    command:
      # API and Dashboard
      - --api.dashboard=false

      # Entry points
      - --entrypoints.web.address=:80

      # Docker provider
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=home-network

      # Logging
      - --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
      - --accesslog=true

    ports:
      - '80:80'

    volumes:
      # Docker socket for service discovery
      - /var/run/docker.sock:/var/run/docker.sock:ro

    healthcheck:
      test: ['CMD', 'traefik', 'healthcheck', '--ping']
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

    networks:
      - local-network

# Networks
networks:
  local-network:
    external: true

This configuration assumes you’re working locally and don’t need HTTPS. It’s fast to spin up and perfect for development work.

Home Server: Convenience with Some Security

The home server configuration strikes a balance between convenience and security. It includes:

  • Dashboard enabled for easy monitoring
  • Let’s Encrypt with DNS-01 challenge for real SSL certificates
  • Cloudflare Tunnel integration for secure external access
  • Flexible HTTP/HTTPS - no forced redirects

Key differences from the production setup:

services:
  traefik:
    image: traefik:v3.0
    container_name: traefik
    restart: unless-stopped

    # Environment configuration
    env_file:
      - .env

    environment:
      # Cloudflare DNS API for Let's Encrypt DNS-01 challenge
      - CF_API_EMAIL=${CLOUDFLARE_EMAIL}
      - CF_API_KEY=${CLOUDFLARE_API_KEY}
      # Alternative: CF_DNS_API_TOKEN (for API tokens instead of Global API Key)
      # - CF_DNS_API_TOKEN=${CLOUDFLARE_DNS_API_TOKEN}

    command:
      # API and Dashboard
      - --api.dashboard=true
      - --api.debug=${TRAEFIK_DEBUG:-false}
      - --api.insecure=${TRAEFIK_API_INSECURE:-false}

      # Entry points
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443

      # HTTP to HTTPS redirect (DISABLED for flexible home access)
      # - --entrypoints.web.http.redirections.entrypoint.to=websecure
      # - --entrypoints.web.http.redirections.entrypoint.scheme=https
      # - --entrypoints.web.http.redirections.entrypoint.permanent=true

      # Docker provider
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=home-network

      # Let's Encrypt certificate resolver with DNS-01 challenge
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=false
      - --certificatesresolvers.letsencrypt.acme.dnschallenge=true
      - --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
      - --certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53
      - --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=30
      - --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL}
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json

      # Logging
      - --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
      - --accesslog=true

    ports:
      # Bind to specific local IP address
      - '${TRAEFIK_LOCAL_IP}:80:80'
      - '${TRAEFIK_LOCAL_IP}:443:443'
      # Dashboard port (optional, can be accessed via domain instead)
      - '${TRAEFIK_LOCAL_IP}:8080:8080'

    volumes:
      # Docker socket for service discovery
      - /var/run/docker.sock:/var/run/docker.sock:ro

      # Let's Encrypt certificates storage
      - traefik_letsencrypt:/letsencrypt

    labels:
      # Enable Traefik for itself
      - 'traefik.enable=true'

      # Traefik dashboard router
      - 'traefik.http.routers.api.rule=Host(`traefik.${ROOT_DOMAIN}`)'
      - 'traefik.http.routers.api.service=api@internal'
      - 'traefik.http.routers.api.entrypoints=websecure'
      - 'traefik.http.routers.api.tls=true'
      - 'traefik.http.routers.api.middlewares=security-headers'

      # Simplified security headers for home use
      - 'traefik.http.middlewares.security-headers.headers.browserxssfilter=true'
      - 'traefik.http.middlewares.security-headers.headers.contenttypenosniff=true'
      - 'traefik.http.middlewares.security-headers.headers.referrerpolicy=strict-origin-when-cross-origin'

      # HTTPS redirect middleware
      - 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https'
      - 'traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true'

    healthcheck:
      test: ['CMD', 'traefik', 'healthcheck', '--ping']
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

    # Network configuration
    networks:
      - home-network

  # Cloudflare Tunnel
  cloudflared-tunnel:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared-tunnel
    restart: unless-stopped

    command: tunnel run

    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}

    networks:
      - home-network

# Networks
networks:
  home-network:
    external: true

volumes:
  traefik_letsencrypt:
    external: true

The Cloudflare Tunnel integration is particularly nice - it handles external access without exposing your home IP or dealing with port forwarding.

There are tools like traefik-cloudflare-companion that can automatically create CNAME records for your Traefik containers, but I prefer keeping things simple by managing the exposed endpoints manually on the Cloudflare dashboard.

If you want a fully automated setup where Traefik routes through Cloudflare Tunnel, this blog post covers it exceptionally well.

Production Security Layers

The production configuration is where things get serious. This isn’t just about routing traffic anymore - it’s about protecting your infrastructure.

Layer 1: Cloudflare IP Whitelisting

The first line of defense is the UFW firewall script that only allows Cloudflare’s IP ranges to reach ports 80 and 443. This script automatically fetches the latest Cloudflare IP ranges and configures both UFW rules and route rules for Docker containers.

# The script handles both regular UFW rules and Docker route rules
for ip in "${CLOUDFLARE_IPV4[@]}"; do
    ufw allow from "$ip" to any port 80 proto tcp
    ufw route allow proto tcp from "$ip" to any port 80
done

Why both types of rules? UFW rules control access to the host, while route rules control access to Docker containers. You need both for this setup to work properly.

Layer 2: CrowdSec Integration

CrowdSec provides real-time threat detection by analyzing logs and sharing threat intelligence. The integration uses a Traefik plugin that communicates with the CrowdSec API:

crowdsec:
  plugin:
    bouncer:
      enabled: true
      logLevel: INFO
      crowdsecMode: stream
      crowdsecLapiKey: CROWDSEC_API_KEY
      crowdsecLapiScheme: 'http'
      crowdsecLapiHost: 'crowdsec:8080'
      crowdsecLapiPath: '/'
      updateIntervalSeconds: 60
      updateMaxFailure: 0
      defaultDecisionSeconds: 60
      remediationStatusCode: 403
      httpTimeoutSeconds: 10
      metricsUpdateIntervalSeconds: 600

The stream mode is key here - it provides real-time protection with minimal performance impact.

Layer 3: Security Headers and Middleware

The middleware configuration includes comprehensive security headers:

security-headers:
  headers:
    stsSeconds: 315360000 # HSTS for 10 years
    browserXssFilter: true
    contentTypeNosniff: true
    forceSTSHeader: true
    stsIncludeSubdomains: true
    stsPreload: true
    frameDeny: true
    referrerPolicy: 'strict-origin-when-cross-origin'

I also included an API key authentication middleware for protecting internal APIs - useful for admin endpoints or service-to-service communication.

The Complete Production Setup

The production docker-compose.yml brings everything together:

  • Traefik with custom Dockerfile to include middleware configuration
  • CrowdSec container for threat detection
  • Cloudflare DNS-01 challenge for SSL certificates
  • Disabled dashboard for security
  • Shared logs volume for CrowdSec log monitoring

Why Custom Dockerfiles? As mentioned earlier, Docker context with remote servers doesn’t handle local file mounting correctly - it creates directories instead of mounting files. The custom Dockerfiles solve this by copying the configuration files during the build process:

# Dockerfile.traefik
FROM traefik:latest
COPY middlewares.yml /etc/traefik/middlewares.yml

# Dockerfile.crowdsec
FROM crowdsecurity/crowdsec:latest
COPY acquis.yaml /etc/crowdsec/acquis.yaml
services:
  traefik:
    build:
      context: .
      dockerfile: Dockerfile.traefik
    container_name: traefik
    restart: unless-stopped

    # Environment configuration
    env_file:
      - .env

    environment:
      # Cloudflare DNS API for Let's Encrypt DNS-01 challenge
      - CF_API_EMAIL=${CLOUDFLARE_EMAIL}
      - CF_API_KEY=${CLOUDFLARE_API_KEY}
      # CrowdSec API key for plugin
      - CROWDSEC_API_KEY=${CROWDSEC_API_KEY}

    command:
      # API and Dashboard (disabled for production)
      - --api.dashboard=false
      - --api.debug=false

      # Entry points
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443

      # HTTP to HTTPS redirect
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --entrypoints.web.http.redirections.entrypoint.permanent=true

      # Trust forwarded headers configuration (handled by middlewares.yml)
      - --entrypoints.websecure.forwardedheaders.insecure=false

      # Docker provider
      - --providers.docker=true
      - --providers.file.filename=/etc/traefik/middlewares.yml
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=app-network

      # Let's Encrypt certificate resolver with DNS-01 challenge
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=false
      - --certificatesresolvers.letsencrypt.acme.dnschallenge=true
      - --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
      - --certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53
      - --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=30
      - --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL}

      # CrowdSec plugin configuration
      - --experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
      - --experimental.plugins.bouncer.version=v1.4.4

      # API Key Authentication
      - --experimental.plugins.traefik-api-key-auth.modulename=github.com/Septima/traefik-api-key-auth
      - --experimental.plugins.traefik-api-key-auth.version=v0.3.0

      # Logging
      - --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
      - --accesslog=true
      - --accesslog.format=json

    ports:
      # Standard HTTP/HTTPS ports
      - '80:80'
      - '443:443'

    volumes:
      - '/var/run/docker.sock:/var/run/docker.sock:ro'
      - logs-traefik:/var/log/traefik

    # Network configuration
    networks:
      - app-network

    depends_on:
      - crowdsec

  # CrowdSec for threat detection
  crowdsec:
    build:
      context: .
      dockerfile: Dockerfile.crowdsec
    container_name: crowdsec
    restart: unless-stopped

    environment:
      # CrowdSec configuration
      - BOUNCER_KEY_TRAEFIK=${CROWDSEC_API_KEY}
      - COLLECTIONS=crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
      # Enable LAPI for Traefik plugin
      - LAPI_HOST=0.0.0.0
      - LAPI_PORT=8080

    volumes:
      # CrowdSec configuration and data
      - crowdsec_config:/etc/crowdsec
      - crowdsec_data:/var/lib/crowdsec

      # Log files to monitor
      - logs-traefik:/var/log/traefik

    networks:
      - app-network

    labels:
      - 'traefik.enable=false'

    # Health check
    healthcheck:
      test: ['CMD-SHELL', 'cscli lapi status || exit 1']
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s

# Networks
networks:
  app-network:
    external: true

# Persistent volumes
volumes:
  logs-traefik:
  crowdsec_config:
  crowdsec_data:

Here’s the static configuration file for Traefik:

# Traefik Static Configuration
# This file defines global middlewares and configuration

http:
  middlewares:
    # Global security headers middleware
    security-headers:
      headers:
        stsSeconds: 315360000
        browserXssFilter: true
        contentTypeNosniff: true
        forceSTSHeader: true
        stsIncludeSubdomains: true
        stsPreload: true
        frameDeny: true
        referrerPolicy: 'strict-origin-when-cross-origin'

    # CrowdSec bouncer middleware
    crowdsec:
      plugin:
        bouncer:
          enabled: true
          logLevel: INFO
          crowdsecMode: stream
          crowdsecLapiKey: CROWDSEC_API_KEY
          crowdsecLapiScheme: 'http'
          crowdsecLapiHost: 'crowdsec:8080'
          crowdsecLapiPath: '/'
          updateIntervalSeconds: 60
          updateMaxFailure: 0
          defaultDecisionSeconds: 60
          remediationStatusCode: 403
          httpTimeoutSeconds: 10
          metricsUpdateIntervalSeconds: 600
          forwardedHeadersTrustedIPs:
            # Add Cloudflare IP ranges here

    # API Key Authentication middleware
    api-key-auth:
      plugin:
        traefik-api-key-auth:
          enabled: true
          authenticationHeaderEnabled: true
          authenticationHeaderName: 'X-API-KEY'
          bearerHeader: true
          bearerHeaderName: 'Authorization'
          queryParam: true
          queryParamName: 'token'
          keys:
            - 'your-very-secret-api-key' # Replace with your actual API key

    # Basic Authentication middleware
    basic-auth:
      basicAuth:
        users:
          - 'admin:$2y$10$your_hashed_password_here' # Generate with: htpasswd -nbB admin your_password
        removeHeader: true

UFW and Docker: The Missing Piece

Understanding how UFW works with Docker was crucial. Docker manipulates iptables directly, which can bypass UFW rules. The solution involves:

  1. UFW rules - Allow Cloudflare IPs to reach your server
  2. UFW route rules - Allow that traffic to pass through to containers
  3. ufw-docker integration - Ensures Docker doesn’t bypass your firewall

The ufw-manager script handles all of this automatically, but understanding the flow helps with troubleshooting: Internet → Cloudflare → UFW Rules → UFW Route Rules → Docker Containers

Next Steps

With Traefik properly configured, I can now deploy services with confidence. Each service just needs the right labels:

labels:
  - 'traefik.enable=true'
  - 'traefik.http.routers.app.rule=Host(`app.yourdomain.com`)'
  - 'traefik.http.routers.app.entrypoints=websecure'
  - 'traefik.http.routers.app.tls.certresolver=letsencrypt'
  - 'traefik.http.routers.app.middlewares=security-headers@file,crowdsec@file'

The key takeaway? Infrastructure automation isn’t just about getting things working - it’s about making them work reliably and securely in different environments.

You can find all the configurations discussed in this article in my self-hosted repository on GitHub.

Subscribe to my newsletter

I send out a newsletter every week, usually on Thursdays, that's it!

You'll get two emails initially—a confirmation link to verify your subscription, followed by a welcome message. Thanks for joining!

You can read all of my previous issues here

Related Posts.