Setting Up Traefik: From Local to Production
Building three different Traefik configurations - from simple local development to production-ready infrastructure with security layers.

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:
- UFW rules - Allow Cloudflare IPs to reach your server
- UFW route rules - Allow that traffic to pass through to containers
- 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.