Skip to main content

Configuration

Single YAML config file (/etc/certautopilot/config.yaml on standalone, rendered from the Helm config: block on Kubernetes), every value override-able via a CERTAUTOPILOT_* environment variable. Sensitive values — KEK material, JWT secret, MongoDB password, HSM PIN — live only in env, never on disk.

config.yaml — top-level keys

KeyPurpose
serverHTTP listener, TLS, trusted proxies, instance name.
databaseMongoDB connection (URI or host/port + credentials).
loggingzerolog level + format.
jwtJWT signer, access / refresh TTLs, issuer.
encryptionKEK provider (env / pkcs11) + PKCS#11 module config (key material is env-only).
telemetryOpenTelemetry tracing endpoint + Prometheus metrics toggle.
schedulerRenewal sweep cadence, leader-lock TTL.
workerMax concurrent discovery jobs.

Reading order: config.yaml → env overrides → defaults. Env wins.

server

server:
host: 0.0.0.0 # bind address
port: 8181 # listen port
mode: release # gin mode: release | debug
instance_name: "" # human-friendly identifier (Settings → Cluster)
trusted_proxies:
- 127.0.0.0/8
- ::1/128
secure_cookies: false # force Secure cookies when a proxy terminates TLS (see below)
tls:
enabled: false # backend-native TLS. Usually false; nginx/ingress terminates
cert_file: ""
key_file: ""

On standalone, the backend binds 127.0.0.1:18181 and nginx terminates TLS on 443. On Kubernetes, the chart binds 0.0.0.0:8181 behind a Service / Ingress.

instance_name drives the Settings → Cluster page and the leader-lock owner prefix. When empty, fallback chain is CERTAUTOPILOT_INSTANCE_NAME → POD_NAME → HOSTNAME → os.Hostname(). Values are sanitised to [A-Za-z0-9._-] and truncated to 63 chars; rewriting logs a warn at startup.

secure_cookies forces the Secure flag on session cookies. The backend infers it from tls.enabled, so in the common deployment where an nginx/ingress terminates TLS and speaks plain HTTP to the backend, session cookies would otherwise ship without Secure over what the browser sees as HTTPS. Set secure_cookies: true whenever TLS is terminated upstream.

trusted_proxies determines which connection sources can set X-Forwarded-For on behalf of the real client. Mis-configured trusted_proxies = spoofable client IPs = broken rate-limiting + audit log. Keep it tight — include only your nginx loopback or ingress controller pod CIDR.

database

database:
uri: "" # explicit URI overrides host/port/username/password
host: 127.0.0.1
port: 27017
name: certautopilot
username: certautopilot
password: "" # from env only — CERTAUTOPILOT_DATABASE_PASSWORD

If uri is set, it takes precedence and individual fields are ignored. Use uri for replica sets, TLS certificates, or DNS-SRV style mongodb+srv:// URLs.

MongoDB 6.0+ required

CertAutoPilot uses $expr / $switch inside update pipelines for atomic state transitions. MongoDB 6.0 or newer is required.

JWT & auth tuning

jwt:
secret: "" # from env only — CERTAUTOPILOT_JWT_SECRET
access_token_ttl: 15m
refresh_token_ttl: 168h # 7 days
issuer: certautopilot
audience: certautopilot

audit:
signing_key: "" # optional — CERTAUTOPILOT_AUDIT_SIGNING_KEY; defaults to jwt.secret

Don't push access_token_ttl above an hour; it defeats quick revocation. Long refresh_token_ttl is safe because of rotation + reuse-detection — see Auth & RBAC.

audit.signing_key is the HMAC key for the tamper-evident audit chain. When empty it falls back to jwt.secret, so a JWT-forger could also forge the chain; set a dedicated key (≥32 chars) to separate the two trust domains. Changing an already-set key invalidates verification of entries written under the old key — to decouple from the JWT secret without breaking the chain, first set audit.signing_key to your CURRENT jwt.secret, then rotate the JWT secret.

