Containers and platforms
How to get Let's Encrypt certificates for Nginx in Docker with Certbot
Run Nginx as a long-lived container and the official certbot/certbot image only on demand, with the challenge and certificate directories shared through bind mounts. Certbot gets the Let's Encrypt certificate with the webroot method, and a systemd timer on the host renews it and reloads Nginx.
The steps apply to Docker Engine with the Compose plugin (docker compose) on any Linux host and Certbot 4.1 or later images.
Prerequisites
Nginx publishes ports 80 and 443 on the host; Certbot only runs when needed.
- A Linux host with Docker Engine and the Compose plugin, and sudo access.
- DNS
A/AAAArecords forexample.comandwww.example.compointing at the host, for example203.0.113.10. - Inbound TCP 80 and 443 open to all sources, with nothing else bound to them on the host.
- A project directory, for example
/opt/web.
Step 1: Create the Compose file and directories
One Compose file defines both services; they share ./certbot/www for challenges and ./certbot/conf for certificates:
sudo mkdir -p /opt/web/nginx/conf.d /opt/web/certbot/www /opt/web/certbot/conf
cd /opt/web
# /opt/web/compose.yaml
services:
nginx:
image: nginx:stable
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt:ro
restart: unless-stopped
certbot:
image: certbot/certbot
volumes:
- ./certbot/www:/var/www/certbot
- ./certbot/conf:/etc/letsencrypt
profiles: ["tools"]
The tools profile keeps the certbot service from starting with docker compose up; you run it on demand. Pin image tags in production and update them deliberately.
Step 2: Serve the challenge directory over HTTP
Nginx must answer /.well-known/acme-challenge/ from the shared directory on port 80 and redirect everything else:
# /opt/web/nginx/conf.d/example.com.conf
server {
listen 80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
cd /opt/web && sudo docker compose up -d nginx
Step 3: Obtain the certificate
Run the certbot service once with the webroot method:
cd /opt/web
sudo docker compose run --rm certbot certonly --webroot -w /var/www/certbot \
--non-interactive --agree-tos \
-d example.com -d www.example.com
The certificate ends up in ./certbot/conf/live/example.com/ on the host. Let's Encrypt stopped sending expiry emails on June 4, 2025, so no email address is required.
Step 4: Add the HTTPS server block
Add the 443 server block only after the certificate exists, because Nginx refuses to start when a certificate file is missing. Append it to the same Nginx file:
server {
listen 443 ssl;
http2 on; # nginx 1.25.1+, true for current nginx:stable images
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
root /usr/share/nginx/html;
}
}
cd /opt/web && sudo docker compose exec nginx nginx -t && sudo docker compose exec nginx nginx -s reload
Replace the location / block with proxy_pass http://app:3000; to front another container on the same Compose network. Leave out ssl_stapling: Let's Encrypt certificates have no OCSP URL.
Verify
The endpoint should present a Let's Encrypt certificate for both names, and HTTP should return a 301. Check it with the SSL checker, or from any terminal:
curl ssl.sh/example.com
On the host:
cd /opt/web && sudo docker compose run --rm certbot certificates
curl -sI http://example.com | head -n 3
Automatic renewal
The Certbot container does not run continuously, so schedule renewal on the host with a systemd timer. certbot renew checks Let's Encrypt's ARI window (Certbot 4.1+) and only renews when due; reloading Nginx afterwards is harmless when nothing changed.
# /etc/systemd/system/certbot-docker.service
[Unit]
Description=Renew Let's Encrypt certificates (Docker)
[Service]
Type=oneshot
WorkingDirectory=/opt/web
ExecStart=/usr/bin/docker compose run --rm certbot renew --quiet
ExecStartPost=/usr/bin/docker compose exec -T nginx nginx -s reload
# /etc/systemd/system/certbot-docker.timer
[Unit]
Description=Twice-daily certificate renewal
[Timer]
OnCalendar=*-*-* 03,15:00:00
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload && sudo systemctl enable --now certbot-docker.timer
cd /opt/web && sudo docker compose run --rm certbot renew --dry-run
This keeps working when Let's Encrypt moves to 64-day certificates on February 10, 2027. Add the domain to ssl.sh monitoring (in development) because Let's Encrypt no longer emails before expiry.
Troubleshooting
- cannot load certificate ... No such file or directory when Nginx starts: the 443 block was added before the certificate existed, or only
live/is mounted; mount the whole./certbot/confdirectory. - Timeout during connect or 404 on the challenge: port 80 is not published, the webroot paths differ between containers, or DNS is wrong; see certbot timeout during connect.
- too many certificates already issued: repeated test runs; use
--test-certand see Let's Encrypt rate limits. - Old certificate still served after renewal: the reload step did not run; check
systemctl status certbot-docker.service. - Apps fail with an incomplete chain: Nginx points at
cert.pem; see missing intermediate certificate.
FAQ
Why not install Certbot inside the Nginx container?
Can I get a wildcard certificate with this setup?
Yes, with a DNS plugin image such as certbot/dns-cloudflare and a credentials file mounted into the container. The webroot volume is then unnecessary because DNS-01 does not touch port 80.
How do I avoid hitting rate limits while testing?
Add --test-cert to certonly to use the Let's Encrypt staging environment until the flow works, then delete the staging certificate with docker compose run --rm certbot delete --cert-name example.com and issue the real one.