The last time I added a service to the production server, I caught myself copy-pasting an NGINX config file for the fifteenth time. Same SSL boilerplate, same proxy headers, same structure - just a different domain and port. Then I updated Certbot, set up renewal in CI, and reloaded NGINX. Four changes across three systems for one concern: getting HTTPS traffic to the right container.
Fourteen files, one pattern
The NGINX setup lived in a directory called heimdall. Fourteen domains routed through fourteen nearly identical server blocks, each in its own .conf file:
server {
listen 443 ssl;
server_name cloudapps.texperience.de;
server_tokens off;
ssl_certificate /etc/nginx/ssl/live/cloudapps.texperience.de/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/live/cloudapps.texperience.de/privkey.pem;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://cloudapps-application-proxy:8005/;
}
}
Nginx
Copy, change three values, done. Every file carries the same SSL paths, the same headers, the same listen 443 ssl. Beyond the config files: a compose.yml orchestrating a Certbot sidecar and a separate NGINX container just for ACME challenges, plus a CI job for scheduled certificate renewal. 362 lines across 20 files.
The domain appeared in the NGINX config, in the Certbot registration, and in the certificate path. The upstream port appeared in both the NGINX config and the Docker Compose file. Three systems holding overlapping slices of the same routing concern.
Routing where the service lives
Traefik connects to the Docker socket and derives routing from container labels. The entire routing declaration moves onto the compose service:
labels:
- "traefik.enable=true"
- "traefik.ingress=heimdall"
- "traefik.http.routers.cloudapps-prod.rule=Host(`cloudapps.texperience.de`)"
- "traefik.http.routers.cloudapps-prod.entrypoints=websecure"
- "traefik.http.routers.cloudapps-prod.tls=true"
- "traefik.http.routers.cloudapps-prod.tls.certresolver=letsencrypt"
- "traefik.http.routers.cloudapps-prod.priority=10"
- "traefik.http.services.cloudapps-prod.loadbalancer.server.port=8005"
Yaml
The ingress=heimdall label marks services for Traefik’s Docker provider constraint - Traefik became the new heimdall. No separate config file, no Certbot, no renewal pipeline. Traefik provisions Let’s Encrypt certificates through HTTP challenge and renews them before expiry.
The first commit deployed Traefik alongside NGINX with a catch-all router at priority 1 forwarding everything to the existing reverse proxy. Each service’s priority=10 pulled it out of the catch-all the moment its labels were added. Services migrated one by one over two weekends. Sixteen commits, zero downtime.
What stays, what goes
I lost fine-grained NGINX control - per-location headers, custom error pages, body size limits per route. Things I’d configured once in four years.
What disappeared: the heimdall directory with its config files, the Certbot sidecar, the CI pipeline. What remained was the name itself - as a label on every service that Traefik routes.