Skip to content

Configuration reference ​

Every setting for every process, and what happens when it is wrong.


Core ​

The core reads one YAML file, some flags, and three environment variables. Start from config.example.yaml.

Values of the form ${VAR} are expanded from the environment when the file is loaded, so a committed configuration never has to contain a secret.

Flags ​

FlagDefault
--configconfig.dev.yamlPath to the YAML file. Missing file → development defaults with a warning
--db—PostgreSQL connection string. Overrides CERTPILOT_DB_URL
--migratefalseApply migrations and exit
--migrationsmigrationsDirectory to read migrations from
--generate-kekfalsePrint a new base64 KEK and exit
--generate-dev-certs—Write development mTLS material to the given directory and exit

Migrations are a separate invocation rather than something the server does at startup, so a schema change is something an operator runs rather than a side effect of a deploy restarting a replica.

Environment ​

VariableRequired
CERTPILOT_KEKyes, with a database, unless secrets.kek_provider says otherwiseBase64 32-byte key encrypting private keys and CA credentials at rest
CERTPILOT_KEK_RETIREDnoComma-separated previous KEKs, still able to decrypt. See KEK rotation
CERTPILOT_DB_URLnoConnection string. DATABASE_URL is a fallback
VAULT_TOKENnoRead only by secrets.kek_provider: vault and only when no token_file is set. For development; a token file can be rotated under a running process

Where the KEK comes from ​

CERTPILOT_KEK is the default and not the only option. An environment variable is readable through /proc/<pid>/environ by anything running as the same user, inherited by every child process, present in core dumps and docker inspect, and tends to end up committed in an orchestrator manifest.

yaml
secrets:
  # "env" (default), "file", or "vault"
  kek_provider: "file"
  kek_file: "/run/secrets/certpilot_kek"
  kek_retired_files:
    - "/run/secrets/certpilot_kek.previous"
yaml
secrets:
  kek_provider: "vault"
  vault:
    address: "https://vault.internal:8200"
    path: "secret/data/certpilot/kek"   # KV v1 paths work too
    field: "kek"                        # default
    retired_field: "retired"            # default
    token_file: "/var/run/secrets/vault-token"
    ca_cert: "/etc/ssl/vault-ca.pem"    # optional; system roots otherwise

Moving between providers is a configuration change, not a migration: the same key from a different source opens existing ciphertext unchanged. A key file writable by group or other is refused — anybody who can write it can replace the key. A configured file or vault provider that returns nothing is fatal rather than falling back, so a misconfiguration cannot hide behind a successful start-up.

With no connection string the core uses the in-memory store, seeded with sample data, discarded on restart.

CERTPILOT_KEK is not recoverable. Every stored private key and every CA credential is encrypted with it. Lose it and they are gone — not inconvenient, gone. Put it in a secret manager before the first certificate is issued.

server ​

yaml
server:
  host: "127.0.0.1"
  port: 8080
  mode: "development"        # development | production
  allowed_origins:
    - "http://localhost:3000"
KeyDefault
host0.0.0.0Bind address. The shipped example sets 127.0.0.1; the code default if the key is absent is 0.0.0.0
port8080HTTP port
modedevelopmentSee production mode
allowed_origins—Browser origins permitted to call the API. Must name the frontend explicitly — a wildcard is not accepted alongside credentials

auth ​

yaml
auth:
  jwks_url: "${CERTPILOT_JWKS_URL}"
  issuer: "${CERTPILOT_JWT_ISSUER}"
  audience: "authenticated"
  role_claim: "certpilot_role"
  bootstrap_admins: ["you@example.com"]
KeyDefault
jwks_url—The identity provider's JSON Web Key Set endpoint. Preferred
issuer—Validated when set
audience—Validated when set
jwt_secret—Legacy HS256 shared secret. See below
role_claimcertpilot_roleClaim inside app_metadata carrying the role. Read only when no user directory is configured
bootstrap_admins—Accounts granted admin on first start, and never maintained afterwards
allow_anonymous—Removed. Setting it refuses to start

Two verification paths exist and they are not equivalent. With jwks_url the core verifies asymmetric signatures against published public keys and holds nothing capable of minting a token. With jwt_secret it holds a key that can forge an admin token. Use jwks_url wherever the provider supports it; the shared-secret path is kept for providers that cannot issue asymmetric signing keys.

allow_anonymous no longer exists and is refused in every mode, not only in production. It used to treat an unauthenticated request as admin, gated to development on a loopback address. The gate held; the feature was wrong anyway, because it meant every local session ran as an unnamed superuser — which made the authorisation paths the least exercised code in the system and attributed every audit entry to a subject nobody could be asked about.

The identity provider says who you are; CertPilot says what you may do. Roles live in CertPilot's own users table, keyed on (issuer, subject), and role_claim is consulted only when no directory is wired in. A claim in a token cannot promote anybody: a sign-in never writes a role, which is why bootstrap_admins exists to grant the first one. GET /me is the only honest source of a role.

plugins ​

yaml
plugins:
  discovery_mode: "static"
  tls:
    cert_file: ".certpilot/pki/core.pem"
    key_file: ".certpilot/pki/core-key.pem"
    ca_file: ".certpilot/pki/ca.pem"
    insecure: false
  gateways:
    - name: "letsencrypt-staging"
      addr: "localhost:9092"
      type: "acme"
      server_name: "localhost"
KeyDefault
discovery_modestaticOnly static is implemented — gateways are the list below
tls.cert_file—The core's client certificate for the gateway channel
tls.key_file—Its key
tls.ca_file—CA bundle the gateway's certificate is verified against
tls.insecurefalseDisable TLS on the gateway channel. Refused in production mode
gateways[].name—Registration name. A CA account is matched to a gateway by this first, then by type
gateways[].addr—host:port
gateways[].type—acme, vault, selfsigned
gateways[].server_namehost part of addrName expected in the gateway's certificate. Set it when dialing by IP or through a service alias

