It's day 89. Your pager goes off because an external probe just failed TLS against port 993. You SSH in, run certbot certificates, and it cheerfully reports a certificate valid for another 71 days. The file on disk is fresh. But the wire tells the truth:
openssl s_client -connect mail.evilmail.pro:993 -servername mail.evilmail.pro 2>/dev/null \
| openssl x509 -noout -dates
notBefore=Apr 6 00:00:00 2026 GMT
notAfter=Jul 5 23:59:59 2026 GMTThat notAfter is yesterday. Certbot renewed the cert three weeks ago; Dovecot has been serving the expired one the entire time, and Outlook has been throwing cert-expired errors to every user on the domain.
The root cause is two sentences long. Postfix and Dovecot read the certificate into memory once, at startup, and cache it. Certbot rewriting the file on disk changes nothing in a running process until something tells that process to re-read it — and nothing did. Automating the renewal is the easy 20% of this problem. The other 80% is automating the reload, and doing it without knifing every live connection on the box.
The moving parts: what actually reads the cert
Two daemons hold the certificate, and neither notices when the underlying file changes.
- Postfix reads
smtpd_tls_cert_file/smtpd_tls_key_file(or the modernsmtpd_tls_chain_files) frommain.cf. Themasterprocess loads TLS config and hands it to thesmtpdchildren that serve ports 25, 587 and 465. It re-reads onpostfix reload. - Dovecot reads
ssl_cert/ssl_keyfromconf.d/10-ssl.conf. The login processes for 993, 995, 143 and 110 use it. It re-reads ondoveadm reload.
The distinction this whole article turns on is simple: reload keeps the listening sockets open and gracefully cycles workers; restart tears everything down. On a reload, the Postfix master re-reads config and spawns new smtpd processes with the new cert while the sessions already in flight run to completion. Dovecot reloads its config without killing the imap/pop3 processes serving existing connections — so that phone sitting in an IMAP IDLE loop never notices. On a restart, the listening sockets close, in-flight sessions get an RST, and every client reconnects at once. For IMAP that means a full-mailbox re-sync across your whole user base; for SMTP it means DATA phases dropped mid-transaction and deferred back into the queue.
Getting the certificate: one lineage, all hostnames
Issue a single certificate whose SAN covers every hostname both daemons present — mail, imap, smtp, autodiscover, whatever your MX and client configs point at. One lineage means one deploy hook and one thing to verify.
certbot certonly --webroot -w /var/www/html \
-d mail.evilmail.pro \
-d imap.evilmail.pro \
-d smtp.evilmail.pro--webroot is the right default when the mail box also answers HTTP-01 on port 80. If port 80 is closed on that host, use --standalone (certbot binds 80 itself briefly), and if you need a wildcard or can't expose 80 at all, go DNS-01 with a plugin (certbot-dns-cloudflare, -route53, etc.) rather than --manual, which can't be automated.
The files land here:
/etc/letsencrypt/live/mail.evilmail.pro/fullchain.pem— leaf + intermediate chain/etc/letsencrypt/live/mail.evilmail.pro/privkey.pem/etc/letsencrypt/archive/mail.evilmail.pro/— the real rotating files (cert1.pem,cert2.pem, …)
Point your daemons at live/, never at archive/. The live/ entries are symlinks that certbot re-targets to the newest archive file on each renewal. Hardcode an archive path and the day it rotates you're serving a dead file.
Wiring Postfix and Dovecot to the live symlinks
On Postfix 3.4+, use the single-directive form in main.cf — key first, then chain:
smtpd_tls_chain_files = /etc/letsencrypt/live/mail.evilmail.pro/privkey.pem
/etc/letsencrypt/live/mail.evilmail.pro/fullchain.pem
smtpd_tls_security_level = may
smtp_tls_security_level = may
smtpd_tls_protocols = >=TLSv1.2
smtpd_tls_mandatory_protocols = >=TLSv1.2(The >= protocol syntax needs Postfix 3.6+; on older builds spell it out with !SSLv2, !SSLv3, !TLSv1, !TLSv1.1. The legacy split form — smtpd_tls_cert_file = .../fullchain.pem plus smtpd_tls_key_file = .../privkey.pem — still works if you're pre-3.4.)
In Dovecot's conf.d/10-ssl.conf, the leading < is not a typo — it means "read the value from this file":
ssl = required
ssl_cert = </etc/letsencrypt/live/mail.evilmail.pro/fullchain.pem
ssl_key = </etc/letsencrypt/live/mail.evilmail.pro/privkey.pem
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yesConfirm the daemons actually parsed what you think they did:
postconf -n | grep tls
doveconf -n | grep sslThe permissions trap
/etc/letsencrypt/live and /etc/letsencrypt/archive are drwx------ root:root (0700) by default. People panic about this, but for the standard setup it's a non-issue: both the Postfix master and the Dovecot master start as root, read the private key into memory, and only then drop privileges to their unprivileged users. The read happens before the privilege drop, so 0700 root is fine.
ls -la /etc/letsencrypt/live/mail.evilmail.pro/
drwxr-xr-x 2 root root ... .
-rw-r--r-- 1 root root ... fullchain.pem
-rw------- 1 root root ... privkey.pem -> ../../archive/mail.evilmail.pro/privkey3.pemThe trap only springs if you copy the key somewhere else or hand it to a process that never runs as root. If you genuinely need that — a HAProxy or a containerized service reading the PEM directly — do the copy in the deploy hook into /etc/ssl/mail/ with a tight group and chmod 640, and re-copy on every renewal. For plain Postfix + Dovecot, leave the files where certbot put them and reload as root. Simpler is safer here.
Graceful reload: the deploy hook done right
This is the section that matters. Certbot gives you two hook types and picking the wrong one is a classic footgun:
--post-hookruns after everycertbot renewinvocation, even when nothing renewed. Twice a day, forever.--deploy-hookruns only when a certificate was actually renewed, and it exports$RENEWED_LINEAGEand$RENEWED_DOMAINSso the script knows which cert changed.
Use --deploy-hook. Drop the script in /etc/letsencrypt/renewal-hooks/deploy/ so it survives certbot upgrades and runs for any renewed lineage without editing per-cert renewal conf files.
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-mail.sh
set -euo pipefail
MAIL_LINEAGE="/etc/letsencrypt/live/mail.evilmail.pro"
# Only act when it's the mail cert that renewed.
if [[ "${RENEWED_LINEAGE:-}" != "$MAIL_LINEAGE" ]]; then
exit 0
fi
log() { logger -t reload-mail "$*"; echo "reload-mail: $*"; }
log "mail cert renewed for ${RENEWED_DOMAINS:-?}, reloading daemons"
if postfix reload; then
log "postfix reloaded OK"
else
log "ERROR: postfix reload failed"; exit 1
fi
if doveadm reload; then
log "dovecot reloaded OK"
else
log "ERROR: dovecot reload failed"; exit 1
fi
log "done"Make it executable (chmod 0755). Two things earn their keep here. The set -euo pipefail plus explicit nonzero exits mean a failed reload is loud — certbot surfaces a failing deploy hook, so it can't rot silently for three weeks. And logger -t puts a breadcrumb in syslog every renewal, which is exactly what you want to grep for when someone asks "did the cert actually roll last month?"
postfix reload and doveadm reload are the graceful calls. Never systemctl restart in a deploy hook. systemctl reload postfix / systemctl reload dovecot are fine equivalents if you prefer the systemd surface.
Automating the renewal itself
Certbot 2.x ships a systemd timer. Use it instead of hand-rolling cron — it runs twice daily with a randomized delay, which spreads load across Let's Encrypt's infrastructure and means you're not one of ten thousand boxes hammering the ACME endpoint at exactly midnight.
systemctl enable --now certbot.timer
systemctl list-timers certbot.timerTest the renewal path before you trust it:
certbot renew --dry-runHere's the sharp edge that bites people: --dry-run skips deploy hooks by design (it hits the staging CA and never touches your real cert, so there's nothing to deploy). A clean dry-run proves renewal works and proves nothing about your reload. On certbot 2.6.0 and newer, test the hook explicitly:
certbot renew --dry-run --run-deploy-hooksThat runs your deploy hook against the current live certificate only if the dry-run succeeds — no forced renewal, no rate-limit exposure. On older certbot, run the hook script by hand with RENEWED_LINEAGE exported, then watch syslog for the reload-mail breadcrumbs.
Standard LE certs are 90 days and auto-renew at 30 days remaining. In 2025 Let's Encrypt launched short-lived 6-day certificate profiles. If you opt into those, manual renewal stops being an option entirely — the timer and a working deploy hook become load-bearing infrastructure, not a nicety. (Related: LE has wound down OCSP in favor of CRLs, so OCSP-stapling config is now optional and on its way out. Don't build anything new on it.)
Verify like a skeptic
certbot certificates reads the file. The file being fresh is the exact failure mode we're guarding against, so that command is worthless for confirming the fix. Trust the wire instead. Grab the fingerprint the daemon is actually serving and compare it to the file:
# What Dovecot serves on 993
openssl s_client -connect mail.evilmail.pro:993 -servername mail.evilmail.pro 2>/dev/null \
| openssl x509 -noout -dates -fingerprint -sha256
# What Postfix serves on submission
openssl s_client -connect mail.evilmail.pro:587 -starttls smtp 2>/dev/null \
| openssl x509 -noout -dates
# The file itself
openssl x509 -in /etc/letsencrypt/live/mail.evilmail.pro/fullchain.pem \
-noout -fingerprint -sha256If the SHA-256 fingerprint from port 993 matches the one from the file, Dovecot re-read the cert. If they differ, your reload didn't fire. Do this independently for both daemons — a working Postfix reload tells you nothing about Dovecot. Check ports 25, 465, 587, 993 and 995.
Monitoring so day 89 never happens
The whole disaster is preventable with one probe that checks the live port, not the file. Use Blackbox exporter's tcp prober with TLS, or a two-line cron, alerting when the on-wire cert has under 21 days left:
end=$(openssl s_client -connect mail.evilmail.pro:993 -servername mail.evilmail.pro 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)
days=$(( ($(date -d "$end" +%s) - $(date +%s)) / 86400 ))
[ "$days" -lt 21 ] && echo "WARN: mail cert expires in $days days" | mail -s "cert" [email protected]Probe the port precisely because a fresh file with a stale daemon is invisible to any file-based check.
Pre-flight checklist
- [ ] One certificate, SAN covering every mail hostname (
mail/imap/smtp/autodiscover) - [ ] Postfix and Dovecot point at
/etc/letsencrypt/live/…, neverarchive/ - [ ] Deploy hook uses
postfix reload/doveadm reload, neverrestart - [ ] Hook lives in
/etc/letsencrypt/renewal-hooks/deploy/, is executable, exits nonzero on failure, and logs to syslog - [ ] Hook guards on
$RENEWED_LINEAGEso unrelated certs don't trigger it - [ ]
certbot.timer
Get this right once and renewals become boring — no pages, no re-sync storms, no expired-cert tickets. Which is exactly the point.


