Zero-touch sync of renewed Let's Encrypt wildcard certificates from a certbot host to an nginx-based reverse proxy over SSH.
When you renew a wildcard cert with certbot, this tool pushes the new
fullchain.pem / privkey.pem to your proxy and reloads it — automatically on
every renewal. No manual copying, no proxy reconfiguration, no downtime.
It is a single self-contained bash script with an interactive installer. Run it once; renewals take care of themselves from then on.
Use case examples:
- Certbot on a TacticalRMM server → Nginx Proxy Manager
- Dedicated cert box → SWAG container
- VPS with certbot → jwilder nginx-proxy on another server
- Any nginx variant (nginx, OpenResty, Angie, Tengine) → any nginx-based proxy
Certbot renews a cert on host A, but the cert is actually served by a reverse proxy on host B (Nginx Proxy Manager, SWAG, plain nginx, etc.). Nothing in stock certbot bridges that gap. People end up copying PEM files by hand and forgetting, until a site starts serving an expired certificate.
Cert-Sync installs a certbot deploy hook on host A that, on every renewal:
- Confirms the local cert is actually valid (never pushes an expired cert).
- Asks the proxy itself where the certs for your domain live (via
nginx -T) — so it keeps working even when the proxy renumbers its cert directories. - Copies the renewed cert to the right place and reloads the proxy.
A daily drift watchdog compares the cert your proxy is actually serving against the one on disk and warns you if a sync was ever missed.
Discovery is done by running nginx -T on the target, so anything nginx-based
is auto-discovered with zero changes to the proxy:
| Backend | What it is | Notes |
|---|---|---|
baremetal-npm |
Nginx Proxy Manager (bare metal) | Reloads via openresty; pushes through NPM's API so the UI expiry date stays correct |
docker-npm |
Nginx Proxy Manager (Docker) | Same API push, delivered via docker cp / docker exec |
nginx |
Plain nginx / OpenResty / Angie / Tengine / freenginx (bare metal) | Any nginx -T-capable binary |
docker-nginx |
Any non-NPM nginx container — SWAG, jwilder nginx-proxy, plain nginx |
Discovered inside the container |
static (fallback) |
Anything else | You provide a fixed cert directory + reload command |
Not supported for auto-discovery (not nginx — they use their own config formats and usually self-manage certificates): Traefik, Caddy, Pangolin, HAProxy, Apache, Envoy. You can still target these via the manual static destination fallback, but that requires configuring the proxy yourself.
Both scripts are functionally identical — only the UI differs:
setup-certsync.sh— uses whiptail (preinstalled on Debian/Ubuntu). Classic blue fullscreen dialogs. Start here.setup-certsync-gum.sh— uses gum for styled inline prompts. If gum isn't installed it offers to install it (from Charm's apt repo), and falls back to plain text prompts otherwise.
Either way, if no TUI is available the script degrades gracefully to plain read/echo prompts, so it still works over a dumb console.
On the certbot host (any Linux box running certbot):
bash,ssh,scp,openssl,certbotwhiptail(orgum) for the nice UI — optional- certbot ≥ 2.3 for fully automatic syncing. Deploy hooks only fire on
certbot certonlyfrom 2.3 onward. If yours is older, the installer detects it and offers to upgrade certbot via a pip venv (/opt/certbot) — useful in Proxmox LXC containers where snap can't update. Below 2.3 everything still works; you just runsudo certsyncafter each renewal.
On the proxy host:
- SSH access (the installer sets up a dedicated key for you)
nginx(any deployment) ordockerwith an nginx-based container- For NPM API push: NPM's
/data/keys.jsonreadable (default on NPM installs)
Copy the script to your certbot host and run it as the user that owns certbot
(it uses sudo where needed):
chmod +x setup-certsync.sh
./setup-certsync.shThe installer walks you through:
- Proxy connection — host/IP and SSH user. It generates a dedicated SSH key and authorizes it on the proxy (asks for the SSH password once).
- Certificate — auto-detected from your certbot lineages, or entered manually.
- Expiry pre-flight — if the local cert is missing or expired it stops with a red alert and offers to renew it right now (guided DNS-01 challenge).
- Discovery — deploys a small probe to the proxy, detects the backend, and finds where your certs are served from. You confirm or override.
- Hook install — installs the deploy hook, a
sudo certsyncmanual trigger, registers arenew_hook, and (optionally) the drift watchdog. - Initial sync — runs the whole pipeline once as an end-to-end test.
- (Optional) Reload services — if running on TacticalRMM, offers to run
update.sh --forceto apply changes; otherwise, you restart your services manually as needed.
Renew exactly as you do now, e.g. a manual DNS-01 wildcard:
sudo certbot certonly --manual --preferred-challenges dns \
-d '*.example.com' -m you@example.com --agree-tos --no-eff-email- certbot ≥ 2.3 — the sync fires automatically when renewal completes.
- certbot < 2.3 — run the sync by hand afterwards:
sudo certsyncsudo certsync is idempotent, re-checks expiry before touching anything, and
logs every run.
On the certbot host:
| Path | Purpose |
|---|---|
/etc/letsencrypt/renewal-hooks/deploy/copy-to-nginx.sh |
The deploy hook |
/usr/local/bin/certsync |
Manual sync trigger (sudo certsync) |
/usr/local/bin/certsync-check |
Drift watchdog (optional) |
/etc/cron.d/certsync-watch |
Daily watchdog schedule (optional) |
/var/log/certsync.log |
Timestamped log of every sync run |
~/.ssh/id_trmm_to_nginx |
Dedicated SSH key for the proxy |
A renew_hook line in the lineage's renewal/<name>.conf |
On the proxy host:
| Path | Purpose |
|---|---|
~/cert-probe.sh |
Cert-location discovery probe |
~/cert-push.js |
NPM API push agent (NPM backends only) |
Every sync (automatic or manual) is logged with timestamps:
sudo tail -f /var/log/certsync.logThe first question for "did the sync run?" is always that file. A healthy run
ends with cert-sync run finished OK.
The drift watchdog (/usr/local/bin/certsync-check) runs daily, stays
silent when everything matches, and emails root (and exits non-zero) if the
proxy is serving a different cert than the local one. It also works as a
TacticalRMM script check. Run it manually any time:
sudo /usr/local/bin/certsync-checkFor NPM backends the cert is pushed through NPM's own API, which updates its
database — so the UI date should be correct. If you ever see a stale date after
a direct file sync (fallback path), it's cosmetic: nginx serves the correct
cert off disk; only NPM's stored expires_on is behind. Re-running the sync via
the API path refreshes it.
- A dedicated SSH keypair is generated for this purpose only.
- The deploy hook refuses to push a cert that fails
openssl x509 -checkend 0, so an expired/stale cert can never clobber what the proxy is serving. - Cert discovery uses an exact allow-list (the literal
*.domainblock plus the subdomains you specify) — it will never touch a sibling subdomain that has its own dedicated certificate. - The NPM API push only updates Custom certificates matching your wildcard; NPM-managed Let's Encrypt entries are never overwritten.
Add your chosen license here (e.g. MIT) before publishing.