Deploy hooks: from renewed certificate to live edge
A renewed certificate sitting on disk is half the job. CertMate's deploy hook system runs a shell command per domain when a cert is created, renewed, or revoked — and the env contract is explicit enough to script against. Here's the schema, the security model, and the recipes that actually work.
Certificate renewal isn't done when the bytes hit disk. The interesting part is everything after renewal: reloading nginx, syncing the cert to an HAProxy peer, pushing the bundle to an S3 bucket your edge consumers read, paging on revocation. CertMate's deploy hooks are the contract between "cert renewed" and that downstream work.
The schema
A hook is a JSON object with five fields:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Stable identifier. The UI auto-generates a UUID. Used by /api/deploy/test/<id>. |
name | string | yes | Human label shown in the UI and audit log. |
command | string | yes | A single shell command run via sh -c. Max 1024 chars. |
enabled | boolean | no | Default true. Disabled hooks skip automatic firing but can still be tested manually. |
timeout | integer | no | Seconds. Default 30, capped at the system max (300). |
on_events | string[] | no | Subset of ["created", "renewed", "revoked"]. Absent = all three. |
Hooks live under two keys in the deploy_hooks config:
-
global_hooks— fire for every domain. Good for "reload nginx after any cert changes." -
domain_hooks— keyed by exact domain name. Good for "push the LB cert forapi.example.comto S3 after that specific cert renews."
The env contract
Every invocation receives these environment variables in the hook's process. Script against these names; CertMate guarantees them.
| Variable | Example |
|---|---|
CERTMATE_DOMAIN | api.example.com |
CERTMATE_CERT_PATH | /app/certificates/api.example.com/cert.pem |
CERTMATE_KEY_PATH | /app/certificates/api.example.com/privkey.pem |
CERTMATE_FULLCHAIN_PATH | /app/certificates/api.example.com/fullchain.pem |
CERTMATE_EVENT | created / renewed / revoked / manual |
CERTMATE_DRY_RUN | Set to 1 during dry-run only; absent otherwise. |
The CERTMATE_DRY_RUN flag is the one nobody thinks
about until they need it: when CertMate is running a renewal in
staging mode, it still fires hooks so the wiring stays exercised,
but it also sets that flag so your hook can short-circuit. See the
"Skip hooks during dry-run" recipe below.
Recipes that actually work
Reload nginx
The minimal global hook:
/usr/sbin/nginx -s reload
No env vars referenced — nginx already knows where the certs live
via its config. The hook timeout default (30s) is generous; if you
see timeout after 30s in the audit log, something is
blocking the reload, not the hook.
Sync to a remote nginx peer
scp -i /etc/certmate/deploy_key \
"$CERTMATE_FULLCHAIN_PATH" "$CERTMATE_KEY_PATH" \
deploy@nginx-2.internal:/etc/nginx/certs/$CERTMATE_DOMAIN/ \
&& ssh -i /etc/certmate/deploy_key deploy@nginx-2.internal \
'sudo systemctl reload nginx'
Put this in a domain_hooks entry for the specific
cert. The SSH key is restricted to a single command on the remote
side via authorized_keys + command= — see
the security model below.
Push to an S3 bucket
aws s3 cp "$CERTMATE_FULLCHAIN_PATH" \
"s3://my-certs-bucket/$CERTMATE_DOMAIN/fullchain.pem" \
--sse aws:kms --acl private Useful when the consumer of the cert is a Lambda, a Fastly origin-shielded edge config, or anything else that reads certificates from S3.
Page on revocation
if [ "$CERTMATE_EVENT" = "revoked" ]; then
curl -X POST "$PAGERDUTY_INTEGRATION_URL" \
-H "Content-Type: application/json" \
-d "{ \"event_action\": \"trigger\",
\"payload\": {
\"summary\": \"Cert revoked: $CERTMATE_DOMAIN\",
\"severity\": \"critical\",
\"source\": \"certmate\"
} }"
fi
Same hook, scoped via on_events: ["revoked"] so the
event check is belt-and-suspenders. PagerDuty integration URL goes
in the CertMate process env.
Skip during dry-run
if [ -n "$CERTMATE_DRY_RUN" ]; then
echo "dry-run, skipping deploy"
exit 0
fi
# real work here Security model
Deploy hooks run as the CertMate process user, with the full
process environment, and execute under sh -c. That's
enough power that CertMate enforces some guardrails on the
configured command string:
- Blocked shell patterns. Commands containing obviously-malicious shapes (eval, rm -rf /, fork bombs) are rejected at write time. The list is non-exhaustive on purpose — it stops accidents, not a determined attacker who already has admin on CertMate.
- Blocked file references. Reading sensitive
paths (
/etc/shadow, the CertMate sqlite database) via simplecat-style commands is rejected. - 1024-char command limit. Forces complex logic into an external script. That's the right call: a 2 KB shell one-liner is a maintenance trap.
- Timeout capped at 300 s. A hook that runs for five minutes is doing too much. Background the work or move it to a queue and have the hook just enqueue.
The real boundary is process-level. If you need the hook to run as
a different user, restart a privileged service, or change ownership
on files, use sudo with a narrowly-scoped sudoers
entry rather than running CertMate as root.
Audit, debug, dry-run
Every hook firing writes an audit row with the user / token that triggered it (or the renewal scheduler, for automatic firings), the exit code, stdout / stderr truncated to a reasonable bound, and the wall-clock duration. The UI also has a per-hook Test button that fires the hook with the most recent certificate's paths so you can verify the wiring without forcing an actual renewal.
Common failures, transcribed from the audit log:
| Message | Cause |
|---|---|
timeout after 30s | Hook ran longer than its timeout. Bump it (max 300s) or background the work. |
Deploy hooks disabled | deploy_hooks.enabled is false. Master switch. |
No hooks configured for <domain> | No global hooks AND no entry under domain_hooks[<domain>]. |
Configuring via the API
The hook config is a single JSON document under deploy_hooks. To replace it:
# Replace config (full document write — pass the whole deploy_hooks dict)
curl -X POST https://certmate.local/api/deploy/config \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @hooks.json
The POST replaces the whole deploy_hooks block; merge
client-side if you want to preserve existing entries. The
conversational agent
surfaces this as the cert_deploy tool and the
/deploy slash command — but the API is the contract,
and it's stable.