What Let's Encrypt and certbot do for Nginx, and who should use them
Three pieces cooperate here, and it helps to keep their responsibilities separate.
Let's Encrypt is a free certificate authority that issues domain-validated TLS certificates over ACME, the protocol standardized in RFC 8555. Its certificates are short-lived. They were valid for 90 days for most of the project's history, and Let's Encrypt has announced shorter lifetimes. It does not issue organization-validated or extended-validation certificates.
Certbot is an ACME client. It proves to Let's Encrypt that you control a domain, generates the private key and signing request for you, writes the resulting files to disk, and later renews them.
Nginx does not talk to the CA at all. It reads a certificate chain and a private key from paths named in its configuration, and it only picks up changed files when it reloads.
Teams adopt this setup because it costs nothing per certificate and removes manual CSR handling. Nobody has to generate keys by hand, paste requests into a portal, or remember an annual expiry date.
The main point of this article is that issuance takes minutes and renewal is the real work. Short lifetimes mean renewal runs constantly. A silent failure in the renewal timer, the challenge path, or the reload step turns into an outage weeks later.
This setup fits backend and platform engineers running public-facing Nginx on a single server or a small fleet, where port 80 is reachable from the internet. Later sections cover the cases that need a different design:
- wildcard certificates
- internal-only hosts
- many nodes behind a load balancer
- environments where inbound HTTP is blocked
Prerequisites that cause most first-run failures
Most failed first runs trace back to three conditions that certbot cannot fix for you. Checking them takes a couple of minutes.
DNS points at this host
Let's Encrypt resolves each requested name itself, so every name must resolve publicly to the machine running Nginx. Run dig +short A example.com and dig +short AAAA example.com for each name, and compare the results with the host's public addresses.
The AAAA record is a common trap. When an AAAA record exists, Let's Encrypt can try validation over IPv6. If that address is stale, or Nginx has no listen [::]:80;, the challenge can fail even though IPv4 works. Either serve IPv6 properly or remove the AAAA record.
Port 80 is reachable from the internet
HTTP-01 validation always starts on port 80, even if you only plan to serve HTTPS. Firewalls, cloud security groups, and ISP blocks often close it. Test from a machine outside your network, not from the server itself. Request a dummy file under the challenge path, for example `curl -i A 404 from your Nginx is a good result, because it shows the request reached the right server. A timeout or a response from a different server means validation will fail.
server_name covers every requested domain
The nginx plugin uses server_name to decide which server block to modify. Any requested name missing from that directive can end up handled by the default server, which leads to failed challenges or a misconfigured block. List every name, including both www and the apex domain if you request both.
Experiment against staging
Let's Encrypt enforces rate limits on production issuance, including limits on failed validations. While you debug, add --staging to your certbot command. Staging certificates are not browser-trusted, but staging lets you repeat attempts without using up your production allowance. Remove the flag only after a staging run succeeds.
#!/usr/bin/env bash
# Pre-flight checks before the first certbot run.
# Run the DNS and curl checks from a machine OUTSIDE the server's network.
DOMAINS="example.com www.example.com"
for d in $DOMAINS; do
echo "== $d"
# Compare these with the server's real public IPv4/IPv6 addresses.
echo "A: $(dig +short A "$d" | tr '\n' ' ')"
echo "AAAA: $(dig +short AAAA "$d" | tr '\n' ' ')"
# Probe the challenge path over each address family separately.
# 404 from your Nginx = reachable. 000 = timeout or blocked.
url=""
echo "IPv4 HTTP: $(curl -4 -s -o /dev/null -w '%{http_code}' --max-time 10 "$url")"
if [ -n "$(dig +short AAAA "$d")" ]; then
# An AAAA record that fails here is the classic IPv6 trap.
echo "IPv6 HTTP: $(curl -6 -s -o /dev/null -w '%{http_code}' --max-time 10 "$url")"
fi
done
# --- On the server itself ---
# Confirm every requested name appears in a server_name directive
# and that Nginx listens on IPv6 if you publish AAAA records.
sudo nginx -T 2>/dev/null | grep -E 'server_name|listen'
# First attempt against staging so failures don't count toward
# production rate limits. Drop --staging once this succeeds.
sudo certbot --nginx --staging -d example.com -d www.example.com
A pre-flight script that checks A/AAAA records, probes the HTTP-01 challenge path over IPv4 and IPv6 separately, confirms server_name and listen directives, and makes the first certbot attempt against staging.
Choosing a certbot mode: nginx plugin vs certonly with webroot
Certbot can obtain a certificate in two broadly different ways, and the choice decides who owns your Nginx configuration.
With certbot --nginx, the nginx plugin handles the challenge and then edits your server blocks. It adds ssl_certificate directives, an include for its TLS settings and, optionally, an HTTP-to-HTTPS redirect. A single command takes you to working HTTPS. The cost is that certbot becomes a second author of files you thought you controlled. If those files come from a template or a configuration management run, the next deploy restores the original version and silently removes certbot's edits. Nginx keeps serving the old configuration until a reload, and then HTTPS disappears.
With certbot certonly --webroot, certbot writes challenge files into a directory you name and never touches Nginx config. You write the TLS server blocks yourself and point them at the paths under /etc/letsencrypt/live/. The obligation moves to you: every vhost on the certificate must serve /.well-known/acme-challenge/ over plain HTTP from that directory. If someone later adds a catch-all redirect, an auth rule or a return 444 ahead of that location, nothing fails today. Renewal fails weeks or months later.
| nginx plugin | certonly --webroot | |
|---|---|---|
| Config ownership | Shared with certbot | Fully yours |
| Setup speed | One command | Manual server blocks |
| Config management compatibility | Poor, because deploys overwrite edits | Good, because config stays declarative |
| Typical failure mode | Deploy reverts TLS directives | Challenge path blocked by a later rule |
Platform teams usually choose webroot, or certonly with another authenticator, for two reasons. The configuration stays reviewable in version control, and the one fragile dependency, the challenge location, can be placed in a shared snippet and tested.
Implementing it: issuance, explicit server blocks and validation
Issuance needs a running port 80 server, so start with that. Create a dedicated challenge directory such as /var/www/letsencrypt and add a server block on port 80 for your names. Inside it, a location ^~ /.well-known/acme-challenge/ with root /var/www/letsencrypt serves challenge files. The ^~ modifier stops regex locations elsewhere in the config from capturing the path. Every other request gets `return 301 Reload Nginx, then request the certificate:
certbot certonly --webroot -w /var/www/letsencrypt -d example.com -d www.example.com
Certbot writes the token, Let's Encrypt fetches it over plain HTTP, and the certificate lands under /etc/letsencrypt/live/example.com/.
Next, add the 443 server with listen 443 ssl, the same server_name, and two directives:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pemssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem
Use the live/ paths because they are symlinks that certbot repoints on each renewal, so the config never changes. Use fullchain.pem rather than cert.pem, because clients need the intermediate certificate to build a trust path.
To validate, run nginx -t first. Then reload instead of restarting: a reload checks the config again and keeps the old workers serving if it fails, while a restart with a broken config takes the site down. Finally, run openssl s_client -connect example.com:443 -servername example.com and check the issuer, the chain, and the validity dates. The -servername flag matters because without SNI you may see the default server's certificate.
Hold off on Strict-Transport-Security until HTTPS works reliably on every subdomain it will cover. Browsers cache the policy for its full max-age, so a premature includeSubDomains can lock users out of hosts that only serve HTTP. The directive reference is in the nginx documentation.
# Port 80: serve ACME challenges, redirect everything else to HTTPS
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# ^~ stops regex locations elsewhere from capturing challenge requests
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
default_type text/plain;
}
location / {
return 301
}
}
# Port 443: terminate TLS using certbot's live/ symlinks
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com www.example.com;
# live/ paths are symlinks certbot repoints on renewal; config never changes
# fullchain.pem includes the intermediate clients need to build trust
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Add Strict-Transport-Security only after HTTPS is reliable on every
# subdomain it will cover; browsers cache it for the full max-age.
root /var/www/example.com;
index index.html;
}
# Validate and apply, then inspect the served chain and dates:
# nginx -t && systemctl reload nginx
# openssl s_client -connect example.com:443 -servername example.com </dev/null \
# | openssl x509 -noout -issuer -dates
This minimal pair of explicit server blocks serves ACME webroot challenges on port 80 and redirects all other traffic, terminates TLS on port 443 with certbot's live/ fullchain and key, and ends with the test, reload and openssl checks.
How auto renewal works and how Nginx picks up new certificates
Nothing in the renewal path is "automatic" until you have confirmed each step on your own host. certbot renew walks every certificate under /etc/letsencrypt/renewal/ and contacts the CA only for the ones inside their renewal window. Running it often is cheap, which is why it is normally scheduled twice a day.
Most packages already install that schedule. Depending on how certbot was installed, it is a systemd timer or a file in /etc/cron.d/. Check systemctl list-timers and the cron directories before you add your own entry. Two schedulers are redundant, and when they overlap they can contend for certbot's lock.
systemd timer / cron
│
▼
certbot renew ──(not due)──► exit, no change
│ due
▼
ACME HTTP-01 challenge ◄── served by Nginx on :80
│ success
▼
new files linked in /etc/letsencrypt/live/<name>/
│
▼
deploy hook ──► systemctl reload nginx
│
▼
workers load new certificate
Nginx reads certificate files once, when its configuration loads, and keeps them in memory. A successful renewal therefore changes nothing that clients see until Nginx reloads.
If you used the nginx installer, certbot handles that reload. With certonly --webroot, you must add --deploy-hook "systemctl reload nginx" or place an executable script in /etc/letsencrypt/renewal-hooks/deploy/. Deploy hooks run only after a successful renewal, so they never cause a reload when nothing changed.
The classic failure looks like this: live/ holds a fresh certificate, but Nginx keeps serving the old one until it expires. To check, compare the expiry date on disk with the one the server actually presents.
certbot renew --dry-run tests the full challenge path against the staging environment without using production rate limits. It skips deploy hooks, though, so test your reload step separately.
#!/usr/bin/env bash
# Inspect, wire up, and verify certbot renewal for Nginx.
# example.com is a placeholder; use your certificate name and hostname.
set -euo pipefail
# 1. Find the existing scheduler before adding another one.
systemctl list-timers --all | grep -i certbot || echo 'no certbot systemd timer'
ls -l /etc/cron.d/ | grep -i certbot || echo 'no certbot cron.d entry'
# 2. Install a deploy hook. Certbot runs it only after a successful renewal.
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null <<'EOF'
#!/bin/sh
# Validate config first so a bad edit elsewhere does not break the reload.
nginx -t && systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
# 3. Exercise the full challenge path against staging (no production rate limit).
# Certbot skips deploy hooks during a dry run by default, so test the reload separately.
sudo certbot renew --dry-run
sudo nginx -t && sudo systemctl reload nginx
# 4. Compare the expiry on disk with the expiry Nginx actually serves.
echo 'on disk:'
sudo openssl x509 -noout -enddate -in /etc/letsencrypt/live/example.com/cert.pem
echo 'served:'
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -enddate
# If the dates differ, the files were renewed but Nginx was never reloaded.
This script finds the existing certbot scheduler, installs a deploy hook that reloads Nginx after successful renewals, runs a staging dry run, and compares the certificate expiry on disk with the one Nginx serves.
Monitoring expiry independently of certbot
Certbot can report a clean renewal while clients still receive the old certificate. The new files sit in /etc/letsencrypt/live/, but Nginx keeps serving whatever it loaded at its last reload. A deploy hook that never ran, a config that points at a copied file instead of the live/ symlink, or a second Nginx instance behind a load balancer will all produce a "successful" renewal that nobody is actually using. Certbot's own logs cannot detect this, because certbot only checks the disk.
Short lifetimes make the failure strict. Let's Encrypt certificates last 90 days, and certbot renews when about 30 remain. If the timer is disabled, a hook breaks, or a firewall change blocks port 80, nothing visible happens for weeks. Then the site fails on the certificate's notAfter date, and that date was fixed when the certificate was issued.
Check what port 443 actually serves
An independent check connects the way a browser does. It runs openssl s_client -connect host:443 -servername host so SNI selects the right server block, then pipes the result into openssl x509 -noout -enddate to read the expiry. Use -checkend N to get a pass/fail exit code, where N is in seconds.
Thresholds and vantage point
Set the alert threshold relative to the renewal schedule. Certbot normally renews with about 30 days left, so a certificate with fewer than about 20 days remaining means renewal has already failed several times. That still leaves enough time to fix it. Add a second, louder alert at around 7 days.
Run the check from outside the host, such as another region or your existing monitoring system. A check that runs on the server itself connects locally and will not notice DNS changes, broken firewall rules, or a misconfigured load balancer in front of Nginx.
#!/usr/bin/env bash
# check-cert-expiry.sh: run from a host OUTSIDE the web server (another region or
# your monitoring box) so DNS, firewall and load balancer failures are caught too.
set -u
HOST="${1:-www.example.com}"
PORT="${2:-443}"
WARN_DAYS=20 # certbot renews at ~30 days left; <20 means several renewals failed
CRIT_DAYS=7 # louder alert: fix this now
# Fetch the certificate actually served on 443. -servername sends SNI so the
# correct Nginx server block answers. </dev/null closes the session immediately.
cert=$(timeout 15 openssl s_client -connect "${HOST}:${PORT}" -servername "${HOST}" \
</dev/null 2>/dev/null | openssl x509 2>/dev/null)
if [ -z "$cert" ]; then
# No cert at all: DNS change, blocked port, or TLS handshake failure
echo "CRITICAL: no certificate received from ${HOST}:${PORT}"
exit 2
fi
# Human-readable expiry, e.g. notAfter=Jan 18 12:00:00 2027 GMT
enddate=$(printf '%s\n' "$cert" | openssl x509 -noout -enddate)
# -checkend N exits non-zero if the cert expires within N seconds
expires_within() {
printf '%s\n' "$cert" | openssl x509 -noout -checkend $(( $1 * 86400 )) >/dev/null
[ $? -ne 0 ]
}
if expires_within "$CRIT_DAYS"; then
echo "CRITICAL: ${HOST} expires within ${CRIT_DAYS} days (${enddate})"; exit 2
elif expires_within "$WARN_DAYS"; then
echo "WARNING: ${HOST} expires within ${WARN_DAYS} days (${enddate})"; exit 1
fi
echo "OK: ${HOST} ${enddate}"
exit 0
A monitoring script that reads the certificate Nginx actually serves on port 443 via openssl s_client with SNI, prints its notAfter date, and uses openssl x509 -checkend to raise a warning at 20 days and a critical alert at 7 days.
When to avoid HTTP-01, alternatives, and the impact on cost and engineering time
Per-host HTTP-01 assumes one server owns the hostname and answers on public port 80. Several common setups break that assumption.
Wildcards and private hosts. Wildcard certificates and hosts without public port 80 require DNS-01. The server then needs DNS API credentials, so an attacker who compromises it can also alter your DNS records. Limit those credentials as far as your provider allows, ideally to TXT records under _acme-challenge for one zone.
Load-balanced fleets. If every node runs certbot, the challenge file is written on one node while the CA's validation request may reach another node, and validation fails. Each node also requests its own certificate for the same names, which uses up rate limits.
Containers. Ephemeral filesystems lose /etc/letsencrypt on restart, so each restart triggers new issuance and creates a new account.
The alternatives are:
- Issue certificates centrally and distribute them to the nodes.
- Terminate TLS at a single layer, such as the load balancer.
- Run DNS-01 from a dedicated component that holds the only DNS credentials.
| Approach | Wildcards | Credential exposure | Fleet fit | Operational effort |
|---|---|---|---|---|
| HTTP-01 per host | No | None beyond the host | Single nodes only | Low |
| DNS-01 on each host | Yes | DNS credentials on every host | Works, but risky | Medium |
| Centralized issuance | Yes, via DNS-01 | One component | Good | Medium: distribution and reloads |
| LB termination | Depends on the LB | Kept at the LB | Good | Low on backends |
The certificates are free. The cost is the engineering time to build the automation and monitoring. Once you have a renewal path verified with a dry run, a deploy hook that reloads Nginx, and an external expiry check, renewals no longer need manual work, and a failed renewal triggers an alert weeks before any certificate expires.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nginx https lets encrypt, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.