When your reverse proxy becomes its own project

Three systems, one concern

Geschrieben von Timo Rieber am 19. September 2025

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.