Configuring Traefik Reverse Proxy with SSL/TLS for Self-Hosted Apps
Running multiple self-hosted services on your homelab is great until you realize you're juggling port numbers and remembering which app lives on which machine. That's where Traefik comes in. I've been using it for two years now, and it's transformed how I manage Nextcloud, Vaultwarden, Jellyfin, and a dozen other services. Traefik automatically routes traffic based on hostnames, handles SSL/TLS certificate renewal without manual intervention, and plays beautifully with Docker. Let me walk you through setting it up.
Why Traefik Over Nginx Proxy Manager or Caddy?
I've tested all three, and here's my honest take: Nginx Proxy Manager has a great UI but feels clunky when you're running 10+ services. Caddy is fantastic for small setups but lacks the dynamic routing flexibility I need. Traefik wins because it's declarative, Docker-native, and incredibly powerful once you understand the labels. The learning curve is steeper, but the payoff is worth it for serious homelabs.
Traefik integrates directly with Docker—you don't configure routes in a separate file. Just add labels to your containers, and Traefik auto-discovers them. Want HTTPS? It automatically provisions Let's Encrypt certificates. Want to add a new service? Spin up a container with the right labels. Done.
Prerequisites
You'll need a machine running Docker and Docker Compose (ideally on Linux, but it works on macOS and Windows with WSL2). I'm assuming you have a domain name pointing to your home IP or VPS. For this guide, I'll use example.com—replace it with yours. You also need port 80 and 443 accessible from the internet, which means opening those ports on your router and pointing your domain there.
Setting Up Traefik with Docker Compose
The core of Traefik is its static configuration—the stuff that doesn't change often, like entrypoints, certificate storage, and middleware. You'll define this in a traefik.yml file and pass it to the container. Here's my production setup:
# traefik.yml
global:
checkNewVersion: false
sendAnonymousUsage: false
api:
insecure: false
dashboard: true
debug: false
entryPoints:
web:
address: ":80"
http:
redirections:
entrypoint:
regex: ^http://(.*)
replacement: https://$1
permanent: true
websecure:
address: ":443"
http:
tls:
certResolver: letsencrypt
domains:
- main: example.com
sans:
- "*.example.com"
certificatesResolvers:
letsencrypt:
acme:
email: [email protected]
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
providers:
docker:
endpoint: unix:///var/run/docker.sock
exposedByDefault: false
network: traefik-network
file:
filename: /traefik/dynamic.yml
watch: true
log:
level: INFO
filePath: /var/log/traefik/traefik.log
accessLog:
filePath: /var/log/traefik/access.log
This configuration does a lot of heavy lifting: all HTTP traffic redirects to HTTPS, Let's Encrypt handles certificate provisioning, and Docker labels drive service discovery. The exposedByDefault: false line is crucial—only services with a Traefik label will be exposed, keeping your homelab secure by default.
Now, here's the Docker Compose file that brings it all together:
version: '3.8'
services:
traefik:
image: traefik:v3.0
container_name: traefik
restart: always
security_opt:
- no-new-privileges:true
networks:
- traefik-network
ports:
- "80:80"
- "443:443"
environment:
- TRAEFIK_API_INSECURE=false
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/traefik.yml:ro
- ./dynamic.yml:/traefik/dynamic.yml:ro
- ./letsencrypt:/letsencrypt
- ./traefik-logs:/var/log/traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`traefik.example.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$$05$$password_hash_here"
# Example: Nextcloud
nextcloud:
image: nextcloud:latest
container_name: nextcloud
restart: always
networks:
- traefik-network
environment:
- MYSQL_HOST=nextcloud-db
- MYSQL_USER=nextcloud
- MYSQL_PASSWORD=secure_password
- MYSQL_DATABASE=nextcloud
volumes:
- ./nextcloud-data:/var/www/html
depends_on:
- nextcloud-db
labels:
- "traefik.enable=true"
- "traefik.http.routers.nextcloud.rule=Host(`nextcloud.example.com`)"
- "traefik.http.routers.nextcloud.entrypoints=websecure"
- "traefik.http.routers.nextcloud.tls.certresolver=letsencrypt"
- "traefik.http.services.nextcloud.loadbalancer.server.port=80"
nextcloud-db:
image: mariadb:latest
container_name: nextcloud-db
restart: always
networks:
- traefik-network
environment:
- MYSQL_ROOT_PASSWORD=root_password
- MYSQL_USER=nextcloud
- MYSQL_PASSWORD=secure_password
- MYSQL_DATABASE=nextcloud
volumes:
- ./nextcloud-db:/var/lib/mysql
networks:
traefik-network:
driver: bridge
htpasswd -nB admin to create one, then double the dollar signs in the Docker Compose file.Understanding Traefik Labels
Labels are where the magic happens. Each label tells Traefik how to route traffic to that specific container. Let me break down the key ones:
traefik.enable=true— Expose this service (required, since we set exposedByDefault to false)traefik.http.routers.{name}.rule=Host(...)— Match requests by hostnametraefik.http.routers.{name}.entrypoints=websecure— Use HTTPS onlytraefik.http.routers.{name}.tls.certresolver=letsencrypt— Provision certificates automaticallytraefik.http.services.{name}.loadbalancer.server.port=XXXX— The internal port the app listens on
For a service like Vaultwarden running on port 80 internally, you'd add these labels:
labels:
- "traefik.enable=true"
- "traefik.http.routers.vaultwarden.rule=Host(`vault.example.com`)"
- "traefik.http.routers.vaultwarden.entrypoints=websecure"
- "traefik.http.routers.vaultwarden.tls.certresolver=letsencrypt"
- "traefik.http.services.vaultwarden.loadbalancer.server.port=80"
Adding Middleware for Security and Rewriting
Traefik's middleware layer lets you modify requests and responses. I use it for CORS headers, rate limiting, and URL rewrites. Create a dynamic.yml file in your Traefik directory:
# dynamic.yml
http:
middlewares:
secure-headers:
headers:
accessControlAllowOriginList:
- "https://example.com"
accessControlMaxAgeSecs: 100
addVaryHeader: true
brokenRolesAction: log
contentSecurityPolicy: "default-src 'self'"
contentTypeNosniff: true
forceSTSHeader: true
frameDeny: true
referrerPolicy: "strict-origin-when-cross-origin"
stsIncludeSubdomains: true
stsMaxAge: 31536000
stsPreload: true
xssProtection: "1; mode=block"
rate-limit:
rateLimit:
average: 100
period: 1m
burst: 50
basic-auth-admin:
basicAuth:
users:
- "admin:hashed_password_here"
Then apply the middleware to a service by adding a label:
- "traefik.http.routers.{service-name}.middlewares=secure-headers,rate-limit"
Getting Certificates to Work Correctly
The first time you spin up Traefik, it might take a minute or two to provision certificates. Watch the logs with docker logs traefik to confirm. You should see something like:
[acme] Obtaining Let's Encrypt certificate for domain [vault.example.com]...
If you see errors about DNS resolution, check that your domain actually points to your home IP. Use dig example.com to verify. For subdomains, ensure DNS is set up correctly; I use wildcard DNS (*.example.com) pointing to my home IP.
caServer: https://acme-staging-v02.api.letsencrypt.org/directory. Staging certificates won't be valid in production but are unlimited for testing.Monitoring Traefik in Production
Access the Traefik dashboard at https://traefik.example.com (the subdomain you configured in the labels). You'll see all routers, services, and middleware in real time. The dashboard is invaluable for debugging routing issues.
Keep an eye on the acme.json file size—it grows as you add services. Every service with a wildcard certificate gets an entry. It's normal to see 50KB+ files. Back it up regularly; I use a simple cron job to copy it to a backup volume weekly.
Handling Subdomains and Wildcard Certificates
For a true multi-service homelab, you probably want a wildcard certificate covering all subdomains. In the traefik.yml file, I already set that up:
domains:
- main: example.com
sans:
- "*.example.com"
This means one certificate covers example.com and anything.example.com. Let's Encrypt issues wildcard certificates through DNS validation by default in Traefik, but I've configured HTTP challenge here for simplicity. If you have trouble with DNS challenges at home, stick with HTTP.
Common Gotchas and Fixes
Certificate not renewing: Traefik renews automatically 30 days before expiry. If you see a 404 on the ACME challenge path during renewal, make sure port 80 is open and your firewall isn't blocking it.
Services behind Traefik returning wrong redirect URLs: Some apps (like Nextcloud) try to build URLs based on the request they see. Since Traefik terminates TLS, the backend sees HTTP. Add this middleware to fix it:
- "traefik.http.middlewares.{name}.headers.customRequestHeaders.X-Forwarded-Proto=https"
Docker socket permission errors: Mount the socket as read-only (`:ro`) to avoid giving Traefik write access to Docker.
Next Steps
Once you have Traefik running smoothly, consider adding authentication middleware to expose your dashboard securely, or set up fail2ban to block brute-force attempts on your exposed services. For serious homelab deployments, I also recommend running Traefik on a dedicated VPS alongside your home hardware—it makes managing DNS and certificates bulletproof.