Zero-Downtime Let's Encrypt for Postfix and Dovecot: Automated Renewals That Actually Reload
Certbot renews your mail cert in 30 seconds. The two silent traps come after: daemons keep serving the old cert until you reload them, and the naive restart kills every in-flight SMTP transaction and IMAP IDLE session. Here's how to wire ACME to Postfix and Dovecot so renewals are hands-off and connection-graceful.
EvilMail TeamJuly 26, 202611 min read
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:
That 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 modern smtpd_tls_chain_files) from main.cf. The master process loads TLS config and hands it to the smtpd children that serve ports 25, 587 and 465. It re-reads on postfix reload.
Dovecot reads ssl_cert / ssl_key from conf.d/10-ssl.conf. The login processes for 993, 995, 143 and 110 use it. It re-reads on doveadm 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.
--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.
/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:
ini
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":
Confirm the daemons actually parsed what you think they did:
bash
postconf -n | grep tls
doveconf -n | grep ssl
The 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.
The 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-hook runs after everycertbot renew invocation, even when nothing renewed. Twice a day, forever.
--deploy-hook runs only when a certificate was actually renewed, and it exports $RENEWED_LINEAGE and $RENEWED_DOMAINS so 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.
bash
#!/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.
Here'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:
bash
certbot renew --dry-run --run-deploy-hooks
That 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:
If 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: