Skip to main content
RunBook Academy

Docker & ContainersXXV Β· Certificates & PKILifecycle

Delivering and rotating certificates into running containers

Advanced⏱ ~20 min

What you'll learn

  • Choose a delivery mechanism that survives renewal
  • Explain why a single-file bind mount defeats atomic replacement
  • Verify a rotation against the served certificate rather than the file on disk

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-11

Not yet marked complete on this device.

Renewal is the easy half. The ACME client ran, exited zero, and wrote a fresh certificate to the host. Monitoring on the file’s expiry date is green.

Users still get the expired one, because the certificate a service serves and the certificate on disk are two different facts, and nothing automatically connects them. This lesson is about the wiring between them: how the file gets into the container, and what makes the process inside pick it up.

The anti-pattern first

Configuration changeDo not do this
FROM nginx:1.27-alpine
COPY app.example.com.crt /etc/nginx/ssl/app.crt
COPY app.example.com.key /etc/nginx/ssl/app.key

Baking the certificate into the image is convenient exactly once. Afterwards:

  • Renewal becomes a rebuild. A ninety-day certificate now forces an image build and a deployment every ninety days, on a schedule set by the CA rather than by you.
  • The private key is in a layer forever. Layers are immutable and content-addressed. The key is in the registry, in every build cache that touched it, in every image derived FROM this one, and in the local store of every machine that has ever pulled it. Deleting the file in a later layer does not remove it.
  • Anyone who can pull can extract it. docker save and a tar extraction is the whole attack.

A private key in an image is a leaked private key with a delay. Treat it as one: rotate the key, do not just fix the Dockerfile.

Delivery mechanisms

MechanismRenewal visible without recreate?Notes
Baked into the imagenonever for keys
Bind mount of a directoryyesthe workhorse; see the inode trap below
Bind mount of a single filenobreaks on atomic replacement
Named volume shared with an ACME sidecaryesTraefik and Caddy patterns
Compose secrets with a file: sourcenomaterialised at container creation
Fetched at start from a secret storeat restart onlyinit container or entrypoint

Compose secrets are worth a sentence of their own. A secret defined with a file: source appears in the container under /run/secrets/, and it is established when the container is created. Changing the source file on the host does not change what a running container sees. That is fine for a certificate you rotate by redeploying; it is not a hot-reload mechanism, and treating it as one is a common mistake.

The inode trap

Every well-written renewal tool replaces a certificate atomically: write cert.pem.tmp in the same directory, then rename() it over cert.pem. A reader either sees the whole old file or the whole new one, never a half-written PEM. This is correct, standard, careful behaviour.

It is also exactly what breaks a single-file bind mount.

Making the process pick it up

Getting the new bytes into the container is necessary and not sufficient. Servers read certificates into memory at startup and hold them for the life of the listener.

ServerHow it picks up a new certificate
nginxSIGHUP reloads configuration and re-reads certificates
HAProxySIGUSR2 to the master process, or a set ssl cert on the runtime API
Caddyautomatic for certificates it manages itself
Traefikfile provider with watch enabled, or automatic for its own ACME store
EnvoySDS, designed for exactly this
Most application serversrestart

For a containerised nginx, the signal goes through Docker:

Service impact possiblereload without restart
$ docker kill -s HUP proxy
proxy

Illustrative output

docker kill -s sends an arbitrary signal to the container’s main process β€” the command is named for its default, not its only behaviour. nginx handles SIGHUP by starting new workers with the new configuration and letting the old ones finish their connections, so this is a graceful reload rather than an interruption.

Permissions bite after the rotation, not before

Bind-mounted files keep their host ownership and mode. A key written by a root-owned renewal process as 0600 root:root is unreadable by a container process running as UID 1000 β€” and many hardened images do run as non-root.

This produces the worst possible timing: the stack works for ninety days on the certificate that was in place when it was set up, then fails at the first rotation, at whatever hour the renewal cron runs.

Read-only / Safecheck before you trust it
CTR=proxy

# What UID does the process actually run as?
docker inspect "$CTR" --format '{{.Config.User}}'

# Can that UID read the key through the mount?
docker exec "$CTR" sh -c 'id; head -c 32 /etc/ssl/certs/app/privkey.pem > /dev/null && echo READABLE'

Fix it with a group the container user belongs to and 0640, not by running the container as root.

Verify against the served certificate

The file on disk is not the fact you care about. Compare serial numbers before and after β€” dates are ambiguous when a renewal produces a certificate with overlapping validity, and the serial is unique per issuance.

  1. Before rotation, record the serial of what is being served: openssl s_client -connect app.example.com:443 -servername app.example.com < /dev/null | openssl x509 -noout -serial -dates.
  2. Rotate: renew, confirm the new file is visible inside the container, signal or restart the server.
  3. Record the serial again from the same command. A changed serial is the proof; an unchanged serial means the reload did not take, whatever the exit codes said.
  4. Check every name and every port that terminates TLS. A wildcard used by six virtual hosts is six checks, and the one nobody reloaded is the one that pages you.
  5. Alert on the served certificate, from outside the host, not on the file modification time.

Sanity check

Knowledge check Β· 4 questions

  1. Q1. A proxy container bind-mounts a single file, /etc/letsencrypt/live/app/fullchain.pem, to /etc/ssl/cert.pem. Renewal succeeds and writes a new file via a temp-file-and-rename. What does the container see?

  2. Q2. Which check most reliably proves a certificate rotation actually took effect?

  3. Q3. Why is COPYing a TLS private key into an image a serious problem? Select all that apply.

  4. Q4. Changing the source file behind a Compose `secrets` entry updates the file a running container sees under /run/secrets.

Passing score: 75%. Answers are checked in this browser.