nginx reads a certificate when it reads its configuration, serves it until the last second of its validity, and then presents one that every client refuses. Between those two moments it says nothing at all. An ACME client that stopped renewing six weeks ago leaves no trace in the error log, and the first report comes from someone who cannot reach the site.
The usual answer is a check from outside: something connects on a schedule, reads the notAfter and raises an alarm. That works, and it has the real advantage of testing the whole path, DNS and routing included. It also has to know every name you serve, it has to be able to reach them, and it never sees a certificate that is configured but not selected.
This module works from the inside. It reads the files nginx was told to use and writes what it finds into the error log you are already collecting.
The smallest configuration that works
There is none. Build the module in and it reports, because both of its directives have a working default:
http {
cert_expiry_warn 14d; # below this, warn level
cert_expiry_check 12h; # how often to look again
server {
listen 443 ssl;
ssl_certificate /usr/local/etc/ssl/shop.crt;
ssl_certificate_key /usr/local/etc/ssl/shop.key;
}
}
Both lines are the defaults written out. The interval matters more than it looks: without it a server that has been up for four months would still be reporting the state of the morning it started, which is the one number that is certain to be wrong.
Three levels, and what each one is for
At worker start and on every check the module writes one line per certificate:
[warn] certificate "/usr/local/etc/ssl/soon.crt" expires in 9 days
[error] certificate "/usr/local/etc/ssl/gone.crt" expired 5 days ago
[info] certificate "/usr/local/etc/ssl/later.crt" is good for 400 more days
The levels are not decoration. An expired certificate is reported at error level whatever cert_expiry_warn says, because there is no threshold at which an outage is a matter of taste. Anything with time left but less than the threshold is a warning. Everything else is info, which means that an error log running at that level holds a complete inventory of the certificates this nginx actually uses, one line each, refreshed on every check. That is a cheaper answer to „what certificates does this machine serve“ than walking the filesystem for .crt files that may or may not be referenced.
The variable, and where it gets its number
$ssl_expiry_days holds the days left on the certificate the current connection was served with. Not the first one configured: the one OpenSSL picked for this handshake.
location = /cert-days {
allow 127.0.0.1;
deny all;
return 200 "$ssl_expiry_days\n";
}
It is rounded towards the past, which is worth knowing before you write a threshold against it. A certificate that ran out an hour ago reports -1 and never 0, because zero is the one number nobody may mistake for „still fine“. Measured against three certificates with deliberately uneven lifetimes:
| certificate | log line | $ssl_expiry_days |
|---|---|---|
| 9 days and 12 hours left | expires in 9 days | 9 |
| expired 5 days and 12 hours ago | expired 5 days ago | -6 |
| expired one hour ago | expired 1 days ago | -1 |
While there is time left the two agree. Once it has run out the log counts whole days gone by and the variable keeps rounding away from zero, so they sit one apart. Both describe the same certificate, and neither will ever tell you a dead one is alive.
When the log and the variable disagree
There is one case where the two numbers really do part company, and it is a finding rather than a fault.
nginx loads certificates when it reads its configuration and keeps them in memory from then on. Replacing the file on disk does not change what a running worker hands to clients. The module, on the other hand, reads the file again on every check. So the log follows the disk and the variable follows the wire.
Measured on a running nginx, starting with six days left:
file on disk ends: Nov 13 11:00:46 2026 GMT
served cert ends: Nov 13 11:00:46 2026 GMT
$ssl_expiry_days: 5
log: certificate "site.crt" expires in 6 days
Then the certificate is renewed on disk, with no reload:
file on disk ends: Feb 5 11:00:48 2027 GMT
served cert ends: Nov 13 11:00:46 2026 GMT
$ssl_expiry_days: 5
log: certificate "site.crt" is good for 89 more days
The file is new, what clients get is still the old one, and the variable is telling the truth about what they get. After nginx -s reload the three agree again:
served cert ends: Feb 5 11:00:48 2027 GMT
$ssl_expiry_days: 89
log: certificate "site.crt" is good for 89 more days

