Docker & ContainersXXV Β· Certificates & PKITLS termination
TLS termination, ACME, and Let's Encrypt
What you'll learn
- Explain where TLS terminates and what the backend consequently cannot see
- Choose between HTTP-01, DNS-01 and TLS-ALPN-01 for a given deployment
- Keep the ACME challenge path reachable through a reverse proxy
- Verify a served chain from outside the host rather than from the filesystem
Prerequisites
None β start here.
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
TLS termination happens at the edge. A reverse proxy or load balancer holds the certificate and the private key, decrypts the request, and forwards plain HTTP to the backend on an internal network. The backend never sees TLS.
That architecture is right, and it moves the entire certificate problem into one container. Everything that follows is about that container.
The architecture
flowchart LR
Client -->|HTTPS 443| Proxy[Reverse proxy<br/>holds cert + key]
Proxy -->|HTTP 80, internal network| App[Application container]
ACME[ACME server] -.->|HTTP-01 challenge, port 80| Proxy
Three consequences worth stating explicitly, because each one produces a support ticket:
- The application sees plain HTTP. Anything it does with
request.is_secure, or a redirect it builds from the scheme, will be wrong unless the proxy setsX-Forwarded-Protoand the application is configured to trust it. - The application sees the proxyβs address as the client. Real client
addresses arrive in
X-Forwarded-For, which means rate limiting and audit logging in the application depend on a header rather than the transport. - The backend network carries plaintext. On a single host that is a Docker bridge and acceptable. Across hosts it is not, and that is what changes when the stack grows.
How ACME proves you control the name
ACME (RFC 8555) automates a challenge-response: the CA gives your client a token, the client makes it observable somewhere only the domain owner controls, and the CA checks.
| Challenge | Where the proof goes | Port | Wildcards |
|---|---|---|---|
| HTTP-01 | http://<domain>/.well-known/acme-challenge/<token> | 80 only | No |
| DNS-01 | A TXT record at _acme-challenge.<domain> | none | Yes |
| TLS-ALPN-01 | A TLS handshake using a custom ALPN protocol | 443 | No |
Facts that decide the choice, taken from Letβs Encryptβs documentation:
- HTTP-01 can only be done on port 80. You cannot move it. Validation
starts there, though it does follow redirects β up to ten deep, to
http:orhttps:only, and only to ports 80 or 443. That redirect allowance is what makes the common HTTP-to-HTTPS redirect compatible with renewal, as long as the redirected request still reaches the challenge path. - DNS-01 is the only one that issues wildcards, and the only one that works for a host with no inbound path from the internet β an internal service, or one behind a WAF you do not control.
- TLS-ALPN-01 runs on 443, which is useful when port 80 is closed by policy, and needs proxy support because it must intercept the handshake.
The failure that costs a night
DOMAIN=app.example.com
# Put a probe file where the ACME client writes challenges,
# then fetch it exactly the way the CA will.
echo "acme-path-probe" | sudo tee /srv/certbot-webroot/.well-known/acme-challenge/probe > /dev/null
curl -sS -L -o - -w '\nfinal: %{url_effective} status: %{http_code}\n' \
"http://$DOMAIN/.well-known/acme-challenge/probe"
sudo rm -f /srv/certbot-webroot/.well-known/acme-challenge/probeYou want acme-path-probe and status: 200. A 301 that ends at your
applicationβs 404 page is the failure above, three months early. -L is
essential β without it you would see the redirect and conclude, wrongly, that
something is broken.
Run that probe from a machine outside the host. From the Docker host itself,
app.example.com may resolve to a private address, or hairpin back to the
proxy in a way that skips a WAF, a CDN or a cloud load balancer that sits in
the real path.
Tools
| Tool | Best for | Renewal model |
|---|---|---|
| Caddy | Zero-config edge; certificates are automatic | Built in, continuous |
| Traefik | Docker-label-driven routing | Built in, continuous |
| certbot | Existing nginx or Apache, scripted flows | systemd timer or cron |
| acme.sh | Minimal footprint, many DNS providers | cron |
DOMAIN=app.example.com
EMAIL=ops@example.com
docker run --rm \
-v /srv/letsencrypt:/etc/letsencrypt \
-v /srv/certbot-webroot:/var/www/certbot \
certbot/certbot:v4.1.1 certonly \
--webroot --webroot-path /var/www/certbot \
-d "$DOMAIN" \
--email "$EMAIL" --agree-tos --no-eff-email \
--dry-run--dry-run runs the full flow against the staging environment. Use it every
time you change the proxy, the volumes or the paths. It is free, it does not
count against rate limits, and it is the only way to find a broken challenge
path before it matters.
Certificate lifetime is shrinking
Letβs Encryptβs default remains 90 days, and the vast majority of issued certificates are 90-day. Two changes are already live and worth knowing before someone enables one:
- Short-lived certificates are generally available β 160 hours, just over
six days β as an opt-in ACME profile named
shortlived. Letβs Encrypt has stated no plan to make them the default. - From May 2026 the
tlsserverprofile issues 45-day certificates, also opt-in, as a step in a stated plan to move to 45 days by 2028.
The operational reading is not βour certificates are about to changeβ. It is that every manual step in your renewal path has a shrinking budget. A process that needs a human to reload something works acceptably at 90 days, is painful at 45, and is impossible at six. Automate the reload now, while the margin still exists to get it wrong.
Verifying from outside
DOMAIN=app.example.com
# Validity window of the leaf certificate being served right now
openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" < /dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
# Days remaining, as a number you can alert on
END=$(openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" < /dev/null 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)
echo "$(( ( $(date -d "$END" +%s) - $(date +%s) ) / 86400 )) days remaining"
# Does the chain verify against the system trust store?
openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" \
-verify_return_error < /dev/null 2>&1 | grep -E 'Verify return code|verify error'-servername is not optional. Without it, OpenSSL sends no SNI, and a proxy
serving several names returns its default certificate β so you verify a
certificate that no real client will ever be given.
Sanity check
- A probe file under
/.well-known/acme-challenge/is retrievable over plain HTTP from outside, following redirects, returning 200. certbot renew --dry-runpasses today, against the current proxy config.openssl s_client -servernamefrom off-host shows the expected leaf and at least one intermediate underCertificate chain.- Your monitoring reads days-remaining from the connection, not from a file on the host.
Knowledge check
Knowledge check Β· 5 questions
Q1. A proxy redirects all of port 80 to HTTPS and the HTTPS server routes / to the application. Renewal has been failing for weeks. What is happening?
Q2. You need a wildcard certificate for *.example.com. Which challenge type can issue it?
Q3. A site works in browsers but fails from `curl` on a minimal container image with a certificate verification error. What is the most likely cause?
Q4. Which statements about Letβs Encrypt are currently true? Select all that apply.
Q5. After `certbot renew` writes a new certificate, clients keep receiving the old one until the proxy reloads.
Passing score: 75%. Answers are checked in this browser.