Docker & ContainersXXV Β· Certificates & PKILifecycle
Delivering and rotating certificates into running containers
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
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
FROM nginx:1.27-alpine
COPY app.example.com.crt /etc/nginx/ssl/app.crt
COPY app.example.com.key /etc/nginx/ssl/app.keyBaking 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
FROMthis 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 saveand 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
| Mechanism | Renewal visible without recreate? | Notes |
|---|---|---|
| Baked into the image | no | never for keys |
| Bind mount of a directory | yes | the workhorse; see the inode trap below |
| Bind mount of a single file | no | breaks on atomic replacement |
| Named volume shared with an ACME sidecar | yes | Traefik and Caddy patterns |
Compose secrets with a file: source | no | materialised at container creation |
| Fetched at start from a secret store | at restart only | init 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.
| Server | How it picks up a new certificate |
|---|---|
| nginx | SIGHUP reloads configuration and re-reads certificates |
| HAProxy | SIGUSR2 to the master process, or a set ssl cert on the runtime API |
| Caddy | automatic for certificates it manages itself |
| Traefik | file provider with watch enabled, or automatic for its own ACME store |
| Envoy | SDS, designed for exactly this |
| Most application servers | restart |
For a containerised nginx, the signal goes through Docker:
$ docker kill -s HUP proxyproxyIllustrative 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.
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.
- 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. - Rotate: renew, confirm the new file is visible inside the container, signal or restart the server.
- 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.
- 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.
- Alert on the served certificate, from outside the host, not on the file modification time.
Sanity check
Knowledge check Β· 4 questions
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?
Q2. Which check most reliably proves a certificate rotation actually took effect?
Q3. Why is COPYing a TLS private key into an image a serious problem? Select all that apply.
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.