Cert issue
So the other day I noticed that spwnr.io was showing one of those lovely browser warnings. You know the one. “Your connection is not private.” The kind of message that makes you look like you’re running a phishing operation. Not a great look.
My first thought was that the Let’s Encrypt certificate had expired. Which would be embarrassing, but fair, these things happen when you’re a one-person operation and don’t check in on your servers every day. So I SSH’d in and ran certbot certificates, fully expecting to see an expired cert staring back at me.
But no. The cert was valid. 43 days left. Plenty of time.
More investigating
Next up, the usual suspects. Is nginx running? Yes. Is Phoenix responding? Yes, curl http://127.0.0.1:4000 returns a perfectly fine HTML page. Are the ports open? Yes, 80 and 443, both listening, both allowed through ufw. Everything looks fine. Everything is fine. Except the site doesn’t work.
I even checked iptables line by line, ran nc from the server to itself, verified DNS, checked if Hetzner had some external firewall I’d forgotten about. All good. At this point I’m starting to question reality a little.
Then I went back to the browser error. ERR_CERT_DATE_INVALID. Not “connection refused”, not “timeout”, not “502 bad gateway”. The browser is connecting. It just doesn’t like the cert. Which means nginx is serving a cert, just not the right one.
The actual problem
One openssl s_client command later:
$ openssl s_client -connect spwnr.io:443 -servername spwnr.io 2>/dev/null | openssl x509 -noout -dates
notBefore=Jan 21 22:03:24 2026 GMT
notAfter=Apr 21 22:03:23 2026 GMT
There it is. Nginx was serving the old cert. Certbot had renewed it, the new cert was sitting right there in /etc/letsencrypt/live/spwnr.io/, but nginx had never been told to pick it up. It was still happily serving the January cert that expired three weeks ago.
A quick certbot certonly --force-renewal -d spwnr.io && systemctl reload nginx and we’re back in business. New cert valid until August 2026.
Why it happened
The certbot renewal config for spwnr.io was missing a deploy hook. Certbot has a systemd timer that runs daily and renews certs when they’re close to expiring, which it did. But without a hook to reload nginx afterwards, the web server just keeps serving whatever cert it loaded into memory at startup. Which, given that nginx had been running since February without a restart, was very much the old one.
The cleaner fix is to drop a small script in certbot’s deploy hook directory, which runs automatically after any successful renewal:
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
systemctl reload nginx
Don’t forget to make it executable, otherwise certbot will silently ignore it:
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
This applies globally to all certs on the server. You could also set deploy_hook = systemctl reload nginx per domain in /etc/letsencrypt/renewal/spwnr.io.conf, but the shared hook directory is the more maintainable approach when you have multiple domains.
Takeaways
In hindsight, the misleading part was that “cert is valid” and “site shows cert error” seemed contradictory, until I realized I was looking at two different certs: the one on disk and the one in memory. A good reminder that the state of the filesystem and the state of a running process are not the same thing.
If you’re running Let’s Encrypt with nginx, make sure your renewal config has a hook to reload nginx. Future you will appreciate it.
Things mentioned above
- Let’s Encrypt - Free, automated SSL certificates
- Certbot - ACME client for Let’s Encrypt
- Nginx - Web server and reverse proxy
- Hetzner - German hosting provider
- spwnr.io - The PaaS platform in question