Skip to main content
RunBook Academy

Docker & ContainersX Β· Production ArchitectureEdge

Reverse proxy, TLS, and the edge

Intermediate⏱ ~26 mindockeropensslcurl

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

Not yet marked complete on this device.

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

Configuration changenginx realip
# 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.

Read-only / Safeedge checks
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

  1. Q1. TLS termination at the edge means:

  2. 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?

  3. Q3. Edge TLS termination requires the proxy to hold the private key.

  4. Q4. Which are true of the PROXY protocol? Select all that apply.

  5. 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.