Skip to main content
RunBook Academy

Docker & ContainersXXV Β· Certificates & PKITLS termination

TLS termination, ACME, and Let's Encrypt

Intermediate⏱ ~26 mindockeropenssl

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

Not yet marked complete on this device.

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 sets X-Forwarded-Proto and 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.

ChallengeWhere the proof goesPortWildcards
HTTP-01http://<domain>/.well-known/acme-challenge/<token>80 onlyNo
DNS-01A TXT record at _acme-challenge.<domain>noneYes
TLS-ALPN-01A TLS handshake using a custom ALPN protocol443No

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: or https: 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

Read-only / Safeprove the challenge path is reachable
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/probe

You 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

ToolBest forRenewal model
CaddyZero-config edge; certificates are automaticBuilt in, continuous
TraefikDocker-label-driven routingBuilt in, continuous
certbotExisting nginx or Apache, scripted flowssystemd timer or cron
acme.shMinimal footprint, many DNS providerscron
Configuration changecertbot in the webroot pattern
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 tlsserver profile 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

Read-only / Safewhat is actually served
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-run passes today, against the current proxy config.
  • openssl s_client -servername from off-host shows the expected leaf and at least one intermediate under Certificate chain.
  • Your monitoring reads days-remaining from the connection, not from a file on the host.

Knowledge check

Knowledge check Β· 5 questions

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

  2. Q2. You need a wildcard certificate for *.example.com. Which challenge type can issue it?

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

  4. Q4. Which statements about Let’s Encrypt are currently true? Select all that apply.

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