encryption

encryption:
# provider: env | pkcs11 — locked at install time, cannot change at runtime
provider: env
# current_version seeds the FIRST KEK version on a fresh install only.
# Once kek_versions has rows the keystore is authoritative and this is
# ignored — rotation does not update it, and it does not need bumping.
current_version: 1
# current_version_override pins THIS process to a specific version,
# bypassing the keystore. Only for disaster recovery.
# current_version_override: 1
# aad_binding turns on AES-GCM field binding on the WRITE side. Default false.
# Keep it OFF until every replica runs a binding-capable build, then enable it
# and run a KEK rotation. See Encryption → Field binding. env override:
# CERTAUTOPILOT_ENCRYPTION_AAD_BINDING
aad_binding: false

# Only relevant when provider == pkcs11:
pkcs11:
module_path: /usr/lib/softhsm/libsofthsm2.so
token_label: certautopilot-prod
# token_serial: "" # alternative token selector
# slot_number: # numeric slot; least portable (renumbers after re-init)
pin_env: CERTAUTOPILOT_ENCRYPTION_PKCS11_PIN
key_label_prefix: certautopilot-kek-v
max_sessions: 0 # 0 = library default
pool_wait_timeout: 0s # 0 = wait forever
use_gcm_iv_from_hsm: false # leave false — see pkcs11-vendors
Provider is install-locked

provider is written into a kek_install MongoDB document at install time and enforced on every startup. Switching between env and pkcs11 on an already-provisioned database is rejected.

Raw KEK material never lives in this file. It is supplied via per-version env vars (CERTAUTOPILOT_ENCRYPTION_ENV_KEK_V1_V{N}). For PKCS#11, the HSM PIN is pulled from the env var named in pkcs11.pin_env.

telemetry · scheduler · worker

telemetry:
tracing: # parsed but INERT — tracing is never initialised
enabled: false
endpoint: localhost:4317 # host:port; the exporter is OTLP/gRPC, plaintext, no scheme
sample_rate: 0.1 # 0.0 – 1.0
metrics:
enabled: true # INERT — /metrics is always served, unauthenticated

scheduler:
interval: 1h # how often the scheduler sweeps for work
leader_lock_ttl: 90s # distributed lock lifetime
heartbeat_interval: 30s # leader re-asserts the lock this often

worker:
max_concurrency: 4 # jobs each worker lane runs at once (default 4; 0 → serial)
max_concurrent_discovery: 50 # global cap on parallel discovery scan work (default 50)

Scheduler mode (serve --mode=scheduler or --mode=all) runs a MongoDB-backed distributed lock — only one replica is active at any time. See Observability for how the metrics + traces flow into Prometheus / OTLP.

Environment variable mapping

An env override needs the key to exist first

A CERTAUTOPILOT_* variable only takes effect for a key that is already present in the loaded config.yaml or that carries a built-in default — otherwise it is silently ignored, with no error. Among the encryption keys only provider, current_version and aad_binding have defaults; current_version_override and every pkcs11.* key must also appear in config.yaml (any placeholder value will do) for the env var to override them. CERTAUTOPILOT_ENCRYPTION_ENV_KEK_V{N} and CERTAUTOPILOT_ENCRYPTION_PKCS11_PIN are read straight from the process environment and are exempt.

The same trap applies outside encryption. Verified against a rendered standalone config.yaml, these are silently ignored as env vars because the key is neither defaulted nor present in the shipped template:

KeyWhy
scheduler.intervalabsent from the standalone template, no built-in default
worker.max_concurrent_discoverysame
server.secure_cookiessame — on a standalone install this can only be set by hand-editing config.yaml
server.trusted_proxieslist-valued; no env var can set it at all

