Context / need
A self-hosted platform runs Docker containers for services of very different natures: public client sites, private internal tools, APIs receiving webhooks. All of them must be served over HTTPS, each with the right exposure, without multiplying entry points or case-by-case configuration.
Constraints
- Public and private coexist on the same machine: a configuration mistake must not expose an internal service to the Internet.
- Certificates must renew without manual intervention. A forgotten renewal ends in a broken service.
- A single maintainer: the configuration has to stay readable and reproducible six months from now.
Options considered
- nginx + certbot, the classic pairing. Proven, but the imperative configuration gets scattered across services, and the certbot-then-reload cycle remains machinery to maintain.
- Caddy makes automatic TLS very simple. It proved less capable for my use case, in particular on composable middlewares and Docker integration through labels.
- Traefik v3: automatic container discovery through labels, chainable
middlewares, built-in ACME. The service and its exposure are described in
the same place, in the versioned
docker-composefile.
Decision & why
Traefik v3, with a fully declarative configuration (static files and Docker labels), versioned in Git. Exposing a new service comes down to a few labels on its container:
# Exposing a service: a few labels are enough (example domain)
services:
app:
image: ghcr.io/exemple/app:1.4.2
networks: [proxy] # network dedicated to the reverse proxy
labels:
traefik.enable: true
traefik.http.routers.app.rule: Host(`app.exemple.fr`)
traefik.http.routers.app.entrypoints: websecure # HTTPS only
traefik.http.routers.app.middlewares: secure-headers@file
On the TLS side: a wildcard Let’s Encrypt certificate obtained through a DNS-01 challenge via the OVH API. Two concrete advantages. No HTTP port to open to validate the challenge. And subdomains are not enumerated one by one in the public Certificate Transparency logs: a wildcard does not disclose the map of services.
Accepted tradeoff
Traefik becomes the single central piece: if it goes down, the whole edge goes down. On a single-server platform, this point of failure is accepted. In exchange, there is only one surface to harden, monitor and update. Traefik’s learning curve (routers, services, middlewares) is real, but it is paid only once.
Outcome
Every service, public and private alike, goes through the same entry point, with HTTPS everywhere and certificates renewed without intervention since the initial setup.
- Reliable: automatic TLS renewal, configuration reproducible from Git.
- Maintainable: adding a service takes a few labels, and everything is versioned.
- Secure: global HTTPS redirection, HSTS, security headers, private services unreachable from the Internet.
Docker hardening around the proxy
The reverse proxy is the most exposed component of the platform. It is also the one with the most power, since it discovers containers through the Docker API. Hence a specific hardening:
- The Docker socket is never mounted directly. Traefik talks to a read-only socket-proxy that only lets through the API endpoints strictly required for service discovery. A compromise of the proxy does not hand over control of the Docker daemon.
no-new-privilegeson the containers: no privilege escalation possible through setuid binaries.- Separate internal networks. The reverse proxy network only contains the services to expose. Databases and supporting services live on internal networks with no route to the edge.
Middleware chain (redirection, HSTS, exposure)
Traefik middlewares are declared once, then composed per service:
- Global HTTPS redirection: everything arriving over HTTP is redirected, no exceptions.
secure-headers: HSTS,X-Content-Type-Options: nosniff, protection against iframe embedding, strict referrer policy.tailscale-only: private services only accept the zero-trust network. Details on the Zero-trust network page.
A service’s exposure is thus declared route by route, never implicitly.