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
| Key | Purpose |
|---|---|
server | HTTP listener, TLS, trusted proxies, instance name. |
database | MongoDB connection (URI or host/port + credentials). |
logging | zerolog level + format. |
jwt | JWT signer, access / refresh TTLs, issuer. |
encryption | KEK provider (env / pkcs11) + PKCS#11 module config (key material is env-only). |
telemetry | OpenTelemetry tracing endpoint + Prometheus metrics toggle. |
scheduler | Renewal sweep cadence, leader-lock TTL. |
worker | Max 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.
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 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
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:
| Key | Why |
|---|---|
scheduler.interval | absent from the standalone template, no built-in default |
worker.max_concurrent_discovery | same |
server.secure_cookies | same — on a standalone install this can only be set by hand-editing config.yaml |
server.trusted_proxies | list-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 key | Environment variable |
|---|---|
server.port | CERTAUTOPILOT_SERVER_PORT |
database.uri | CERTAUTOPILOT_DATABASE_URI |
logging.level | CERTAUTOPILOT_LOGGING_LEVEL |
jwt.access_token_ttl | CERTAUTOPILOT_JWT_ACCESS_TOKEN_TTL |
scheduler.interval | CERTAUTOPILOT_SCHEDULER_INTERVAL |
encryption.pkcs11.module_path | CERTAUTOPILOT_ENCRYPTION_PKCS11_MODULE_PATH |
Secrets — env-only, never on disk
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.
| Variable | Purpose | Shape |
|---|---|---|
CERTAUTOPILOT_JWT_SECRET | HMAC key for access + refresh JWTs. | ≥ 32 bytes entropy. openssl rand -base64 48. |
CERTAUTOPILOT_DATABASE_PASSWORD | MongoDB app-user password (when uri is not used). | Any string. |
CERTAUTOPILOT_DATABASE_URI | Full 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_VERSION | Fresh-install seed for the first KEK version. | Integer. Only consulted when kek_versions is empty (first install). |
CERTAUTOPILOT_ENCRYPTION_PKCS11_PIN | HSM 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 forlocalhost,127.0.0.1,::1,--bind-host, and every--extra-hostnamesentry.--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-signedand 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
| Tier | Cert 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.)
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.
Cookies not being set on login
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.