fix: route host loopback backends

This commit is contained in:
Alfredo Di Stasio
2026-06-30 10:16:03 +02:00
parent f3759e76ea
commit 1509c1e5fd
11 changed files with 46 additions and 48 deletions
+5 -5
View File
@@ -31,17 +31,17 @@ tcp:
- address: "host.docker.internal:8443"
```
`HostSNI(...)` matches the hostname sent by the client during the TLS handshake. If a backend listens on the Docker host itself, use `host.docker.internal:PORT`.
`HostSNI(...)` matches the hostname sent by the client during the TLS handshake. This deployment uses host networking so Traefik can reach services bound to the Docker host loopback address, such as `127.0.0.1:8443`.
HTTP-01 certificate challenges use normal HTTP routing on port `80`, not SNI. The included example forwards:
```text
lab.younerd.org/.well-known/acme-challenge/* -> host.docker.internal:8080
azionelab.org/.well-known/acme-challenge/* -> host.docker.internal:9080
www.azionelab.org/.well-known/acme-challenge/* -> host.docker.internal:9080
lab.younerd.org -> 127.0.0.1:8080
azionelab.org -> 127.0.0.1:9080
www.azionelab.org -> 127.0.0.1:9080
```
Adjust `8080` and `9080` to the local ports where the Certbot challenge responder or webroot service is listening.
Adjust `8080` and `9080` to the local ports where the backend HTTP service is listening. The full HTTP request is forwarded so the backend can handle redirects and ACME challenge paths.
## Run
+3 -5
View File
@@ -5,16 +5,14 @@ services:
restart: unless-stopped
command:
- --configFile=/etc/traefik/traefik.yml
ports:
- "80:8080/tcp"
- "443:8443/tcp"
extra_hosts:
- "host.docker.internal:host-gateway"
network_mode: host
volumes:
- ./config:/etc/traefik:ro
read_only: true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
healthcheck:
+6 -6
View File
@@ -3,25 +3,25 @@ http:
lab-younerd-acme:
entryPoints:
- web
rule: "Host(`lab.younerd.org`) && PathPrefix(`/.well-known/acme-challenge/`)"
rule: "Host(`lab.younerd.org`)"
service: lab-younerd-acme
azionelab-acme:
entryPoints:
- web
rule: "(Host(`azionelab.org`) || Host(`www.azionelab.org`)) && PathPrefix(`/.well-known/acme-challenge/`)"
rule: "Host(`azionelab.org`) || Host(`www.azionelab.org`)"
service: azionelab-acme
services:
lab-younerd-acme:
loadBalancer:
servers:
- url: "http://host.docker.internal:8080"
- url: "http://127.0.0.1:8080"
azionelab-acme:
loadBalancer:
servers:
- url: "http://host.docker.internal:9080"
- url: "http://127.0.0.1:9080"
tcp:
routers:
@@ -45,9 +45,9 @@ tcp:
lab-younerd:
loadBalancer:
servers:
- address: "host.docker.internal:8443"
- address: "127.0.0.1:8443"
azionelab:
loadBalancer:
servers:
- address: "host.docker.internal:9443"
- address: "127.0.0.1:9443"
+2 -2
View File
@@ -4,9 +4,9 @@ global:
entryPoints:
web:
address: ":8080"
address: ":80"
tls:
address: ":8443"
address: ":443"
ping:
address: ":8082"
+4 -4
View File
@@ -12,9 +12,9 @@ The load balancer must run as a Docker container and forward TCP TLS traffic to
Use Traefik Proxy in Docker with the file provider and TCP TLS passthrough routing. Static and dynamic configuration live under the host `./config` directory and are mounted read-only at `/etc/traefik`.
The public host port `443/tcp` is mapped to container port `8443/tcp` so the container can run with all Linux capabilities dropped. Traefik routes incoming TLS connections with `HostSNI(...)` to the configured TCP load-balancer services.
Traefik uses host networking so it can reach backends bound to host loopback addresses such as `127.0.0.1:8443`. The container is granted only `NET_BIND_SERVICE` so Traefik can bind host ports `80` and `443`.
The public host port `80/tcp` is mapped to container port `8080/tcp` for HTTP-01 certificate challenge traffic. HTTP challenge routes use `Host(...)` and `PathPrefix('/.well-known/acme-challenge/')` because SNI exists only in TLS handshakes.
Traefik routes incoming TLS connections with `HostSNI(...)` to the configured TCP load-balancer services. HTTP routes on port `80` use `Host(...)` and forward the full request to the selected backend because SNI exists only in TLS handshakes and backend nginx instances are responsible for redirects and ACME challenge handling.
## Consequences
@@ -30,11 +30,11 @@ The Docker provider was not selected because the backends are addressed directly
## Security impact
The container does not mount the Docker socket, does not run privileged, drops Linux capabilities, uses `no-new-privileges`, and mounts configuration read-only. No private keys or credentials are required in this repository. Public `80/tcp` exposure is limited by HTTP routers to ACME challenge paths for configured hostnames.
The container does not mount the Docker socket, does not run privileged, drops Linux capabilities except `NET_BIND_SERVICE`, uses `no-new-privileges`, and mounts configuration read-only. No private keys or credentials are required in this repository. Host networking exposes Traefik directly on the host network namespace, so host firewall rules should restrict non-public ports such as the ping entrypoint.
## Operational impact
Operators update backend IP addresses and ports in `config/dynamic/tcp-services.yml`. The Compose deployment publishes `80/tcp` and `443/tcp`; the ping entrypoint is internal to the container and used by the healthcheck.
Operators update backend IP addresses and ports in `config/dynamic/tcp-services.yml`. Traefik listens directly on host `80/tcp` and `443/tcp`; the ping entrypoint is used by the healthcheck.
## Rollback
+7 -7
View File
@@ -4,24 +4,24 @@ This project runs Traefik Proxy as a Dockerized TCP load balancer.
## Components
- `compose.yaml`: defines the Traefik container, published port, host-based configuration volume, container hardening, and healthcheck.
- `compose.yaml`: defines the Traefik container, host networking, host-based configuration volume, container hardening, and healthcheck.
- `config/traefik.yml`: static Traefik configuration for HTTP, TCP TLS, ping, file provider, and logging.
- `config/dynamic/tcp-services.yml`: dynamic HTTP challenge routers, TCP routers, and backend load-balancer services.
## Data flow
1. A client opens a TLS connection to the Docker host on `443/tcp`.
2. Docker forwards the connection to Traefik on container port `8443/tcp`.
3. Traefik matches the TCP router using `HostSNI(*)`.
2. Traefik receives the connection directly through host networking on `443/tcp`.
3. Traefik matches the TCP router using `HostSNI(...)`.
4. Traefik forwards the unchanged TCP stream to one configured backend IP address and port.
5. The selected backend terminates TLS and presents its own SSL certificate.
For HTTP-01 certificate validation:
1. Let's Encrypt opens an HTTP connection to the Docker host on `80/tcp`.
2. Docker forwards the request to Traefik on container port `8080/tcp`.
3. Traefik matches the HTTP `Host(...)` and `PathPrefix('/.well-known/acme-challenge/')` router.
4. Traefik forwards the request to the configured local challenge responder.
2. Traefik receives the request directly through host networking on `80/tcp`.
3. Traefik matches the HTTP `Host(...)` router.
4. Traefik forwards the full HTTP request to the configured local backend so that backend redirects and ACME challenge handling remain in one place.
## Persistence
@@ -31,6 +31,6 @@ No certificates, private keys, application data, or Docker socket are mounted in
## Deployment topology
Traefik runs as one Docker container on the load-balancer host. Backends are external to this Compose stack and must be reachable from the container through their configured IP addresses and ports. The Compose service defines `host.docker.internal` through Docker's `host-gateway` feature for backends that listen on the Docker host itself.
Traefik runs as one Docker container on the load-balancer host with `network_mode: host`. Backends are external to this Compose stack and can be reached on host loopback addresses such as `127.0.0.1:8443`.
See `docs/adr/0001-traefik-tcp-tls-passthrough.md` for the design decision.
+6 -6
View File
@@ -23,12 +23,12 @@ tcp:
example:
loadBalancer:
servers:
- address: "host.docker.internal:8443"
- address: "127.0.0.1:8443"
```
If a backend listens on the Docker host, use `host.docker.internal:PORT`. Compose maps that name through Docker's `host-gateway` feature.
This deployment uses host networking so Traefik can reach services bound to host loopback addresses such as `127.0.0.1:8443`.
HTTP-01 certificate challenges are routed separately with HTTP `Host(...)` rules on port `80`. Configure the `*-acme` services in `config/dynamic/tcp-services.yml` to point at the local port where the challenge responder listens.
HTTP traffic is routed separately with HTTP `Host(...)` rules on port `80`. Configure the HTTP services in `config/dynamic/tcp-services.yml` to point at the local backend port that should handle redirects and ACME challenge paths.
The `./config` directory is the host-based configuration volume. It is mounted read-only into the container:
@@ -39,9 +39,9 @@ volumes:
## Ports
- Host `80/tcp` -> container `8080/tcp`: public HTTP entrypoint for ACME HTTP-01 challenge paths.
- Host `443/tcp` -> container `8443/tcp`: public TCP TLS passthrough load-balancer entrypoint.
- Container `8082/tcp`: internal Traefik ping entrypoint used only by the container healthcheck.
- Host `80/tcp`: public HTTP entrypoint handled directly by Traefik through host networking.
- Host `443/tcp`: public TCP TLS passthrough entrypoint handled directly by Traefik through host networking.
- Host `8082/tcp`: Traefik ping entrypoint for the container healthcheck; restrict this port with host firewall rules if needed.
## Deploy
+3 -3
View File
@@ -48,13 +48,13 @@ docker compose logs -f traefik
To remove a backend from rotation, edit `config/dynamic/tcp-services.yml` and remove or comment out its `address` entry. Keep at least one reachable backend configured before applying the change.
For backends running on the Docker host, use `host.docker.internal:PORT` rather than `127.0.0.1:PORT`, because `127.0.0.1` inside the container refers to the container itself.
This deployment uses host networking. For backends running on the Docker host, use `127.0.0.1:PORT` when the service is bound to host loopback.
For HTTP-01 challenges, update the `*-acme` HTTP services in `config/dynamic/tcp-services.yml` to match the local challenge responder ports.
For HTTP on port `80`, update the HTTP services in `config/dynamic/tcp-services.yml` to match the local backend ports that handle redirects and ACME challenge paths.
## Known operational risks
- TLS passthrough prevents Traefik from inspecting HTTP paths, headers, or certificate contents.
- Backend certificates and renewal jobs must be monitored on each backend.
- SNI routing depends on clients sending the expected hostname during the TLS handshake. Clients that connect by IP address or omit SNI will not match hostname-specific routers.
- Public port `80/tcp` is only intended for `/.well-known/acme-challenge/` traffic in this configuration.
- Public port `80/tcp` forwards all HTTP paths for configured hostnames to the matching backend.
+2 -2
View File
@@ -41,11 +41,11 @@ Confirm that `config/traefik.yml` is mounted and that the ping entrypoint is ena
### Clients cannot connect
Check that the host is listening on `80/tcp` and `443/tcp`, firewall rules allow inbound traffic, and backend IP addresses are reachable from the Traefik container. For services on the Docker host, configure backends as `host.docker.internal:PORT`.
Check that the host is listening on `80/tcp` and `443/tcp`, firewall rules allow inbound traffic, and backend IP addresses are reachable from Traefik. For services bound to host loopback, configure backends as `127.0.0.1:PORT`.
### Certificate HTTP-01 challenge fails
Check that port `80/tcp` is reachable from the internet and that the matching `*-acme` service points to the local challenge responder port.
Check that port `80/tcp` is reachable from the internet and that the matching HTTP service points to the local backend that handles ACME challenge paths.
### Wrong certificate is presented
+3 -3
View File
@@ -13,9 +13,9 @@ Backend services are responsible for:
## Network exposure
Compose publishes `80/tcp` for HTTP-01 certificate challenges and `443/tcp` for TLS passthrough traffic. The Traefik ping entrypoint listens inside the container for healthchecks and is not published on the host.
Traefik uses host networking and listens directly on host ports `80/tcp` for HTTP traffic and `443/tcp` for TLS passthrough traffic. The Traefik ping entrypoint listens on host port `8082/tcp` for healthchecks; restrict this port with host firewall rules if needed.
The HTTP routers only match `/.well-known/acme-challenge/` paths for configured hostnames.
The HTTP routers match configured hostnames and forward the full HTTP request to the selected backend. Backend services are responsible for redirects and ACME challenge handling.
## Container hardening
@@ -23,7 +23,7 @@ The Traefik service:
- does not mount the Docker socket;
- is not privileged;
- drops Linux capabilities;
- drops Linux capabilities except `NET_BIND_SERVICE`, which is required to bind host ports `80` and `443`;
- enables `no-new-privileges`;
- mounts configuration read-only;
- uses an explicit Traefik image tag instead of `latest`.
+5 -5
View File
@@ -12,12 +12,12 @@ docker compose config
## Smoke test
After replacing the example backend addresses and starting the stack, verify HTTP-01 routing:
After replacing the example backend addresses and starting the stack, verify HTTP routing:
```bash
curl -H 'Host: lab.younerd.org' http://LOAD_BALANCER_HOST/.well-known/acme-challenge/test-token
curl -H 'Host: azionelab.org' http://LOAD_BALANCER_HOST/.well-known/acme-challenge/test-token
curl -H 'Host: www.azionelab.org' http://LOAD_BALANCER_HOST/.well-known/acme-challenge/test-token
curl -H 'Host: lab.younerd.org' http://LOAD_BALANCER_HOST/
curl -H 'Host: azionelab.org' http://LOAD_BALANCER_HOST/
curl -H 'Host: www.azionelab.org' http://LOAD_BALANCER_HOST/
```
Then verify TCP TLS connectivity:
@@ -40,5 +40,5 @@ openssl s_client -connect LOAD_BALANCER_HOST:443 -servername www.azionelab.org
- Docker/Compose validation: `docker compose config`.
- Runtime health: `docker compose ps`.
- HTTP-01 route smoke test: `curl`.
- HTTP route smoke test: `curl`.
- TLS passthrough smoke test: `openssl s_client`.