Also note what happens when server.trusted_proxies is unset: the built-in fallback trusts all of 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, ::1/128 and fc00::/7 — not just loopback. The standalone template leaves the key unset, so a standalone install trusts every RFC1918 source by default. Set it explicitly if that is wider than you want.

Every YAML key has an env equivalent. Naming rules:

  • Prefix: CERTAUTOPILOT_.
  • Separator: underscore; nested keys are joined with _.
  • All uppercase.
  • Env wins over file.
YAML keyEnvironment variable
server.portCERTAUTOPILOT_SERVER_PORT
database.uriCERTAUTOPILOT_DATABASE_URI
logging.levelCERTAUTOPILOT_LOGGING_LEVEL
jwt.access_token_ttlCERTAUTOPILOT_JWT_ACCESS_TOKEN_TTL
scheduler.intervalCERTAUTOPILOT_SCHEDULER_INTERVAL
encryption.pkcs11.module_pathCERTAUTOPILOT_ENCRYPTION_PKCS11_MODULE_PATH

Secrets — env-only, never on disk

These must never land in config.yaml, Helm values, or shell history

The values below are loaded by systemd via EnvironmentFile= on standalone, or from a Kubernetes Secret on Helm. Anything pasted into a terminal with history enabled should be rotated.

VariablePurposeShape
CERTAUTOPILOT_JWT_SECRETHMAC key for access + refresh JWTs.≥ 32 bytes entropy. openssl rand -base64 48.
CERTAUTOPILOT_DATABASE_PASSWORDMongoDB app-user password (when uri is not used).Any string.
CERTAUTOPILOT_DATABASE_URIFull MongoDB connection URI (carries credentials).mongodb://user:pass@host/db or mongodb+srv://…
CERTAUTOPILOT_ENCRYPTION_ENV_KEK_V{N}Raw KEK material, one per version (env provider).64 hex chars = 32 bytes. V1 required; add V2 before rotation.
CERTAUTOPILOT_ENCRYPTION_CURRENT_VERSIONFresh-install seed for the first KEK version.Integer. Only consulted when kek_versions is empty (first install).
CERTAUTOPILOT_ENCRYPTION_PKCS11_PINHSM user PIN (only when provider = pkcs11).String. CloudHSM uses user:password; other vendors just the PIN.

TLS — standalone (nginx)

The standalone installer provisions nginx as the TLS terminator. The backend binds 127.0.0.1:18181 plain HTTP; only nginx talks to it.

curl -fsSL https://raw.githubusercontent.com/CloudNativeWorks/certautopilot-archive/main/get.sh \
| sudo bash -s -- --version=1.4.0 --mongo=local \
--tls=self-signed \
--bind-host=0.0.0.0 \
--port=443 \
--extra-hostnames=cap.example.com,cap-admin.example.com
  • --tls=self-signed — installer generates a 10-year cert with SANs for localhost, 127.0.0.1, ::1, --bind-host, and every --extra-hostnames entry.
  • --tls=provided --cert=<path> --key=<path> — use your own cert. Rerun the bootstrap to swap TLS material; other state is preserved.
  • nginx config lands at /etc/nginx/conf.d/certautopilot.conf; TLS material at /etc/certautopilot/tls/.

TLS — Kubernetes (ingress)

The Helm chart exposes a NodePort Service by default. Front it with whatever ingress you use.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: certautopilot
namespace: cap
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "16m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts: [ "cap.example.com" ]
secretName: cap-tls
rules:
- host: cap.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: certautopilot
port: { number: 8181 }

Add the cluster's pod CIDR to server.trusted_proxies so the backend trusts the ingress controller's X-Forwarded-For:

# values.yaml
config:
server:
trustedProxies:
- 127.0.0.0/8
- ::1/128
- 10.244.0.0/16 # cluster pod CIDR

Bootstrapping the first cert