The gap between the two numbers is exactly the window between a renewal and the reload that makes it count. That window is where the quiet failures live: the renewal hook that writes the file but never signals nginx, the one that signals the wrong process, the one that was removed from the timer last spring. Keep both numbers and a forgotten reload shows up the day it happens rather than ninety days later.
Two certificates for one name
A server carrying an RSA and an ECDSA certificate at once is a common arrangement, and it is where „the certificate“ stops being a single thing:
server {
listen 443 ssl;
server_name shop.example;
ssl_certificate /usr/local/etc/ssl/shop-rsa.crt;
ssl_certificate_key /usr/local/etc/ssl/shop-rsa.key;
ssl_certificate /usr/local/etc/ssl/shop-ec.crt;
ssl_certificate_key /usr/local/etc/ssl/shop-ec.key;
}
The log names both, each on its own line, because both files exist and both can expire. The variable names one. With an RSA certificate of 40 days and an ECDSA certificate of 200 sitting in that same server, the same request to the same location answered 39 or 199 depending only on what the client offered in its handshake.
Which is correct, and which is the reason to keep the log as well. A threshold built on the variable alone watches whichever certificate your monitoring client happens to negotiate, and that is usually the modern one, which is usually not the one you forgot to renew.
When the worker cannot read the file
A private key is commonly readable by root alone, and a certificate often sits next to it with the same ownership. nginx starts as root, reads its configuration, and then drops its workers to an unprivileged user. A module that read certificates only in the worker would therefore find nothing on a correctly set up machine.
So the dates are read twice. First in the master, where the configuration is read and root still applies, which is the reading that is certain to succeed. After that every check reads the files again in the worker and takes the new date where it is allowed to. Where it is not, the master’s date stands and the worker says so at info level:
[info] cert_expiry: cannot open "/usr/local/etc/ssl/locked.crt"
[warn] certificate "/usr/local/etc/ssl/locked.crt" expires in 3 days
The countdown keeps running either way, since it is arithmetic on a date the master already read. The difference is when a renewal is noticed: on the next check if the worker can read the file, and at the next reload if it cannot. Neither case goes quiet.
What it does not watch
Certificates of a stream server are not read. Neither is an ssl_certificate whose path contains variables, because the file is not known until a handshake has named a server, nor one given as data: or engine:, which names no file at all. Each of those is noted at info level when the configuration is read, so the omission is in the log rather than in your assumptions. $ssl_expiry_days still answers on those connections, since it reads the certificate off the connection and not off the disk.
A file named by twenty servers is read once. One worker writes the report, not all of them, which is the difference between one line per certificate and one line per certificate per worker on a machine with twenty four of them.
Putting the number where you will see it
The log is half the job. The other half is the path from log to alarm, and it needs no further software.
Carry the number in the access log and every request records what it was served with:
log_format expiry '$status days=$ssl_expiry_days proto=$ssl_protocol';
200 days=89 proto=TLSv1.3
200 days=- proto=-
The second line is a plain HTTP request, where the variable is not found and the log format shows it as a dash. Worth remembering if something downstream parses that field as a number.
Then two rules on the error log, which are the ones that wake somebody: a line matching [error] certificate means the site is already down for anyone who checks the chain, and [warn] certificate means this week. The info lines are for the inventory, not for the pager.
On FreeBSD
The sources ship as www/nginx-module-cert-expiry, and the option CERT_EXPIRY in www/nginx, www/nginx-devel and www/freenginx builds them in. It implies HTTP_SSL, so turning it on pulls in the TLS support the module needs instead of failing at configure time.
make -C /usr/ports/www/nginx config # tick CERT_EXPIRY
make -C /usr/ports/www/nginx install clean
It is built as a loadable module, so one line in the main context switches it on:
load_module modules/ngx_http_cert_expiry_module.so;
Everything above was measured on nginx 1.30.5 on FreeBSD 15.1, with certificates signed through a local CA so that the end dates were known to the second.
Where it is
The sources, the full directive reference and the test suite: github.com/sysadmin-labs/nginx-cert-expiry-module
The FreeBSD port, with its package state, its CVE status and the branches it is in: www/nginx-module-cert-expiry on portstatus
BSD 2-Clause.