My home lab had grown to the point where five or six things needed the same wildcard certificate: the virtualisation host’s web UI, Plex, a torrent client, two Pi-hole instances and an SMTP proxy. Each had its own half-remembered renewal hack. I consolidated all of them onto one Linux box that runs certbot, with a deploy hook per consumer. Here is the pattern, and the security mistake I found in my own setup while doing it.

Deploy hooks, not cron

certbot runs every script in /etc/letsencrypt/renewal-hooks/deploy/ after a successful renewal, and only then. That is exactly the trigger you want, so there is no need for a separate cron job guessing when the certificate changed.

The catch is that every hook runs for every renewed certificate. If the box renews more than one lineage, guard each script so it only acts on its own.

#!/bin/bash
set -euo pipefail
[ "$(basename "$RENEWED_LINEAGE")" = "example.com" ] || exit 0

scp -i /root/.ssh/deploy_pihole \
    "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" \
    [email protected]:/etc/pihole/tls/
ssh -i /root/.ssh/deploy_pihole [email protected] 'sudo systemctl reload pihole-FTL'

One file per consumer. If Plex breaks you edit the Plex hook and nothing else.

Consumers with awkward formats

Plex wants a PKCS#12 file with a password. The hook builds it fresh each time and rotates the password, writing the new one into Plex’s preferences so nobody has to remember it.

PASS=$(openssl rand -base64 24)
openssl pkcs12 -export -inkey "$RENEWED_LINEAGE/privkey.pem" \
    -in "$RENEWED_LINEAGE/fullchain.pem" -out /tmp/plex.pfx -passout "pass:$PASS"

The virtualisation host just wants the two PEM files dropped into its configuration directory and its web server restarted. The SMTP proxy container mounts a folder and watches it. None of them are difficult once each has its own script.

Lock down the receiving side

Each consumer gets a dedicated SSH key, and the key is restricted in its authorized_keys entry so it can only come from the certbot box and can only run the copy.

from="10.0.0.5",no-pty,no-port-forwarding,command="/usr/local/bin/receive-cert.sh" ssh-ed25519 AAAA...

The mistake I found

Deploy hooks run as root. My hook script was owned by my normal user and was group-writable. Anyone who could write to that file could put a command in it and have root run it at the next renewal. It is an easy thing to do when you write the script as yourself and copy it into place. Make the hooks root-owned and mode 0755, and check the directory the same way.

chown root:root /etc/letsencrypt/renewal-hooks/deploy/*
chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/*

A side quest: the reverse proxy that stopped renewing

While I was in there I noticed my Nginx Proxy Manager instance had stopped renewing its own certificates with the error domain_names.join is not a function. The cause was one row in its SQLite database where the domain list had been stored as a string rather than a JSON array. Fixing the row fixed the renewals. If you see that error, look at the database before you look at the code.

Test it before you need it

certbot renew --dry-run does not run deploy hooks. To test the hooks, run one by hand with the variable set:

RENEWED_LINEAGE=/etc/letsencrypt/live/example.com /etc/letsencrypt/renewal-hooks/deploy/pihole.sh

Do that once per consumer and you will not find out at 3am in ninety days.