The blog was "down" this morning. Every browser threw NET::ERR_CERT_DATE_INVALID, curl bailed with SEC_E_CERT_EXPIRED. My first assumption — the app crashed — was wrong. The app was fine. curl -k (ignore the cert) returned a clean 200 and the full page. The only thing broken was TLS: the Let's Encrypt certificate had expired the day before.

TL;DR: The cert was managed by Plesk, and Plesk's auto-renewal had quietly died. Instead of fighting Plesk, I moved the domain onto the same standalone certbot that already renews my other domains on that box. Two things bit me on the way: a stale ACME order Plesk kept replaying, and a reverse-proxy vhost that swallowed the ACME challenge. Both below.

Step 0: prove the site is actually up

Before touching anything, separate "the service is down" from "TLS is down". They need completely different fixes.

# ignore the cert, hit the app directly
curl -sk -o /dev/null -w "%{http_code}\n" https://blog.example.com/   # 200 = app is fine

# what cert is actually being served, and when did it expire?
echo | openssl s_client -servername blog.example.com \
  -connect blog.example.com:443 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

notAfter was yesterday. So: app healthy, cert dead. This is a renewal problem, not an outage.

Why it expired at all

The box runs Plesk, and Plesk's Let's Encrypt extension owned this domain's cert. The other domains on the same machine don't go through Plesk — they use plain certbot, and their certbot.timer had renewed them on schedule. Only the Plesk-managed one died, because Plesk's renewal had stopped running (in my case the panel's licensing was no longer healthy, which takes a surprising amount of background machinery down with it).

The lesson I took: don't leave a control panel as the single owner of something as time-critical as a 90-day cert if the panel itself is on the way out. Move it to the boring, well-understood tool.

Gotcha 1: "Order is gone (HTTP 404)"

I tried Plesk's own Let's Encrypt CLI first, to renew in place:

plesk bin extension --exec letsencrypt cli.php -d blog.example.com -m you@example.com

It failed with:

Order at 'https://acme-v02.api.letsencrypt.org/acme/order/.../...' is gone (HTTP 404)

Plesk had cached a pending ACME order from a previous half-finished attempt and kept replaying it. Let's Encrypt had already expired that order server-side, so every retry hit a dead URL. Running it again just replayed the same stale order.

The order state lives in JSON on disk. Find the one for your domain and move it aside so a fresh order gets created:

grep -rl blog.example.com /usr/local/psa/var/modules/letsencrypt/orders/
mv /usr/local/psa/var/modules/letsencrypt/orders/<hash>.json{,.stale-bak}

Now the next attempt builds a new order instead of resurrecting a corpse. But this only got me to the next wall, which is the more interesting one — and the reason I abandoned Plesk's flow entirely.

Gotcha 2: the reverse proxy ate the ACME challenge

The renewal now got far enough to attempt HTTP-01 validation, and failed:

Invalid response from https://blog.example.com/.well-known/acme-challenge/xxxx: 404

Here's why. This vhost isn't serving files — it's a hand-written nginx reverse proxy to the app:

location / {
    proxy_pass http://127.0.0.1:2368;   # everything goes to the app
    ...
}

location / matches everything, including /.well-known/acme-challenge/…, so the challenge request got proxied straight to the app, which knows nothing about ACME and returns 404. The validator never sees the token.

The fix is one location block that intercepts the challenge path and serves it from a real directory, before the catch-all proxy. ^~ makes nginx stop looking once it matches the prefix:

location ^~ /.well-known/acme-challenge/ {
    default_type "text/plain";
    root /var/www/html-acme;
    allow all;
}

location / {
    proxy_pass http://127.0.0.1:2368;
    ...
}
mkdir -p /var/www/html-acme/.well-known/acme-challenge
nginx -t && nginx -s reload

Quick sanity check before you burn a real ACME attempt — drop a file and fetch it. Port 80 redirects to 443 here, and that's fine: Let's Encrypt follows the redirect, so serving the token over HTTPS is enough.

echo ok > /var/www/html-acme/.well-known/acme-challenge/selftest
curl -skI https://blog.example.com/.well-known/acme-challenge/selftest | head -1   # want 200
rm /var/www/html-acme/.well-known/acme-challenge/selftest

Issue the cert with certbot, not Plesk

Now it's the same webroot flow every other domain on the box already uses:

certbot certonly --webroot -w /var/www/html-acme \
  -d blog.example.com --cert-name blog.example.com \
  --non-interactive --agree-tos -m you@example.com

Point the vhost at the certbot-managed files and reload:

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

One trap when you verify: right after a reload, check with SNI, because a no-SNI probe hits the box's default server block and its cert, not yours.

echo | openssl s_client -servername blog.example.com \
  -connect blog.example.com:443 2>/dev/null | openssl x509 -noout -dates

Make sure it never does this again

Getting a valid cert today is worthless if the renewal isn't automated. certbot installs a systemd timer; confirm it's live, and add a deploy hook so nginx actually reloads when the cert rolls over (certbot renews the files but won't restart your web server for you):

systemctl is-enabled certbot.timer && systemctl is-active certbot.timer

cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOF'
#!/bin/sh
/usr/sbin/nginx -s reload
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Then actually test the unattended path with a dry run:

certbot renew --cert-name blog.example.com --dry-run --no-random-sleep-on-renew

That --no-random-sleep-on-renew flag matters if you're impatient: a plain certbot renew inserts a random delay of up to ~8 minutes (to spread load across the internet) and holds its lock the whole time, which looks exactly like a hang. Skip it for the test; leave it on for the timer.

You want to see Congratulations, all simulated renewals succeeded. Now it's boring, and boring is the goal.

FAQ

Was the site actually down? No. The application served 200 the whole time behind the expired cert. Only TLS validation failed. Always test with curl -k before you assume the app crashed — it changes which problem you're solving.

Why not just fix Plesk's renewal? You can, if the panel is healthy. Mine wasn't, and I have five other domains renewing fine through standalone certbot. Consolidating onto the tool that already works beats resurrecting the one that doesn't.

Will Plesk overwrite my nginx edits? For a fully custom vhost it usually won't regenerate it, but Plesk can rewrite domain configs when you change settings in the panel. If you keep Plesk around, add the ACME location as an "Additional nginx directive" in the panel instead of editing the file directly, so it survives a reconfigure.

How do I know which cert nginx is really serving? openssl s_client -servername <domain> -connect <domain>:443. Always pass -servername (SNI), or you'll get the default vhost's cert and chase a ghost.

Related reading on this blog: systemd timers instead of cron for how the renewal timer actually fires, and Caddy vs Nginx — because half the appeal of Caddy is never writing this post in the first place.


Related posts