make dev-certs writes usable development material into .certpilot/pki/.

pki ​

yaml
pki:
  ca_health_check_interval: 360   # minutes
  crl_ocsp_check_interval: 60     # minutes
KeyDefault
ca_health_check_interval360 (6 h)How often every CA is re-parsed, CRL and OCSP checked, expiry thresholds evaluated
crl_ocsp_check_interval60CRL freshness and OCSP responsiveness cadence

The issuer importer runs on a fixed 12-hour interval and is not configurable: a mount's issuers change when somebody rotates a CA, which happens a few times a decade.

renewal ​

yaml
renewal:
  scan_interval: 60               # minutes
  default_lead_days: 30
  retry_backoff: [60, 240, 720, 1440]   # minutes
KeyDefault
scan_interval60How often to look for certificates inside their lead window
default_lead_days30Days before expiry to renew, when a certificate does not set its own
retry_backoff[60, 240, 720, 1440]Minutes between renewal attempts, per attempt

On lead days: the CA/Browser Forum schedule takes maximum validity to 200 days (March 2026), 100 days (March 2027) and 47 days (March 2029). A fixed 30-day lead stops making sense well before the end of that. Where a CA publishes RFC 9773 renewal information, the ACME gateway reports the window the CA suggests, which is both better timed and what lets a CA drain a mass-revocation event gradually instead of every client renewing at once.

logging ​

yaml
logging:
  level: "info"     # debug | info | warn | error
  format: "text"    # text | json

Gateways ​

All three gateways share the transport flags:

FlagDefault
--portper gatewaygRPC port
--tls-cert—This gateway's certificate
--tls-key—Its key
--tls-ca—CA bundle the core's certificate is verified against
--insecurefalseServe without TLS. Development only, and refused by the core in production mode
--grpc-reflectionfalseEnable reflection, for grpcurl
--log-levelinfodebug, info, warn, error

ACME — port 9092 ​

FlagDefault
--directoryletsencrypt-stagingDefault ACME directory, or an alias: letsencrypt, letsencrypt-staging, zerossl, buypass, google
--state-dir—Where ACME account keys are persisted. Strongly recommended — without it, every restart registers a new ACME account
--http01-addr:80Bind address for the http-01 challenge listener

Per-account settings — directory URL, contact email, EAB credentials, challenge type, DNS provider — live in the CA account's config, not here. See api-reference.md.

Vault — port 9093 ​

FlagDefault
--address$VAULT_ADDRDefault Vault address for accounts that do not name one, and the only address HealthCheck can probe
--namespace$VAULT_NAMESPACEDefault Vault Enterprise namespace

No Vault token is read from the environment. A gateway that picked up VAULT_TOKEN would issue under an ambient credential no CA account names, and nothing in CertPilot would record which identity signed. Every account carries its own. See gateways/vault.md.

Self-signed — port 9091 ​

No gateway-specific flags. Development and testing only.


Agent ​

certpilot-agent enrol   --server URL --token TOKEN [--name] [--interval] [--path]
certpilot-agent run     [--state-dir] [--installs] [--once]
certpilot-agent status  [--state-dir]
certpilot-agent scan    [--path]... [--json]
certpilot-agent request --name HOST [--key-type] [--key-size]
certpilot-agent install [--installs] [--force] [--offline]
FlagDefault
--server—Core base URL, e.g. https://certpilot.internal:8080
--token—An enrolment token, issued by an operator and single-use
--namehostnameWhat to call this host in CertPilot
--state-dirplatform defaultWhere the agent's identity and certificates live
--interval5mHow often this agent reports
--pathplatform defaultsA directory to scan for certificates. Repeatable
--installs/etc/certpilot/installs.jsonWhere install destinations are declared
--oncefalseOne cycle — heartbeat, renew, install, inventory — then exit
--jsonfalsePrint a scan report exactly as it would be sent
--forcefalseRewrite and reload even when the files already match
--offlinefalseDo the local work and report nothing to the core
--key-typeECDSAECDSA, RSA, Ed25519
--key-size256 / 2048Defaults by key type

CERTPILOT_AGENT_STATE overrides the state directory.

install --offline is deliberate: putting a certificate this machine already holds where its own server reads it needs permission from nobody, so it works with a revoked credential or an unreachable core. See agent.md.


Production mode ​

Setting server.mode: production refuses four things that are convenient locally and dangerous deployed:

RefusedWhy
Neither auth.jwks_url nor auth.jwt_secretNothing would verify anything
plugins.tls.insecure: trueThe gateway channel carries CSRs, private keys and CA credentials
"*" in server.allowed_originsA wildcard with credentials is not a CORS configuration, it is an open door
No database connection stringAn in-memory store would come up healthy and lose every certificate it issued

The core refuses to start, rather than warning. A warning in a log nobody reads is the same as no check at all. The first three are checked at config load (pkg/config/config.go Validate); the last when the store is built.

One check applies in every mode rather than only in production: auth.allow_anonymous is refused outright. There is no configuration in which CertPilot serves an unauthenticated caller.

Production mode also requires CERTPILOT_KEK whenever a database is configured. See operations.md and security.md.

What is not refused ​

"*" in allowed_origins outside production mode does not error — but it does not work either. The CORS middleware matches origins exactly, so a wildcard matches nothing and every browser request is simply refused by the browser. That combination — wildcard origin plus credentials — is rejected by the Fetch specification in any case; the previous implementation set it and looked permissive while working nowhere.