Mert Özen Further With Every Line
DevOps SSL/TLS Certificates and HTTPS — #03

Automatic Certificates with Let's Encrypt and ACME: Certbot, Nginx, Wildcards and Renewal with ARI

Mert Özen Nov 14, 2026 18 min 5 views
Automatic Certificates with Let's Encrypt and ACME: Certbot, Nginx, Wildcards and Renewal with ARI

Getting a certificate with ACME takes account, order, authorization, challenge and finalize steps; Certbot does it on Nginx with webroot or the nginx plugin, gets wildcards with DNS-01, and thanks to ARI renews when the CA suggests. With real runs on Pebble we watch a revoked certificate renew itself through ARI, and look at profiles, 6-day and IP certificates, and the cost of closing port 80.

Version note: The examples in this post were run with Certbot 5.10.0, Nginx 1.30.6 and Pebble, Let's Encrypt's test server (November 2026 image), on Docker 29; Let's Encrypt's real directory and profile information were read on 14 November 2026.

Guess First

Bookstore's Nginx server has Certbot installed, and certbot renew runs twice a day. Certificates last 90 days, and Certbot renews them when a third of the lifetime remains, around day 60. One morning the CA announces that a bug was found in its validation software and the affected certificates will be revoked within five days. This is not fiction; in 2020 Let's Encrypt announced it would revoke 3,048,289 certificates this way because of a bug in its CAA checking.

Guess: if Bookstore's certificate was obtained only 20 days ago, will Certbot renew it before the revocation? Does someone have to log into the server for that? We will find out in the middle of the post by actually revoking the certificate and watching.

The ACME Flow: Account, Order, Authorization, Challenge, Finalize

ACME is the protocol that turns getting a certificate into an HTTP API. Everything starts with a directory address. Let's Encrypt's production directory looks like this today:

$ curl -s https://acme-v02.api.letsencrypt.org/directory
{
  "keyChange": "https://acme-v02.api.letsencrypt.org/acme/key-change",
  "meta": {
    "caaIdentities": [
      "letsencrypt.org"
    ],
    "profiles": {
      "classic": "https://letsencrypt.org/docs/profiles#classic",
      "shortlived": "https://letsencrypt.org/docs/profiles#shortlived",
      "tlsserver": "https://letsencrypt.org/docs/profiles#tlsserver"
    },
    "termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.8-July-06-2026.pdf",
    "website": "https://letsencrypt.org"
  },
  "newAccount": "https://acme-v02.api.letsencrypt.org/acme/new-acct",
  "newNonce": "https://acme-v02.api.letsencrypt.org/acme/new-nonce",
  "newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order",
  "nxZYWyxD4ek": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417",
  "renewalInfo": "https://acme-v02.api.letsencrypt.org/acme/renewal-info",
  "revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert"
}

The client assumes no URL other than these. The randomly named nxZYWyxD4ek field is a joke that tests exactly that: it is there so that clients parsing the directory as a fixed schema break. The steps of the flow:

  • Account: the client generates a key pair and registers with newAccount. Every later request is signed with this account key.
  • Order: newOrder says "I want a certificate for these names"; the CA returns an authorization for each name.
  • Authorization and challenge: each authorization offers challenge options such as HTTP-01 and DNS-01 (DNS: Domain Name System). The client prepares one and tells the CA "ready", and the CA validates it.
  • Finalize: once all authorizations are valid, the client sends the CSR, the CA signs the certificate, and the client downloads it.

In this post's lab I used Pebble, Let's Encrypt's open-source test server, instead of Let's Encrypt. Pebble speaks the same protocol but is not a real CA; it generates a new root every time it starts. That is why you see --server https://pebble:14000/dir in the commands. On your own server, without this parameter Certbot goes to Let's Encrypt production, and with --test-cert to staging. Certbot's debug log shows the flow exactly:

$ grep -oE "Sending (GET|POST|HEAD) request to [^ ]+" /var/log/letsencrypt/letsencrypt.log
Sending GET request to https://pebble:14000/dir.
Sending HEAD request to https://pebble:14000/nonce-plz.
Sending POST request to https://pebble:14000/sign-me-up:
Sending POST request to https://pebble:14000/order-plz:
Sending POST request to https://pebble:14000/authZ/Ch3IeZwNYMkKOroGEmMcWYznO264tUdS05S53mYBkxs:
Sending POST request to https://pebble:14000/authZ/ZpA_NHihl-1tMJkTKcpSDBu-aHdvRAlTtBTFlauuf4E:
Sending POST request to https://pebble:14000/chalZ/xskVwxPNsj8vPPk4KXBfUXlx8TgOa3eEvB6OawbrjIA:
Sending POST request to https://pebble:14000/chalZ/y3JuOtCXCriGNZjNdDYgZncLK-qJUBx9zZg7Gpewszc:
Sending POST request to https://pebble:14000/authZ/Ch3IeZwNYMkKOroGEmMcWYznO264tUdS05S53mYBkxs:
Sending POST request to https://pebble:14000/authZ/ZpA_NHihl-1tMJkTKcpSDBu-aHdvRAlTtBTFlauuf4E:
Sending POST request to https://pebble:14000/finalize-order/ck7m5TpPlkSsgW7qMnUQR4gd2w54QCvNcqwAOLkC_B4:
Sending POST request to https://pebble:14000/my-order/ck7m5TpPlkSsgW7qMnUQR4gd2w54QCvNcqwAOLkC_B4:
Sending POST request to https://pebble:14000/certZ/424b9a33d3d87124:

Directory, nonce, account, order, two authorizations for two names, two challenges, polling the authorizations' results, finalize, polling the order status, and downloading the certificate. Pebble's endpoint names (sign-me-up, order-plz) are playful, but the order is the same as at Let's Encrypt. Because the protocol guarantees this order, clients such as Certbot, acme.sh, lego, Caddy or cert-manager are interchangeable.

Staging and Rate Limits

Let's Encrypt production has rate limits, and it is easy to hit them while experimenting with configs. The ones you meet most often today: 50 certificates per registered domain per 7 days, 5 certificates per 7 days for the exact same set of names (the duplicate limit), and 5 failed validations per name per hour. Always try a new setup against staging first (--test-cert). Staging's limits are much higher; its certificates come from an untrusted root, so browsers warn, but it really runs the whole flow. One more detail matters: renewals coordinated with ARI are exempt from all rate limits. Ordinary renewals with the same set of names are exempt from the new-order and per-domain limits, but not from the duplicate limit.

Certbot with Nginx: Webroot

The most common setup on Linux: Nginx sits in front, and Certbot writes the challenge file into a folder that Nginx serves. Nginx's port 80 config does only two things: serve the challenge and redirect everything else to HTTPS (HTTP Secure):

