Bali, Indonesia
Insights

Certbot Renewed the Certificate, So Why Does Nginx Still Serve the Old One?

October 26, 2026
0:00
The renewal log says the certificate was renewed. The files in /etc/letsencrypt/live/ have fresh timestamps. Yet the browser, an uptime monitor, or a customer's API client still reports the old expiry date. This is one of the more confusing SSL tickets, because every piece looks fine on its own until you check what Nginx is actually sending over the wire. The short version: Nginx reads certificate files when it starts or reloads, not on every request. Certbot can replace the files on disk all day, and Nginx will keep serving the copy it loaded earlier until something tells it to reload. This guide walks through checking that directly from the command line, so you are not guessing from a browser padlock.
When Nginx starts, the master process parses the configuration, opens the files named in ssl_certificate and ssl_certificate_key, and hands the loaded certificate to its worker processes. Those workers serve TLS handshakes from memory. They do not watch the files for changes. A reload (systemctl reload nginx or nginx -s reload) makes the master re-read the config and certificate files, start new workers with the new data, and gracefully retire the old ones. Existing connections finish on the old workers, so there is no visible downtime. If the new config fails to parse, the reload is rejected and the old workers keep running with the old certificate. Certbot itself only writes files. Whether it reloads Nginx afterwards depends on how the certificate was obtained. If you used the nginx plugin, the renewal config usually records installer = nginx, and Certbot typically reloads Nginx after a successful renewal. If you used --webroot or --standalone, or a DNS plugin, Certbot has no reason to touch Nginx unless you gave it a hook. That second case is where this problem usually comes from. This matters more as certificate lifetimes shrink. Under the CA/Browser Forum's Ballot SC-081 schedule toward 47-day certificates, a single certificate can cycle 8 to 11 times a year, so a missing reload stops being an annual annoyance and becomes a recurring outage. The broader tracking problem is covered in Why Manual SSL Renewal Tracking Fails as Certificate Lifetimes Shrink to 47 Days.
Don't compare expiry dates by eye in a browser. Compare the serial number and fingerprint, which uniquely identify a certificate. Run one command against the file and one against the live port, then put the output side by side.
BASH
# Certificate on disk (replace example.com with the lineage name from 'certbot certificates')
sudo openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -serial -enddate -fingerprint -sha256

# Certificate actually served on port 443, using SNI for the right hostname
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -serial -enddate -fingerprint -sha256

# Same check against the origin IP directly, to skip DNS, a CDN, or a proxy
echo | openssl s_client -connect 203.0.113.10:443 -servername example.com 2>/dev/null | openssl x509 -noout -serial -enddate -fingerprint -sha256
A few details are worth knowing here. The -servername flag sends SNI (Server Name Indication), which tells Nginx which hostname you want. Leave it out and you may get whatever certificate the default server block uses, which can look like a renewal failure when it isn't. The echo | closes the connection immediately so the command does not sit waiting for input. Replace 203.0.113.10 with your server's real IP. Read the results like this:
  • Disk and served match, both new: the renewal and reload worked. If a browser still shows the old date, look at caching on the client side or at a different hostname.
  • Disk is new, served is old, and the origin IP check is also old: Nginx has not reloaded, or it is pointing at a different file. Continue to the next section.
  • Disk is new, origin IP check is new, but the hostname check is old: something between the visitor and your server is terminating TLS, such as a CDN, load balancer, or the DNS record pointing to another machine. The fix is there, not in Nginx.
That last case trips up a lot of people after migrations. If the A record still resolves to the old server, you are testing the old server's certificate. A quick dig +short example.com tells you which IP the hostname check actually reached.
Once you know the served certificate is stale at the origin, confirm three things: which files Nginx uses, how Certbot renews this lineage, and whether a reload ran after the last renewal.
BASH
# Lineages Certbot manages, with expiry dates and file paths
sudo certbot certificates

# Certificate paths Nginx is configured with right now
sudo nginx -T 2>/dev/null | grep ssl_certificate

# How this lineage renews: authenticator, installer, and any hook lines
sudo cat /etc/letsencrypt/renewal/example.com.conf
ls -l /etc/letsencrypt/renewal-hooks/deploy/ /etc/letsencrypt/renewal-hooks/post/

