Operations
Running CertPilot, and what to do when something goes wrong.
- First run
- Migrations
- Deploying
- Running more than one replica
- Rotating the KEK
- Backups and restore
- Upgrading
- Health and observability
- Routine tasks
First run
Four things, in order.
1. Generate a KEK
make generate-kekStore it somewhere durable before issuing anything. Every certificate private key and CA credential is encrypted with it, and there is no recovery path. An environment variable injected from a secret manager is the minimum; a .env file on one laptop is not a plan.
export CERTPILOT_KEK='...'2. Point at a database
export CERTPILOT_DB_URL='postgres://user:pass@host:5432/certpilot?sslmode=require'Any PostgreSQL 13 or later — the schema uses gen_random_uuid(), which is built in from 13. Tested against 17. You run the server; CertPilot does not provision one — see database.md for the pooler note, which matters.
Without a connection string the core runs on the in-memory store with sample data, which is the right way to spend ten minutes on it and the wrong way to run it.
3. Apply migrations
make migrate4. Generate the gateway channel material
make dev-certsThis writes development mTLS material into .certpilot/pki/. In production, issue the core and each gateway a certificate from your own internal CA and point --tls-ca at that CA. The development material is a convenience for getting the shape right, not something to deploy.
Migrations
Numbered SQL files in migrations/, applied in order, each recorded in schema_migrations with a checksum.
make migrate # uses CERTPILOT_DB_URL
make migrate DB='postgres://...' # explicit
docker compose -f deploy/docker-compose.yml --profile migrate run --rm migrateThree rules the migrator enforces or expects:
They are append-only. A file that has been applied is never edited. To change something, add a migration.
They are rerunnable. Every statement is IF NOT EXISTS, or guarded on the catalogue. PostgreSQL has no ADD CONSTRAINT IF NOT EXISTS, so constraints are wrapped in a do $$ … pg_constraint … $$ block. Migration 002 was not rerunnable for twenty-three migrations and nothing noticed until a conformance suite applied the whole schema twice.
A changed checksum is reported, not re-run. If a file changes after being applied, the migrator warns and moves on:
WARNING 002_crypto_agility.sql has changed since it was applied to this database.
It was not rerun. Reconcile the difference with a new migration.This warning is currently expected once on any database migrated before August 2026: migration 002 was fixed to be rerunnable, which changed its checksum. It is recorded as applied and will not re-run. See database.md.
The server never applies migrations itself. A schema change is something an operator runs, not a side effect of a replica restarting during a deploy.
Deploying
Docker Compose
cp deploy/compose.env.example deploy/.env # edit it
cp deploy/config.production.example.yaml deploy/config.production.yaml
make dev-certs # or your own CA material
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -dFour containers: core, ACME gateway, Vault gateway, frontend. The database is deliberately not in the compose file — CertPilot stores private keys, and running its store as an ephemeral container beside the app is how an evaluation becomes a deployment nobody meant to make.
The compose stack and Dockerfiles were repaired in this pass — they pinned Go 1.24 against a workspace requiring 1.26.6, so no image had built since the version bump, and the core had no
CERTPILOT_KEKso it could not have started against a database. They have not been verified by an actual build, because no container runtime was available on the machine where they were fixed. Treat the firstdocker compose buildas the test.
Systemd
The core is a single static binary with no runtime dependencies beyond CA certificates.
[Unit]
Description=CertPilot Core
After=network-online.target
[Service]
Type=simple
User=certpilot
Environment=CERTPILOT_KEK=...
Environment=CERTPILOT_DB_URL=...
ExecStart=/usr/local/bin/certpilot-core --config=/etc/certpilot/config.yaml
Restart=always
RestartSec=5
# The core needs to read its config and mTLS material and nothing else.
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ReadOnlyPaths=/etc/certpilot
[Install]
WantedBy=multi-user.targetPrefer EnvironmentFile= with 0600 permissions over inline Environment= for the KEK — the latter is visible in systemctl show.
Each gateway is the same shape on its own port.
Behind a reverse proxy
One thing matters and it is easy to get wrong: the SSE endpoint must not be buffered.
location /api/v1/events {
proxy_pass http://core:8080/api/v1/events;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
chunked_transfer_encoding off;
}Without proxy_buffering off, nginx holds the stream until a buffer fills. A dashboard that has stopped updating then looks merely quiet — which on a wall display is the worst available failure, because it reports "no problems" precisely when the tool has stopped being able to see problems. The shipped deploy/docker/nginx.conf has this block, and it must come before the generic /api/ one.
The core also sends X-Accel-Buffering: no on that route as a second line of defence.
Running more than one replica
Run as many as you like. There is no leader election and none is needed.
- Enqueues collide on a partial unique index with
ON CONFLICT DO NOTHING, so two schedulers noticing the same due certificate produce one job. - Claims use
SELECT … FOR UPDATE SKIP LOCKED, so two workers claiming produce one execution. - A worker that dies mid-job holds a lease that expires; another picks it up.
This is deliberate. A leader has a failover window during which nothing renews, and for a system whose purpose is that certificates do not expire, that is the wrong failure mode.
What is not shared between replicas: the SSE event broker is in-process, so a browser sees events from the replica it is connected to. Events are published on the replica doing the work, so an alert is raised once, by whichever replica ran the job — but a dashboard pinned to a different replica will not see that event until it refreshes its snapshot. Sticky sessions on /api/v1/events are worth configuring if you run several.
Rotating the KEK
Rotation changes which key seals new data, without downtime, because each envelope and each audit entry records the key that wrote it. It does not let you retire the old key: keep that for as long as you keep the audit log.
1. Make the new key primary and keep the old one as retired.
export CERTPILOT_KEK='<new key>'
export CERTPILOT_KEK_RETIRED='<old key>'Restart the core. From this point new writes are sealed under the new key, and everything sealed under the old one still reads.
2. Know what moves to the new key, and when. A record is re-sealed only when it is written again:
| Record | Re-sealed under the new key |
|---|---|
| Private keys of certificates CertPilot holds | When the certificate renews, which generates a new key |
| Deployment targets, notification channels, cloud connections | When updated with config sent again. An update without it keeps the stored ciphertext as it is |
| CA accounts | Never. There is no update route, so the only way is to recreate the account |
| Audit entries | Never. Each is signed once, by the key in use when it was written |
Nothing reports which records still depend on the retired key, and there is no bulk re-seal command.
3. Keep the retired key. Checking the audit chain needs every key that ever signed an entry. Drop one, and /api/v1/audit/verify reports the chain unverifiable from the first entry that key signed; anything still sealed under it becomes unreadable too. Measured on v0.2.1: after a rotation, dropping the retired key broke the chain at entry 1, private-key export answered 500, and issuing through a CA account created before the rotation failed to decrypt its configuration.
CERTPILOT_KEK_RETIRED accepts a comma-separated list, so more than one generation can be kept.
After a suspected compromise of the old key, rotation protects only what is written from then on. Everything still sealed under the old key, and every audit entry it signed, is as exposed as the key is: reissue the certificates CertPilot holds keys for, and replace the credentials in CA accounts, deployment targets, notification channels and cloud connections at their source.
Backups and restore
Back up the database. That is the whole of the state.
pg_dump "$CERTPILOT_DB_URL" --format=custom --file=certpilot-$(date +%F).dumpA backup without the KEK is not a backup. The dump contains ciphertext for every private key and CA credential. Store the KEK separately — a backup and the key to it in the same place is one compromise, not two.
Restore:
pg_restore --dbname="$CERTPILOT_DB_URL" --clean --if-exists certpilot-2026-08-22.dump
export CERTPILOT_KEK='<the key that sealed it>'Then check the estate reconciles: the CA health sweep and the renewal scheduler both run on startup, so a restored database converges without intervention. Restored with the key that sealed it, the database reads as it did: the audit chain checks, the private keys CertPilot holds still match their certificates, and renewal and issuance work.
Given the wrong key, the core refuses to start. It compares the key that signed the newest audit entry with the keys it was given, and stops before writing anything if none of them matches:
this database was sealed with key encryption key cb9cffa7e89e96e5, which signed
its newest audit entry (4), and this core was given key b09f5d25a3121a51. …Core v0.2.1 and earlier started anyway, and could read nothing they had sealed. The first sign-in then wrote an audit entry signed with the wrong key, which could never be checked once the right key was back, so the audit chain reported itself broken from then on.
If the key is lost for good, everything it sealed is lost with it: the private keys of certificates CertPilot holds, and the configuration of every CA account, deployment target, notification channel and cloud connection. Restart once with the lost key's identifier, as printed in the refusal:
export CERTPILOT_KEK='<a new key>'
export CERTPILOT_KEK_ABANDON=cb9cffa7e89e96e5The core starts, and writes a secrets.kek_abandoned audit entry, signed with the new key, naming the one given up. Remove the variable afterwards: the next start no longer needs it. Then recreate those accounts, targets, channels and connections, and reissue the certificates whose keys CertPilot held. Certificates held by agents are unaffected, because their keys never left the host. The audit chain stays readable, but entries signed with the lost key can no longer be checked, so /api/v1/audit/verify reports the chain from its first entry as unverifiable.
What to keep, and what can be reissued
| State | Where it lives | If it is lost |
|---|---|---|
| Certificates, CA accounts, templates, agents, the audit log | The database | Restore the backup |
| The KEK | Your secret manager, never beside the backup | Everything it sealed is lost; see above |
| ACME account keys | account_key_pem in the CA account's configuration, sealed in the database, if you set one; otherwise the ACME gateway's --state-dir | A new account is registered with the CA. One that requires External Account Binding needs EAB credentials again |
| Agent identities | Each agent's --state-dir on its own host | Re-enrol that host |
| The self-signed gateway's CA | Nowhere: it is in memory by design, and a restart issues from a new authority | Nothing to keep |
| Vault credentials | The CA account's configuration, sealed in the database | Restore the backup |
| Gateway mTLS material | Your internal CA | Reissue it |
Upgrading
git pull
make build
make migrate # if the release adds migrations
# restart the core, then the gatewaysOrder matters in one direction only: migrate before starting the new core. A core started on an older schema refuses, naming both versions:
this database's schema is at migration 039, and this core needs migration 043.
Run the migrations before starting it: certpilot-core --migrate, …Core v0.2.1 and earlier started anyway, reported themselves healthy, and renewed nothing until somebody read the log. A newer schema than the core needs is accepted, so rolling the binary back after a migration still starts. Gateways are independent and can be restarted whenever — the core reconnects and re-negotiates capabilities.
A migration run that stops part-way is finished by running it again: each file applies whole or not at all, and a file that applied but was never recorded applies again harmlessly.
Rolling back a schema change is not supported. Migrations are append-only and there are no down-migrations, because a down-migration that drops a column drops the data in it, and the moment somebody wants one is the moment that data matters. Roll forward.
The recovery drill
make test-recovery runs the procedures on this page against real containers. It upgrades a v0.1.1 quickstart to the core built from this tree, and on the way starts the new core before migrating, interrupts a migration, rolls the binary back, restores a backup, starts with the wrong key, and gives up a lost one. Each check names the sentence on this page it proves, and fails when that sentence changes. CI runs it weekly, and on changes to the migrations, the store, the server's startup or the key handling.
Health and observability
Endpoints
GET /healthz the core is up. Says nothing about internals
GET /api/v1/gateways per-gateway connection state and capabilities
POST /api/v1/ca-accounts/:id/health ask one CA account's gateway to check its CA
GET /api/v1/dashboard/stats counts, by status
GET /api/v1/events the live stream (SSE)/healthz is deliberately uninformative — it is a liveness probe, and a liveness probe that reports internals is an unauthenticated information leak.
What to watch
| Signal | Where | Means |
|---|---|---|
CAs in CRITICAL | GET /pki/authorities?status=CRITICAL | A CA is inside 30 days. Everything it signed dies with it |
Renewal jobs FAILED | GET /api/v1/renewals | A CA is refusing, or a challenge is failing |
Deployment jobs FAILED | GET /api/v1/deployments | A certificate renewed and did not reach the server |
verification_attempts climbing | certificate detail | The new certificate is not being served. The renewal is not done |
| Agents not reporting | GET /api/v1/agents | A host has stopped; its certificates will expire silently |
notification.failed in the audit log | GET /dashboard/activity?actions=notification.failed | Alerts are not reaching anyone |
The last one deserves attention. A monitoring system whose alerting is broken is worse than none, because it is trusted.
Logs
Structured via log/slog. logging.format: json for ingestion.
The request logger never writes the query string, because display tokens travel in it. If you add your own access logging at the proxy, do the same.
Routine tasks
Connect a CA
These examples carry
-b "$JAR", a cookie jar from signing in. There is no anonymous mode, so a command without a credential is a 401. See Authentication for the one-liner that fills it, or substitute-H "Authorization: Bearer $TOKEN".
curl -b "$JAR" -X POST localhost:8080/api/v1/ca-accounts -H 'Content-Type: application/json' -d '{
"name": "vault-issuing", "provider_type": "vault",
"gateway_addr": "gateway-vault:9093",
"config": {"address": "https://vault.internal:8200", "mount": "pki-int",
"role": "web", "auth_method": "approle",
"role_id": "...", "secret_id": "..."}}'The configuration is validated against the CA before it is stored, and the response carries warnings worth reading — see gateways/vault.md. The CAs behind the account are imported at the same time.
Import the CAs behind an account
curl -b "$JAR" -X POST localhost:8080/api/v1/pki/authorities/import
curl -b "$JAR" -X POST 'localhost:8080/api/v1/pki/authorities/import?account=vault-issuing'Runs automatically every 12 hours and on account creation. This is for the moment after somebody has rotated an issuer.
Force a renewal
curl -b "$JAR" -X POST localhost:8080/api/v1/certificates/$ID/renewEnqueues a job; the queue runs it. The response is the job, not the certificate.
Check what is actually being served
curl -b "$JAR" -X POST localhost:8080/api/v1/certificates/$ID/verifyOpens a TLS connection to the endpoint and compares the fingerprint being served against the one stored. This is the difference between "renewed" and "renewed and in use".
Revoke
curl -b "$JAR" -X POST localhost:8080/api/v1/certificates/$ID/revoke \
-H 'Content-Type: application/json' \
-d '{"reason": 1}'Admin only. The CA is told first, and the record changes only if the CA agreed. Every failure on this path — an unreachable gateway, a CA that declines, a certificate CertPilot holds no copy of — leaves the record exactly as it was and says so, because a row reading REVOKED beside a certificate that still answers handshakes is the one outcome worse than not revoking at all.
reason is required rather than defaulted. "Unspecified" is a legitimate answer, but it should be one somebody chose: the reason is what tells the next reader whether a key was compromised or a service was simply retired.
| Code | Reason |
|---|---|
0 | unspecified |
1 | keyCompromise |
3 | affiliationChanged |
4 | superseded |
5 | cessationOfOperation |
9 | privilegeWithdrawn |
Anything else is a 400 that lists these. RFC 5280 defines more codes; CertPilot accepts the subset a CA will act on.
What can refuse it:
| Response | Meaning |
|---|---|
400 | No reason, or one not in the table |
400 | CertPilot holds no copy of the certificate, so it cannot be shown to a CA — usually a discovered certificate. Revoke it at the issuing CA |
400 | Not bound to a CA account, so there is nowhere to send the revocation |
404 | No such certificate |
409 | Already revoked. The CA is not asked a second time |
502 | The gateway is not connected, or the CA declined. Nothing has been changed |
The one dangerous outcome is a 500 reading "the CA has revoked this certificate, but CertPilot could not record it". The certificate is genuinely dead; the record is wrong until the write succeeds. It is stated plainly rather than reported as a generic failure because the usual problem is the opposite way round.
Revocation is audited (certificate.revoked, naming the actor, reason and fingerprint) and published on the event stream as cert.revoked, so a wall display reflects it without waiting for a sweep.
Delete — not a substitute for revoking
DELETE /api/v1/certificates/:id deletes the record. It does not revoke.
It refuses with a 409 on a certificate that is still live, and names the revoke endpoint in the error. Expired and already-revoked certificates delete without argument: the first authenticates nothing, and for the second the CA has already been told.
?forget=true is the deliberate override, for a certificate you want CertPilot to stop tracking while it remains valid — an estate you no longer own, say. It is recorded in the audit log as the choice it is.
Take a CA out of the inventory
curl -b "$JAR" -X DELETE localhost:8080/api/v1/pki/authorities/$IDAdmin only. Note that a gateway-imported CA will come back on the next import sweep if it is still offered by its mount — which is usually what you want. To stop tracking it, remove the CA account.