Chicken-and-egg: CertAutoPilot issues certs, but it itself needs one to accept browser logins. Two patterns:

  • External issuance for CertAutoPilot only. Issue the CertAutoPilot-fronting cert via cert-manager + Let's Encrypt (K8s) or one-off certbot (standalone). Once CertAutoPilot is up, have it issue the replacement on the next rotation.
  • Self-signed forever. Use --tls=self-signed and distribute the root via your MDM / config management. Common in air-gapped deployments.

Health endpoints

  • /healthz — liveness. Returns 200 once the HTTP server is up.
  • /readyz — readiness. Pings MongoDB; 503 when the ping fails. It does not verify the KEK registry.
  • Both are unauthenticated. Don't expose them to the public internet through the ingress — use a server.trusted_proxies-restricted ACL or a basic-auth wrapper.

License

CertAutoPilot uses Ed25519-signed licenses verified offline against a public key baked into the binary at build time (internal/license.PublicKey). Licenses can also be activated online against the CNW License API (https://license-api.cloudnativeworks.com); both modes coexist.

Tiers & cert limits

TierCert limit
free (no valid license)1
enterprise (valid license)from the license features["cert_limit"] (0 = unlimited)

Tiers are defined in internal/license/features.go. Every feature ships in both tiers — LDAP, OTP policy, Syslog, ACME, MSCA, discovery, distribution modules, KEK rotation, approvals, PKCS#11 HSM. The only thing a license changes is the certificate cap: no valid license → Free (cap 1); any valid license → Enterprise with the cap the license carries in its features map (an Enterprise license with no cert_limit is unlimited).

Activate: Settings → License (org owner role) → paste your license key. The backend verifies signature + expiry, registers fingerprint + activation ID with the License API (when an API key is configured), and caches the validated state in MongoDB. Air-gap deployments use the offline path — same key, no network round-trip.

Enforcement

  • Cert-count limit: issuance blocks once active certs reach the tier's cap. Renewals still run on existing certs even at the cap — existing infrastructure is protected. This is the only license-enforced limit; there are no feature gates.

Expiry & grace

When the license exp passes, the backend enters a 7-day grace window (license.GracePeriodDuration = 7 * 24h). During grace, the Enterprise certificate cap stays in effect and the API responds normally; the UI surfaces an expired-banner. After grace, the tier reverts to Free (cap 1) and an admin must upload a renewed license to lift the cap again. Cert renewals always continue regardless of license state — production infra is never broken by an expired license. (All features remain available in every state; only the certificate cap changes.)

Renewals NEVER stop during grace

Letting prod infrastructure break because a license date rolled past the weekend is the opposite of what this product is for. Existing cert lifecycle runs; only the certificate cap tightens after the grace window (all features stay available).

License status endpoint

GET /api/v1/license/status returns the cached license view (LicenseStatusView in internal/service/license_service.go):

{
"valid": true,
"plan": "enterprise",
"plan_name": "Enterprise",
"cert_limit": 0,
"expires_at": "2027-04-20T00:00:00Z",
"in_grace_period": false,
"grace_remaining_seconds": 0,
"license_key": "...",
"fingerprint": "...",
"activation_id": "...",
"activated_at": "2026-04-20T09:00:00Z",
"last_checked_at": "2026-04-28T12:00:00Z",
"api_key_configured": true,
"license_mode": "online"
}

Troubleshooting

"License signature invalid" or "expired" right after upload

Either a corrupted paste (stray whitespace / line breaks) or system-clock drift — the backend refuses tokens > 5 minutes in the past. Check NTP on the host.

"License requires feature X, not supported by this build"

You upgraded across a major feature boundary. Get a license issued against the new build's public key.

Almost always a SameSite mismatch or non-HTTPS dev. Secure cookies are refused over HTTP; behind a reverse proxy, set server.trusted_proxies so the backend correctly identifies origin.

Rate-limit bypassed / audit log shows wrong IP

trusted_proxies too permissive. Restrict to the exact loopback / pod CIDR your fronting proxy uses.

See also