# When the renewal timer runs, and what the last runs logged
systemctl list-timers | grep -i certbot
sudo journalctl -u certbot.service --since '7 days ago'

# Whether Nginx was reloaded after that
sudo journalctl -u nginx.service --since '7 days ago' | grep -i reload

# Worker start times change on every successful reload; the master's does not
ps -o pid,ppid,lstart,cmd -C nginx
Start with the paths. If nginx -T shows /etc/ssl/certs/example.com.pem or some other copied file, Certbot is renewing a certificate Nginx never reads. If it shows /etc/letsencrypt/live/example.com-0001/, Certbot created a second lineage at some point, and you need to check which one is actually renewing. Both are common on servers that have changed hands a few times. Next, the renewal config. Look for the authenticator and installer lines, and for renew_hook, post_hook, or pre_hook. A deploy hook passed on the command line with --deploy-hook is stored as renew_hook in that file. If there is no nginx installer, no hook line, and the renewal-hooks directories are empty, nothing reloads Nginx after renewal. Then the logs. Unit names depend on how Certbot was installed. Distro packages typically ship certbot.timer and certbot.service, while the snap usually uses snap.certbot.renew.timer and snap.certbot.renew.service. Some systems run renewal from cron instead, in which case the journal for those units will be empty and /var/log/letsencrypt/letsencrypt.log is the place to read. In either log, look for the renewal itself and for a line mentioning a hook being run. systemctl status nginx alone is not a reliable signal. Its "active since" time reflects the last start, and a reload does not reset it. The journal lines for the Nginx unit and the worker start times from ps are better evidence. If the workers started before the renewal timestamp, Nginx has not loaded the new certificate. Keep in mind that a reload triggered with nginx -s reload instead of systemctl will not appear in the Nginx unit's journal, but it will still show up in the worker start times.
Sometimes the journal shows a reload and the workers are fresh, yet the served serial is still old. A few causes are worth checking in order. The reload failed. If the config had an error at reload time, systemd may log the attempt, Nginx refuses the new config, and the old workers stay. Run sudo nginx -t and read the journal around the reload time for error lines. The wrong server block answers. If two server blocks share the same server_name, or the hostname you are testing doesn't match any block, Nginx falls back to the default server for that port, which may use a different certificate. The nginx -T output shows every block and its certificate path, so search it for your hostname. Something else holds port 443. On some setups a separate proxy, an old container, or another web server listens on the public IP and Nginx only serves behind it. sudo ss -ltnp | grep ':443' shows which process owns the port. The certificate renewed into a different lineage. certbot certificates may list both example.com and example.com-0001, with only one of them renewing and only the other one referenced by Nginx.
If your lineage uses a webroot, standalone, or DNS authenticator, add a deploy hook. Deploy hooks run once per successfully renewed certificate, which is exactly when a reload is needed. Scripts in /etc/letsencrypt/renewal-hooks/deploy/ apply to all lineages without editing each renewal file.
BASH
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null <<'EOF'
#!/bin/sh
# Reload only if the config and certificate files pass the syntax test
nginx -t && systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

# Run it once by hand, then repeat the openssl comparison from the first block
sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
The nginx -t guard keeps a broken config from being pushed through. If the test fails, the hook exits non-zero, Certbot logs the failure, and the running Nginx keeps serving. That is still a stale certificate, but it is a logged one instead of a silent one. Running the script once by hand fixes the current state immediately, and a reload is not disruptive, so it is safe to do during working hours. If the lineage already uses the nginx installer, a second reload from this hook is harmless. Be careful with certbot renew --dry-run as a test of the hook: depending on the Certbot version, a dry run may skip deploy hooks entirely, so a clean dry run doesn't prove the reload works. The manual run plus the openssl s_client check does. The habit worth keeping is simple. After any renewal you care about, compare the serial on disk with the serial on port 443, against the origin IP and the public hostname. If they match, you are done. If they don't, the gap is almost always a missing reload, a different file path, or something in front of Nginx, and the commands above tell you which one within a few minutes.