A certificate expiry used to be an annual chore. Someone bought a two-year or one-year cert, pasted the files into /etc/ssl, reloaded Apache, and put a reminder in a calendar. On a lot of the servers we are handed, that is still the process — and it is now on a collision course with the rules.
The CA/Browser Forum has locked in a schedule that shortens publicly trusted TLS certificates in three steps:
- 15 March 2026 — maximum lifetime drops from 398 days to 200 days.
- 15 March 2027 — 100 days.
- 15 March 2029 — 47 days.
Domain validation reuse shrinks alongside it, so the "we already proved we own this domain" shortcut also expires faster each step. The direction is settled and public; browsers enforce it on the client side, so no amount of vendor relationship gets you an exception.
The practical consequence is simple arithmetic. A manual renewal you do once a year becomes twice a year now, almost four times a year in 2027, and eight times a year in 2029. Manual processes that run eight times a year get missed. An expired certificate is a full outage with a scary browser interstitial in front of it, and it is one of the few outages your customers will photograph and send you.
This is a cheap fix. A day of work on a typical server, and then it stops being a thing you think about.
Step 1: inventory what you actually have
Before automating anything, find every certificate the server terminates. Apache will tell you:
apachectl -S
grep -rE 'SSLCertificateFile|SSLCertificateKeyFile|ServerName|ServerAlias' /etc/apache2/sites-enabled/
Then check expiry dates on the files it names:
for f in $(grep -rhE '^\s*SSLCertificateFile' /etc/apache2/sites-enabled/ | awk '{print $2}' | sort -u); do
printf '%s ' "$f"
openssl x509 -noout -enddate -subject -in "$f"
done
And confirm what is served on the wire, which is not always what the config suggests:
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
Write the list down: hostname, cert file, issuer, expiry, and how it is renewed today. Most estates turn up at least one surprise — a wildcard pasted onto a staging box, an internal hostname on a cert nobody tracks, or a SAN list that still includes a domain retired three years ago.
Two things on that list need separate handling:
- Internal or private-CA certificates. The 200/100/47-day schedule applies to publicly trusted certs. Your internal CA can keep issuing ten-year certs for a MySQL replication link if you want. Do not let that distinction confuse the inventory — label each row public or private.
- Certificates pinned into applications. Payment gateways, EDI partners, and old SOAP clients sometimes pin a specific certificate or issuer. Those break when a cert rotates, and they break quietly. Find them now, not on renewal night.
Step 2: pick an ACME client and a challenge type
For publicly trusted certs, use ACME. Let's Encrypt and ZeroSSL both issue free, automation-first certificates; commercial CAs increasingly expose ACME endpoints too, so you can usually keep your existing vendor if a contract requires it.
Two clients cover nearly every LAMP server:
- Certbot — packaged everywhere, with an Apache plugin that can edit vhosts for you. Convenient on a clean server, less so on one with hand-tuned config.
- acme.sh — a shell script with no Python dependency chain, which matters on older distros where Certbot's packages have quietly rotted. It installs under one user, supports dozens of DNS providers, and leaves your Apache config alone.
On a legacy box we usually reach for acme.sh precisely because it touches nothing it does not have to.
For the challenge:
-
HTTP-01 is the default and the simplest: the CA fetches a token from
http://example.com/.well-known/acme-challenge/. It needs port 80 reachable from the internet for every name on the certificate. If the app has a catch-all rewrite or a forced HTTPS redirect, carve out an exception rather than disabling the redirect:Alias /.well-known/acme-challenge/ /var/www/acme/.well-known/acme-challenge/ <Directory /var/www/acme/.well-known/acme-challenge/> Require all granted Options None </Directory>Put that in a shared snippet and include it from every vhost, so a new site cannot forget it.
-
DNS-01 is the one to use for wildcards, for hosts not reachable on port 80, and for anything behind a WAF or IP allowlist. It needs API credentials for your DNS provider. If your DNS is somewhere you would rather not hand out an API key, delegate
_acme-challenge.example.comwith a CNAME to a zone that exists only for this purpose and scope the key to that zone.
Step 3: issue, install, and reload — with a hook that is honest
The renewal has to do more than fetch files. It has to put them where Apache looks and reload the service, and it must not reload a broken config. acme.sh does this with an install step that owns the deployment:
acme.sh --issue -d example.com -d www.example.com -w /var/www/acme
acme.sh --install-cert -d example.com \
--key-file /etc/ssl/private/example.com.key \
--fullchain-file /etc/ssl/certs/example.com.fullchain.pem \
--reloadcmd "apachectl configtest && systemctl reload apache2"
The configtest && matters. A reload against a config that fails leaves Apache running on the old certificate and the renewal reported as successful — the worst of both worlds, because you find out 200 days later.
Point Apache at the installed paths, not at the ACME client's internal directories, which move between versions:
SSLCertificateFile /etc/ssl/certs/example.com.fullchain.pem
SSLCertificateKeyFile /etc/ssl/private/example.com.key
On Apache 2.4.8 and later, SSLCertificateChainFile is obsolete — the full chain goes in SSLCertificateFile. Old vhosts often still carry the directive; remove it rather than leaving a stale intermediate on disk.
Then remember everything else on the box that reads a certificate. On a typical LAMP server that is Postfix or Dovecot, HAProxy or stunnel if present, MySQL if replication runs over TLS, and anything in a container that was handed a copy of the PEM at build time. Each one needs its own line in the reload hook. A cert that renews for Apache and silently leaves mail serving an expired chain is a renewal that failed.
Step 4: prove the renewal works before you need it
Do not wait 190 days to learn whether the timer fires. Force one:
acme.sh --renew -d example.com --force
# certbot:
certbot renew --dry-run
Use the CA's staging endpoint while you are iterating; Let's Encrypt rate-limits failed orders against production, and it is easy to burn through the allowance debugging a rewrite rule.
Check the scheduler actually exists and runs as the right user:
systemctl list-timers | grep -iE 'certbot|acme'
crontab -l -u root | grep acme
This is where inherited servers fail most often. The cron entry was added under a user account that has since been removed, or it calls a binary at a path that changed in a distro upgrade, and the error output went to a mail spool nobody reads. Same failure mode as the backup jobs we wrote about in the restore-drill post, same fix: alert on absence of success, not on presence of failure.
Step 5: monitor expiry independently of the renewal
The renewal job should not be the only thing that knows whether the certificate is current. Check from outside, against the live port, and alert well before expiry — with 47-day certificates arriving in 2029, a two-week warning window is the useful one.
A cheap standalone check:
#!/bin/sh
# usage: certdays example.com [port]
host=$1; port=${2:-443}
end=$(echo | openssl s_client -servername "$host" -connect "$host:$port" 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)
days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
echo "$host expires in $days days ($end)"
[ "$days" -lt 14 ] && exit 2
exit 0
Wire that into whatever already pages you. If nothing pages you, a weekly cron that emails the line for every hostname in your inventory is better than nothing and takes ten minutes.
One more reason to monitor independently: certificate authorities have been reducing and, in Let's Encrypt's case, discontinuing expiry notification emails, on the reasoning that anyone relying on them is not automated yet. That is fair, but it means the safety net you may have been quietly depending on is gone.
What this does not require
It does not require moving off Apache, containerising the app, or putting a CDN in front of the site. A PHP 7.4 application on Ubuntu 20.04 can renew certificates perfectly well; ACME lives entirely outside the application. If someone tells you shorter certificate lifetimes are a reason to re-platform, they are selling something.
It also does not require doing all of your TLS work at once. Automated renewal is the piece with a deadline attached. Cipher suites, HSTS, and security headers are worth doing, and we have a separate checklist for them, but an expired certificate takes the site down and a slightly dated cipher list does not.
The short version
Inventory every certificate and how it renews. Move the public ones to an ACME client with a reload hook that runs configtest first and updates every service that reads the file. Force a renewal to prove it. Monitor expiry from outside with a 14-day alert. One day of work, and the 2027 and 2029 steps down become changes you read about rather than changes you schedule.
If you want a second pair of eyes on an older Apache estate — what renews, what does not, and what breaks when it rotates — that is the kind of thing our health and security review covers.