Setting Up HTTPS for Dockerized Nginx with certbot
Environment: Docker-1.13.1, nginx-1.15.12, certbot
Enabling HTTPS for a site isn't hard when nginx runs directly on the host — tutorials are everywhere. But when nginx runs inside a docker container, the certificate files have to be mounted in via volumes, which adds a layer of filesystem indirection. And as it happens, the certificate files certbot generates are symlinks. When these two things collide, you get a pitfall that's genuinely hard to reason about without checking the docs. This post records how I debugged it at the time.
Since sites without HTTPS now get flagged as insecure, I decided to set it up and apply for a free Let's Encrypt certificate — it's not much hassle after all. What I never expected was that configuring an SSL certificate inside a docker nginx container hides a trap. I was starting from scratch and had never read the docker documentation, so I got bitten here. When I get the chance, I really should work through the official docs properly.
Where the Certificates Come From
First, some context on my situation. After using certbot to get a free SSL certificate for my domain, the resulting
fullchain.pem and privkey.pem files were symlinks.
This is no accident — it's certbot's deliberate design. The real certificate files live under /etc/letsencrypt/archive/<domain>/, and each renewal generates a new set of files there. The fullchain.pem and privkey.pem under /etc/letsencrypt/live/<domain>/ are just symlinks pointing at the latest version in archive. That way your nginx config can reference the fixed live path and never needs changing after a renewal. On a physical host this mechanism is very convenient — but inside a container it falls apart.
Mounting into the Container
Next, I used docker volumes to mount the SSL config folder into the nginx container, and configured nginx.conf along the way. (Note that the nginx container's nginx.conf works a little differently: the main nginx.conf includes the *.conf files under /etc/nginx/conf.d/ — a small detail worth paying attention to. And don't forget to map the container's port 443.)
The site config looks roughly like this:
# Placed under /etc/nginx/conf.d/, pulled in by the main config's include
server {
listen 443 ssl;
server_name example.com;
# These are paths inside the container — they must match
# where the files actually end up after mounting
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}
Where the Trap Is
Here's the catch. Because the certificate files are symlinks, they become unusable once mounted into the container.
nginx throws an error saying it can't find the certificate file. But if you enter the container, you'll see the certificate file is indeed there — except it can't be read. This has nothing to do with permissions. It's entirely because docker volumes only map real file data; a symlink can't be meaningfully mapped, and what's left in the container is just an empty shell of a link.
There's a simple way to diagnose this kind of problem: get into the container and run ls -l. If the certificate file shows up as a link pointing at some path, try cat-ing it. If it can't be read, the link's target almost certainly doesn't exist inside the container.
Why It Works This Way
It's actually not hard to understand. A symlink is just a shortcut. If a shortcut inside a volume could reach through to arbitrary files on the host, that would be the real problem. The symlink might live on the /home volume while the actual file it points to lives on the /usr volume. But my volumes config only mapped /home, which cannot access data on /usr. You have to map things so the link target is accurately covered — mounting just the directory containing the symlink isn't enough, and neither is mounting the symlink file directly.
Put another way: a symlink stores nothing but a target path. Docker copies the link into the container as-is, and the container then resolves that path against its own filesystem. If the target path wasn't mounted in as well, the link is dead. This is also exactly what container isolation is supposed to mean — if mounting a shortcut let you follow it to read any file on the host, isolation would be meaningless.
The mount must cover the full path that the certificate symlinks resolve to. Only then can nginx inside docker follow the links to the actual certificate files at startup.
In other words, the key is making sure the symlink's target path genuinely exists inside the container. For certbot, that means both the live and archive directories have to go into the container together so the links can resolve:
# Mount the entire letsencrypt directory at container start —
# with both the symlinks under live and the real files under archive
# present, the links resolve correctly
docker run -d \
-p 80:80 -p 443:443 \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
-v /data/nginx/conf.d:/etc/nginx/conf.d \
nginx
Pitfalls and Notes
-
The official nginx image's main config only handles global settings; site configs come in via include
/etc/nginx/conf.d/*.conf. Your server block has to go in the right place, or your changes silently do nothing. -
Port 443 is easy to forget. If nginx listens on 443 inside the container but the host doesn't map it, browsers still can't connect — and the nginx logs show nothing wrong.
-
Let's Encrypt certificates are only valid for 90 days. After renewal, a new set of files appears under archive and the symlinks under live are updated accordingly. If you took the shortcut of hardcoding the resolved real file paths in your config, they'll be wrong after the next renewal — which is exactly why mounting the whole directory and referencing the live paths is the recommended approach. And remember to have nginx inside the container reload its config after a renewal.
-
Seeing that a file "exists" in the container doesn't mean it's readable.
lson a symlink won't reveal the problem;catit, or just read nginx's error message — that's more direct.
Wrapping Up
The essence of this pitfall is two individually reasonable designs colliding: certbot uses symlinks to decouple "a fixed path" from "certificate files that change", while docker volumes faithfully transport the link itself without following its target. Once you understand that a symlink stores nothing but a path, the mystery disappears — mount the link and its target into the container together so the path resolves fully inside it, and the certificates become readable. Consider it a lesson in docker documentation, paid for with one pitfall.
COMMENTS