8 min read Grounded in docs/deploy-hooks.md What a hook is + Recipes

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:

FieldTypeRequiredNotes
idstringyesStable identifier. The UI auto-generates a UUID. Used by /api/deploy/test/<id>.
namestringyesHuman label shown in the UI and audit log.
commandstringyesA single shell command run via sh -c. Max 1024 chars.
enabledbooleannoDefault true. Disabled hooks skip automatic firing but can still be tested manually.
timeoutintegernoSeconds. Default 30, capped at the system max (300).
on_eventsstring[]noSubset 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 for api.example.com to 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.

VariableExample
CERTMATE_DOMAINapi.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_EVENTcreated / renewed / revoked / manual
CERTMATE_DRY_RUNSet 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 simple cat-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:

MessageCause
timeout after 30sHook ran longer than its timeout. Bump it (max 300s) or background the work.
Deploy hooks disableddeploy_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.