Docker & ContainersX Β· Production ArchitectureEdge
Reverse proxy, TLS, and the edge
What you'll learn
- Explain what TLS termination does to client identity and how to restore it
- Configure trusted-proxy handling so `X-Forwarded-For` cannot be spoofed
- Choose between HTTP forwarded headers and the PROXY protocol
- Verify a certificate chain and a client IP from outside the host
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
A container with a published port is reachable on the hostβs network. On a host with a public address, that means reachable from the internet. Almost no application container should be in that position; it should sit behind a reverse proxy that terminates TLS and routes by hostname.
Why a reverse proxy
- One place for TLS. The proxy holds the certificate and the private key. Renewal happens once, not once per service.
- Host-based routing. Twenty containers share one IP and one port 443. Without a proxy you are publishing twenty ports and telling people to remember numbers.
- A stable front door. Containers get a new IP every time they are replaced. The proxy re-resolves; clients never notice.
- Cross-cutting concerns in one place. Rate limiting, compression, request size limits, IP allow-lists, and a place to return a maintenance page when the backend is gone.
What TLS termination actually changes
# Only these sources may set X-Forwarded-For
set_real_ip_from 172.20.0.0/16; # the Docker bridge the proxy is on
real_ip_header X-Forwarded-For;
real_ip_recursive on; # walk right-to-left past trusted addresses
# The backend then sees the true client in $remote_addr
location / {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}Caddy and Traefik both do the sensible thing by default and need explicit
configuration to trust upstream headers β Caddy through
trusted_proxies, Traefik through forwardedHeaders.trustedIPs on the
entry point. If you are behind a CDN or a cloud load balancer, that
trusted set must include the CDNβs published ranges, and it must be only
those ranges.
When headers are not enough: the PROXY protocol
HTTP forwarded headers only work for HTTP. For anything else β a Postgres connection, an SMTP session, a raw TLS stream you are passing through without decrypting β there is no header to add.
The PROXY protocol solves this at the transport layer. Before any application bytes, the proxy sends a short preamble on the backend connection carrying the original source and destination addresses and ports:
PROXY TCP4 203.0.113.7 198.51.100.2 56324 443\r\n
Version 2 is a binary equivalent and is what you should prefer. The backend
must be configured to expect it, and this is the sharp edge: a listener
expecting the PROXY protocol cannot serve a client that does not send it,
and vice versa. There is no negotiation. Enable it on one side only and
every connection fails immediately β nginx logs broken header, Postgres
logs a protocol error, and the symptom looks like a TLS problem rather than
a configuration mismatch.
Two rules follow:
- Enable it on the backend first, on a listener that nothing is using yet, then switch the proxy over.
- A PROXY-protocol listener must never be reachable directly. Anything that can connect to it can declare any source address it likes, which is strictly worse than a forgeable header because there is no hop count to reason about.
Use it when you need real client IPs for non-HTTP traffic, or when you are passing TLS through to the backend without terminating it. Use forwarded headers for ordinary HTTP, because they are simpler and fail softer.
Choosing a proxy
Caddy. Automatic HTTPS by default, including ACME issuance and renewal, in a single binary with a short config. The best default for a single host.
# Caddyfile
shop.example.com {
reverse_proxy api:8080
}
That is a complete configuration: certificate obtained, renewed, HTTP redirected to HTTPS, and forwarded headers set correctly.
Traefik. Container-native β it watches the Docker API and configures itself from labels, so a new service needs no proxy config at all.
services:
api:
image: myorg/api:1.4.0
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`shop.example.com`)"
- "traefik.http.routers.api.tls.certresolver=le"
- "traefik.http.services.api.loadbalancer.server.port=8080"
The cost is that Traefik needs access to the Docker socket to do it. Mount it read-only, or better, run a socket-proxy that exposes only the container list endpoint β a container that can read the socket can enumerate every environment variable and secret mount on the host.
nginx. The deepest documentation and the most control. Configuration is
static, so a new service means editing a file and reloading. Certificates
need certbot or acme.sh alongside.
HAProxy. The best L4 load balancer of the four, with the most mature PROXY protocol support in both directions. Reach for it when you are proxying something that is not HTTP.
The choice matters far less than the architecture. All four terminate TLS, all four route by hostname, and all four need the trusted-proxy configuration above.
Verifying from outside
Every check below must run from another machine. Run from the host, they all pass regardless.
HOST=shop.example.com
# Full chain and expiry as a real client sees it
echo | openssl s_client -connect "$HOST":443 -servername "$HOST" 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
# HTTP must redirect, not serve
curl -sSI "http://$HOST/" | head -1
# The backend should see YOUR address, not the proxy's. Use whatever
# endpoint your application exposes that echoes the client IP it recorded,
# and compare it against the egress address of the machine you are on.
curl -fsS "https://$HOST/whoami"
ip -br route get 1.1.1.1
# Forging the header must NOT change what the application records
curl -fsS -H 'X-Forwarded-For: 192.0.2.99' "https://$HOST/whoami"The last two lines are the test that matters and the one nobody runs. If
the /whoami output changes when you forge the header, your trusted-proxy
configuration is wrong and every IP-based control you have is decorative.
Knowledge check
Knowledge check Β· 5 questions
Q1. TLS termination at the edge means:
Q2. An application reads the leftmost value of `X-Forwarded-For` as the client IP and trusts it from any source. What is the consequence?
Q3. Edge TLS termination requires the proxy to hold the private key.
Q4. Which are true of the PROXY protocol? Select all that apply.
Q5. A certificate expires despite an ACME client that renews at 30 days. Which monitoring check would have caught every likely cause?
Passing score: 75%. Answers are checked in this browser.