server {
    listen 80 default_server;
    server_name bookstore.example www.bookstore.example;

    location /.well-known/acme-challenge/ {
        root /var/www/acme;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

Getting the certificate:

$ certbot certonly --webroot -w /var/www/acme \
    -d bookstore.example -d www.bookstore.example \
    --server https://pebble:14000/dir -m ops@bookstore.example --agree-tos --no-eff-email \
    --deploy-hook "nginx -s reload"
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Account registered.
Requesting a certificate for bookstore.example and www.bookstore.example
Hook 'deploy-hook' ran with error output:
 2026/11/13 21:39:45 [notice] 69#69: signal process started

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/bookstore.example/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/bookstore.example/privkey.pem
This certificate expires on 2027-02-11.
These files will be updated when the certificate renews.

The "ran with error output" line looks scary, but the hook succeeded. nginx -s reload writes its notice to stderr, and Certbot shows anything written to stderr under that heading. On a server running systemd, systemctl reload nginx avoids the noise. --deploy-hook runs only when a certificate was actually renewed and is saved to the renewal config, so it will run on later renewals too.

The requests that hit Nginx's access log during validation:

$ docker logs --since $T0 tlsseries-web 2>&1 | grep acme-challenge | awk '{print $1, $4, $6, $7, $9}'
172.31.33.20 [13/Nov/2026:21:39:43 "GET /.well-known/acme-challenge/fjvv7XptyCayJkVnJbHnCH_IhLXklC_5oo3DSeq4LKM 200
172.31.33.20 [13/Nov/2026:21:39:43 "GET /.well-known/acme-challenge/fjvv7XptyCayJkVnJbHnCH_IhLXklC_5oo3DSeq4LKM 200
172.31.33.20 [13/Nov/2026:21:39:43 "GET /.well-known/acme-challenge/yWBInESu70B2h2eERCK7qOKDXzwyWB10CMcWrVuXmbs 200
172.31.33.20 [13/Nov/2026:21:39:43 "GET /.well-known/acme-challenge/yWBInESu70B2h2eERCK7qOKDXzwyWB10CMcWrVuXmbs 200
172.31.33.20 [13/Nov/2026:21:39:43 "GET /.well-known/acme-challenge/fjvv7XptyCayJkVnJbHnCH_IhLXklC_5oo3DSeq4LKM 200
172.31.33.20 [13/Nov/2026:21:39:43 "GET /.well-known/acme-challenge/yWBInESu70B2h2eERCK7qOKDXzwyWB10CMcWrVuXmbs 200

Pebble requested each token three times. Let's Encrypt also repeats validation from different points on the internet; that is why trying to open port 80 only to "the CA's IP address" doesn't work. The file layout Certbot leaves behind:

$ cd /etc/letsencrypt/live/bookstore.example && ls -l | awk 'NR>1 {print $9, $10, $11}'
README
cert.pem -> ../../archive/bookstore.example/cert1.pem
chain.pem -> ../../archive/bookstore.example/chain1.pem
fullchain.pem -> ../../archive/bookstore.example/fullchain1.pem
privkey.pem -> ../../archive/bookstore.example/privkey1.pem

The files under live/ are symlinks; the real files are under archive/, and the number increases on every renewal. Always give Nginx the live/ path so the config doesn't change on renewal:

server {
    listen 443 ssl;
    server_name bookstore.example www.bookstore.example;

    ssl_certificate     /etc/letsencrypt/live/bookstore.example/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/bookstore.example/privkey.pem;

    location / {
        return 200 "Bookstore over HTTPS\n";
    }
}
$ curl -sS --cacert /tmp/pebble-root.pem https://bookstore.example/
Bookstore over HTTPS
$ echo | openssl s_client -connect bookstore.example:443 -servername bookstore.example \
    -CAfile /tmp/pebble-root.pem 2>/dev/null | grep -E '^ *[0-9] s:|^   i:|^Verify'
 0 s:
   i:CN=Pebble Intermediate CA 402e82
 1 s:CN=Pebble Intermediate CA 402e82
   i:CN=Pebble Root CA 61303e
Verify return code: 0 (ok)

The leaf's Subject is empty; like Let's Encrypt's tlsserver profile, Pebble does not set a CN, and the names live only in the SAN. The fullchain rule from the first post of the series is satisfied automatically here.

Webroot or the nginx Plugin?

Certbot's nginx plugin (--nginx) solves the challenge with a temporary config and writes the certificate into your config itself. I tried it on a separate server block for Bookstore's API:

$ certbot --nginx -d api.bookstore.example --redirect -n --server https://pebble:14000/dir
Requesting a certificate for api.bookstore.example
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/api.bookstore.example/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/api.bookstore.example/privkey.pem
This certificate expires on 2027-02-11.
These files will be updated when the certificate renews.
Deploying certificate
Successfully deployed certificate for api.bookstore.example to /etc/nginx/conf.d/api.conf
Congratulations! You have successfully enabled HTTPS on https://api.bookstore.example
$ diff -u /tmp/api.conf.orig /etc/nginx/conf.d/api.conf
@@ -1,8 +1,26 @@
 server {
-    listen 80;
     server_name api.bookstore.example;

     location / {
         proxy_pass http://127.0.0.1:8080;
     }
+
+    listen 443 ssl; # managed by Certbot
+    ssl_certificate /etc/letsencrypt/live/api.bookstore.example/fullchain.pem; # managed by Certbot
+    ssl_certificate_key /etc/letsencrypt/live/api.bookstore.example/privkey.pem; # managed by Certbot
+    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
+
 }
+server {
+    if ($host = api.bookstore.example) {
+        return 301 https://$host$request_uri;
+    } # managed by Certbot
+
+
+    listen 80;
+    server_name api.bookstore.example;
+    return 404; # managed by Certbot
+
+
+}

HTTPS was enabled with one command, but Certbot rewrote your file: it moved listen 80, included its own TLS settings file and added a redirect block. If you keep the config in Git or generate it with a tool like Ansible, the next deploy wipes these changes. The rule is simple: on a single, hand-managed machine --nginx is convenient; if you do config as code, get the certificate with certonly --webroot and write the Nginx config yourself. We continue with webroot for the rest of this post. The Nginx HTTPS config itself (protocols, headers, HTTP/2) is in the "HTTPS, Security Headers, Rate Limiting and Load Balancing in Nginx" post of the Web Servers and Hosting series.

Renewal: systemd Timer, --dry-run and ARI

If you installed Certbot with pip or from source, scheduling renewal is your job; distribution packages and the snap set it up themselves. A systemd timer:

# /etc/systemd/system/certbot-renew.service
[Unit]
Description=Renew certificates with Certbot

[Service]
Type=oneshot
ExecStart=/opt/certbot/bin/certbot renew --quiet

# /etc/systemd/system/certbot-renew.timer
[Unit]
Description=Run certbot renew twice a day

[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target
$ systemd-analyze verify certbot-renew.timer certbot-renew.service; echo "verify exit=$?"
verify exit=0
$ systemd-analyze calendar --iterations=3 "*-*-* 00,12:00:00"
Normalized form: *-*-* 00,12:00:00
    Next elapse: Sat 2026-11-14 00:00:00 UTC
       From now: 2h 16min left
   Iteration #2: Sat 2026-11-14 12:00:00 UTC
       From now: 14h left
   Iteration #3: Sun 2026-11-15 00:00:00 UTC
       From now: 1 day 2h left

Twice a day is not too often: certbot renew doesn't touch certificates that are not due. RandomizedDelaySec keeps thousands of servers from hitting the CA in the same second. Certbot also adds its own random wait when it runs non-interactively; I saw a log line like Non-interactive renewal: random delay of 263.347987354948 seconds. Persistent=true makes up for a run missed while the machine was off. systemd doesn't run in my container, so I could not actually trigger the timer; I verified the files with systemd-analyze.

To test the renewal chain without waiting for a real renewal:

$ certbot renew --dry-run --server https://pebble:14000/dir
Processing /etc/letsencrypt/renewal/bookstore.example.conf
Simulating renewal of an existing certificate for bookstore.example and www.bookstore.example
Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/bookstore.example/fullchain.pem (success)

In the lab I added --server because --dry-run goes to Let's Encrypt staging by default. The first time I forgot --server, Certbot tried to connect to acme-staging-v02.api.letsencrypt.org. On a production server that is a good thing: a dry run doesn't use up production rate limits. One more detail to watch: a dry run does not run deploy hooks. To test the hook itself, add --run-deploy-hooks.

Now ARI. Standardized as RFC 9773, ARI lets the client ask the CA "when should I renew this certificate?". Since Certbot 4.1, certbot renew asks on every run. The question is asked with an identifier made of the issuing CA's key identifier (AKI, Authority Key Identifier) and the certificate's serial number:

$ aki=$(openssl x509 -in cert.pem -noout -ext authorityKeyIdentifier | tail -1 | tr -d ' :')
$ serial=$(openssl x509 -in cert.pem -noout -serial | cut -d= -f2)
$ b64url() { xxd -r -p | base64 | tr '+/' '-_' | tr -d '='; }
$ echo "$(echo $aki | b64url).$(echo $serial | b64url)"
AvON4hzT-xLJspVeKzVX0-KAMDM.QkuaM9PYcSQ
$ curl -s -D - --cacert /pebble.minica.pem \
    https://pebble:14000/draft-ietf-acme-ari-03/renewalInfo/AvON4hzT-xLJspVeKzVX0-KAMDM.QkuaM9PYcSQ \
    | grep -iE "retry-after|start|end|^\{|\}"
retry-after: 21600
{
      "start": "2027-01-11T21:39:43Z",
      "end": "2027-01-13T21:39:43Z"
   }
}

For a 90-day certificate the CA suggests days 59 to 61; retry-after says "ask again in six hours". Certbot saves this in the renewal config (ari_retry_after = 2026-11-14T03:40:03) and doesn't bother the CA until then:

$ certbot renew
Processing /etc/letsencrypt/renewal/bookstore.example.conf
Certificate not yet due for renewal
The following certificates are not due for renewal yet:
  /etc/letsencrypt/live/bookstore.example/fullchain.pem expires on 2027-02-11 (skipped)
No renewals were attempted.

Now let's stage the Guess First scenario. I revoke the certificate at the CA and ask ARI again:

$ certbot revoke --cert-name bookstore.example --reason keycompromise --no-delete-after-revoke --non-interactive
Congratulations! You have successfully revoked the certificate that was located at /etc/letsencrypt/live/bookstore.example/cert.pem.
$ curl -s --cacert /pebble.minica.pem \
    https://pebble:14000/draft-ietf-acme-ari-03/renewalInfo/AvON4hzT-xLJspVeKzVX0-KAMDM.QkuaM9PYcSQ
{
   "suggestedWindow": {
      "start": "2026-11-13T20:44:52.174Z",
      "end": "2026-11-13T20:44:53.174Z"
   }
}

The suggested window is now in the past. In the CA's language that means "renew right now". In real life the next certbot renew sees this answer within six hours at most. To avoid waiting in the lab, I deleted the ari_retry_after line from the renewal config and then renewed:

$ certbot renew
Processing /etc/letsencrypt/renewal/bookstore.example.conf
Renewing an existing certificate for bookstore.example and www.bookstore.example
Hook 'deploy-hook' ran with error output:
 2026/11/13 21:48:34 [notice] 515#515: signal process started
Congratulations, all renewals succeeded:
  /etc/letsencrypt/live/bookstore.example/fullchain.pem (success)
$ grep -h -E "Certificate is due for renewal|random delay" /var/log/letsencrypt/letsencrypt.log | cut -d: -f4-
INFO:certbot._internal.renewal:Certificate is due for renewal, auto-renewing...
INFO:certbot._internal.renewal:Non-interactive renewal: random delay of 220.4705418133215 seconds
$ cd /etc/letsencrypt/live/bookstore.example && ls -l | awk 'NR>1 {print $9, $10, $11}'
README
cert.pem -> ../../archive/bookstore.example/cert2.pem
chain.pem -> ../../archive/bookstore.example/chain2.pem
fullchain.pem -> ../../archive/bookstore.example/fullchain2.pem
privkey.pem -> ../../archive/bookstore.example/privkey2.pem

That is the answer to Guess First. For a certificate only 20 days old, Certbot without ARI would have waited for day 60; with ARI, once the CA said "now", it renewed on the timer's next run, the deploy hook reloaded Nginx, and nobody had to log into the server. That is exactly ARI's value: it takes renewal timing away from a fixed rule and hands it to the CA, which knows the situation when an incident happens. I saw one more thing in the lab: the order Certbot 5.10 sent on renewal had no replaces field, which RFC 9773 defines to point at the old certificate. Let's Encrypt's ARI rate limit exemption depends on that field. A renewal with the same set of names is still exempt from the new-order and per-domain limits.

DNS-01 for Wildcards

A wildcard like *.bookstore.example can only be obtained with DNS-01. Certbot has ready-made DNS plugins (for Cloudflare, Route 53, Azure DNS and others). For a provider without a plugin, you write two hook scripts with --manual. In the lab I used Pebble's test DNS server pebble-challtestsrv as the DNS server; the hooks call its HTTP API:

#!/bin/sh
# /etc/letsencrypt/hooks/dns-auth.sh
# Lab: the test DNS server's API (pebble-challtestsrv); in production, your DNS provider's API
curl -sf -X POST http://localhost:8055/set-txt \
  -d "{\"host\":\"_acme-challenge.${CERTBOT_DOMAIN}.\",\"value\":\"${CERTBOT_VALIDATION}\"}"

#!/bin/sh
# /etc/letsencrypt/hooks/dns-cleanup.sh
curl -sf -X POST http://localhost:8055/clear-txt \
  -d "{\"host\":\"_acme-challenge.${CERTBOT_DOMAIN}.\"}"
$ certbot certonly --manual --preferred-challenges dns \
    --manual-auth-hook /etc/letsencrypt/hooks/dns-auth.sh \
    --manual-cleanup-hook /etc/letsencrypt/hooks/dns-cleanup.sh \
    --cert-name bookstore-wildcard -d bookstore.example -d "*.bookstore.example" \
    --server https://pebble:14000/dir -n
Requesting a certificate for bookstore.example and *.bookstore.example
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/bookstore-wildcard/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/bookstore-wildcard/privkey.pem
This certificate expires on 2027-02-11.
$ openssl x509 -in /etc/letsencrypt/live/bookstore-wildcard/cert.pem -noout -ext subjectAltName
X509v3 Subject Alternative Name: critical
    DNS:bookstore.example, DNS:*.bookstore.example

The DNS server's log shows the apex and the wildcard writing two separate TXT values to the same _acme-challenge.bookstore.example name:

Added TXT response for Host "_acme-challenge.bookstore.example." - Value "XsGkEbEjw6_yjBpoNEBIcclqKB_y638RjDQo8caLstI"
Added TXT response for Host "_acme-challenge.bookstore.example." - Value "5g1NzrmOkh1KKLy8P4hPhxeYDcZYKVDGAvVZ9DPyGzE"
Removed TXT response for Host "_acme-challenge.bookstore.example."
Removed TXT response for Host "_acme-challenge.bookstore.example."

DNS-01's real risk is the DNS API credential itself. Someone who gets hold of it doesn't just obtain certificates; they can change every record of the domain and redirect its mail and traffic elsewhere. There are three precautions. First, create the token with the narrowest permission the provider allows: only that zone, only DNS record editing. Second, point _acme-challenge.bookstore.example with a CNAME record to a separate zone used only for validation. Let's Encrypt follows this delegation, so the automation's write access never touches the main zone. Third, keep the credentials file readable only by root with chmod 600; Certbot DNS plugins warn about a file with broad permissions.

On the horizon is DNS-PERSIST-01: a persistent TXT record, written once, that names the CA and the ACME account, so you no longer need to write to DNS on every renewal. In its February 2026 announcement Let's Encrypt targeted production for the second quarter of 2026. A draft version was tried in staging, but in June 2026 the Let's Encrypt team wrote that they would not deploy it to production until an open issue in the IETF draft is resolved. As of 14 November 2026 it is not on Let's Encrypt's challenge types page. If you plan to use it, check the current status first.

Profiles, 6-Day and IP Certificates, Generation Y

Let's Encrypt has supported ACME profiles since January 2025. You choose the shape of the certificate by sending a profile name in the order:

ProfileLifetimeSubject CNNumber of namesIP addressAuthorization reuse
classic (default)90 daysYes100No30 days
tlsserver45 daysNo25No7 hours
shortlived160 hours (about 6 days)No25Yes7 hours

Since Certbot 4.0 there are two options. --preferred-profile falls back to the default if the profile doesn't exist. --required-profile fails if it doesn't. The profile you choose is saved to the renewal config. I restarted the lab's Pebble with the same three profiles and the same lifetimes:

$ certbot certonly --webroot -w /var/www/acme --cert-name bookstore-short -d bookstore.example \
    --preferred-profile shortlived --server https://pebble:14000/dir -n
Requesting a certificate for bookstore.example
This certificate expires on 2026-11-19.
$ grep profile /etc/letsencrypt/renewal/bookstore-short.conf
preferred_profile = shortlived
$ certbot certonly --webroot -w /var/www/acme --cert-name bookstore-typo -d bookstore.example \
    --required-profile tlserver --server https://pebble:14000/dir -n
Requesting a certificate for bookstore.example
An unexpected error occurred:
Order includes unrecognized profile name "tlserver"

The typo in the second command is a good lesson: --required-profile does not quietly ignore a wrong profile name. --preferred-profile, on the other hand, keeps renewal from breaking when Let's Encrypt removes a profile. Use the latter in production.

IP address certificates, generally available since January 2026, are also tied to the shortlived profile. Let's Encrypt issues IP certificates only as short-lived ones, on the grounds that IP addresses change hands more often than domains. Certbot 5.3 added the --ip-address parameter, and 5.4 added webroot support for it. DNS-01 cannot be used for an IP address; you need HTTP-01 or TLS-ALPN-01:

$ certbot certonly --webroot -w /var/www/acme --cert-name bookstore-ip --ip-address 172.31.33.10 \
    --preferred-profile shortlived --server https://pebble:14000/dir -n
Requesting a certificate for 172.31.33.10
This certificate expires on 2026-11-19.
$ openssl x509 -in /etc/letsencrypt/live/bookstore-ip/cert.pem -noout -ext subjectAltName -dates
X509v3 Subject Alternative Name: critical
    IP Address:172.31.33.10
notBefore=Nov 13 21:50:04 2026 GMT
notAfter=Nov 19 21:50:03 2026 GMT

Certbot renews a 6-day certificate when half of its lifetime remains (that is the rule for certificates shorter than 10 days). So the twice-daily timer is a must here. The system stopping for even a day puts the certificate at risk.

Two more changes happened in 2026. The first is the Generation Y hierarchy: since May 2026 the default profile signs from intermediates under the new Root YE and Root YR. The long, cross-signed chain we saw in the first post of the series comes from this, and the cross-signs provide compatibility with old trust stores. The second is the removal of the TLS client authentication EKU: since 11 February 2026 Let's Encrypt no longer includes this EKU in the default profile, and the tlsclient profile offered for the transition shut down on 8 July 2026. Systems that use a Let's Encrypt certificate as a client certificate in mTLS (mutual TLS) find on renewal that the new certificate is invalid for client authentication. The right home for that need is an internal CA, the subject of the seventh post in the series.

In Production

Monitor renewal; installing it is not enough. A timer's existence doesn't prove renewal works. You need an alert that measures, from the outside, the remaining days of the certificate the server actually serves; we will build it in the last post of the series. Let's Encrypt shut down expiry emails entirely in June 2025, so the CA will no longer warn you either.

Test the deploy hook. If the new certificate is written to disk but Nginx is not reloaded, the server keeps serving the old certificate until it expires. This is the most common "renewed, but we still had an outage" incident in the industry. Verify with certbot renew --dry-run --run-deploy-hooks that the hook runs, then with openssl s_client that the served serial number changed.

Back up /etc/letsencrypt and keep it portable. The account key, renewal configs and certificates live here. If this folder is missing when the server is rebuilt, you open a new account; that is not a problem, but the hooks and profile choices are lost too.

Add a CAA record. A CAA 0 issue "letsencrypt.org" record in DNS says only Let's Encrypt may issue certificates for your domain. Details are in the Domain, DNS and CDN series.

Common Pitfalls

Closing port 80: if you close 80 in the firewall because "everything is HTTPS, we don't need 80", HTTP-01 breaks. I tried it with a name pointing to a closed port:

$ certbot certonly --webroot -w /var/www/acme -d shop.bookstore.example --server https://pebble:14000/dir -n
Requesting a certificate for shop.bookstore.example
Certbot failed to authenticate some domains (authenticator: webroot). The Certificate Authority reported these problems:
  Identifier: shop.bookstore.example
  Type:   connection
  Detail: Get "http://shop.bookstore.example:80/.well-known/acme-challenge/zlnlY8fFa1osJ61nbhIAVFXI7uILvClI-pmjvaxEXHc": dial tcp 172.31.33.20:80: connect: connection refused
Hint: The Certificate Authority failed to download the temporary challenge files created by Certbot. Ensure that the listed identifiers serve their content from the provided --webroot-path/-w and that files created there can be downloaded from the internet.
Some challenges have failed.

On a server where the first issuance succeeded and port 80 was closed later, this error shows up 60 days later, at renewal. Keep 80 open, serve only the challenge and redirect the rest with a 301; or switch to DNS-01.

Losing the default server: the port 80 block the --nginx plugin added was in a file that came alphabetically first under conf.d. So it became the default server for the challenge request arriving by IP address, and the request got a 404. Writing listen 80 default_server; in the block that serves the challenge fixed it.

HTTP-01 behind a CDN proxy: when a proxy like Cloudflare sits in front of the domain, the CA's request goes to the proxy first. The proxy's "force everything to HTTPS" rule or its own edge certificate may keep the challenge from reaching the origin. Either exempt the challenge path in the proxy rules or use DNS-01. The edge vs origin certificate distinction is the subject of the fifth post.

Running Certbot in a container without a volume: if the certificates and the account key stay in the container's own file system, they are all gone when the container is removed, and every run requests a new certificate. That means hitting the duplicate limit (5 per 7 days) quickly. /etc/letsencrypt must be a persistent volume.

Confusing the deploy hook with --pre-hook/--post-hook: --post-hook runs after every renew run, even when nothing was renewed. It reloads needlessly twice a day. Work that should run only on renewal goes into --deploy-hook.

A Small Experiment

★☆☆ On a test server of yours, run Certbot against Let's Encrypt staging with --test-cert. Find the request sequence from this post in /var/log/letsencrypt/letsencrypt.log and note which request is which ACME step.

★★☆ Compute the ARI identifier of a certificate as in this post and ask Let's Encrypt's renewal-info endpoint (staging or production). What percentage of the certificate's lifetime does the suggested window correspond to? How many seconds is Retry-After?

★★★ Bring up Pebble and pebble-challtestsrv in Docker and repeat this post's revocation experiment, but this time wait for the systemd timer to trigger renewal instead of running certbot renew (on a real virtual machine). Verify from the outside that the deploy hook reloaded Nginx and the served serial number changed.

The certificate is now on disk and renews itself. But there are cases where Nginx is not in front: a container where Kestrel terminates TLS itself, the dev certificate on a developer machine, or an HttpClient connecting to an internal service. In the next post we move to the .NET side: dev-certs, loading PEM and PFX (PKCS #12), SNI, reload without a restart after renewal, and HttpClient's certificate validation.