Cockpit Configuration
The Cockpit is configured entirely through environment variables, read once
at startup (a .env file is supported for local use). On-premise, these
variables are resolved from OpenBao via the External Secrets Operator and
injected into the pod as environment variables — never baked into the image and
never committed to Git.
All values shown here are placeholders. Secrets (database DSNs, signing and encryption keys, client secrets, tokens) must never be hardcoded or committed to Git — store them in your secret engine and inject them at deploy. See Secrets sourcing and Secret Manager Integration.
Secrets sourcing
On-premise, the environment-specific configuration is held in OpenBao and materialized at deployment, so secret values live only in your secret engine.
OpenBao holds the environment values; the External Secrets Operator materializes them into an in-memory Kubernetes Secret that the Cockpit pod consumes as environment variables.
- The complete, environment-specific variable set (with real secret values) is stored in OpenBao.
- At deployment, the External Secrets Operator fetches it and materializes an
in-memory (
tmpfs) Kubernetes Secret. - That Secret is exposed to the Cockpit pod as environment variables
(
envFrom), not as a mounted file. - To change configuration: update the value in OpenBao and redeploy — nothing is edited inside the image or stored in Git.
If your platform standardizes on ArgoCD for GitOps and a HashiCorp
Vault (or OpenBao) as the secret store, see
GitOps Secret Delivery for the
manifest-by-manifest version of this exact flow — the SecretStore/
ExternalSecret objects your Git repository holds, and the Vault-side
auth/policy setup.
Alternative: the Cockpit fetches its own boot secrets
Instead of (or alongside) ESO materializing a Secret, the Cockpit binary can
fetch its own bootstrap secrets (JWT_SECRET, ENCRYPTION_KEY, HMAC_KEY,
DATABASE_URL, Graph app credentials, …) directly from a backend at process
start, before AppConfig::from_env() runs, and inject them into its own
environment. This is controlled by DKE_BOOT_SECRETS_BACKEND:
| Variable | Default | Purpose |
|---|---|---|
DKE_BOOT_SECRETS_BACKEND | env | env (no-op, read from the injected environment as today) | vault / openbao | gcp |
DKE_BOOT_SECRETS_ADDR | — | OpenBao/Vault base URL (required for vault/openbao) |
DKE_BOOT_SECRETS_TOKEN | — | Static Vault token. Provide this or the AppRole pair below |
DKE_BOOT_SECRETS_ROLE_ID / _SECRET_ID | — | AppRole login credentials (alternative to a static token) |
DKE_BOOT_SECRETS_APPROLE_MOUNT | approle | AppRole auth mount path |
DKE_BOOT_SECRETS_MOUNT | secret | KV-v2 mount holding the boot secret |
DKE_BOOT_SECRETS_PATH | dke/boot | KV-v2 path; all string values under it are injected as env vars |
DKE_BOOT_SECRETS_NAMESPACE | (unset) | Vault Enterprise namespace header |
DKE_BOOT_SECRETS_TLS_SKIP_VERIFY | false | Dev only — disables TLS verification against the Vault address |
DKE_BOOT_SECRETS_GCP_PROJECT | — | GCP project (required for gcp) |
DKE_BOOT_SECRETS_GCP_SECRET | dke-boot | Secret Manager secret name |
DKE_BOOT_SECRETS_GCP_VERSION | latest | Secret version |
DKE_BOOT_SECRETS_GCP_SA_JSON | (unset) | Inline service-account JSON; falls back to ADC (Workload Identity) when unset |
When DKE_BOOT_SECRETS_BACKEND=vault, openbao, or gcp, a fetch failure
aborts startup — the process never silently falls back to reading secrets
from its own (possibly stale/insecure) environment. This is the same
fail-closed principle as the RUST_ENV=production secret gates below.
Most on-premise deployments use the ESO pattern above (DKE_BOOT_SECRETS_BACKEND
left at its env default) and don't need this section — it exists for
deployments that want the pod to talk to OpenBao directly instead of relying on
the External Secrets Operator sidecar.
Environment reference
When RUST_ENV=production (also accepts the prod alias, case-insensitively),
the server refuses to start if JWT_SECRET, ENCRYPTION_KEY, HMAC_KEY,
WEBAUTHN_RP_ID or WEBAUTHN_ALLOWED_ORIGINS are missing/weak, or if
PWNED_PASSWORDS_ENABLED=false. Generate strong random values and store them in
OpenBao — never commit them.
Server & TLS
| Variable | Default | Purpose |
|---|---|---|
HOST | 127.0.0.1 | Bind address (set 0.0.0.0 in a container) |
PORT | 3000 | HTTP port |
TLS_PORT | 3443 | HTTPS port (0 disables; dev certs auto-generated) |
TLS_CERT_PATH / TLS_KEY_PATH | (unset) | TLS certificate / key PEM paths |
KMIP_TLS_PORT | 5696 | KMIP (OASIS TTLV over TLS) listener; 0 disables |
PUBLIC_BASE_URL | https://{host}:{tls_port} | API/machine-facing origin (DKE service URLs, JWKS, ACME/EST) |
FRONTEND_BASE_URL | (derived) | SPA/browser-facing origin (OIDC redirect, email links) |
CORS_ORIGINS | http://localhost:5180 | Allowed CORS origins (comma-separated) |
COOKIE_DOMAIN | (auto-derived) | Explicit Domain= attribute for session cookies. Only needed when the SPA and API live on different hosts under a shared registrable parent (e.g. cockpit-dev.duokey.cloud + cockpit-api-dev.duokey.cloud → auto-derives duokey.cloud); set explicitly for multi-label public suffixes (co.uk-style) the heuristic can't detect |
Database & cache
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL | postgres://…/dke_cockpit | PostgreSQL DSN (sslmode=require auto-added for non-localhost) |
REDIS_URL | memory | memory (in-process) or redis://host:port — Redis is required when running more than one replica |
The Cockpit uses PostgreSQL as its only datastore. Database schema migrations run automatically at startup — the database role needs DDL rights on first boot; no separate migration job is required.
Secrets & crypto (required in production)
| Variable | Purpose |
|---|---|
JWT_SECRET | HS256 JWT signing key (≥ 64 chars) |
JWT_SECRET_KID / JWT_SECRETS_PREVIOUS | Current key id and historical keys (rotation) |
ENCRYPTION_KEY | AES-256 key (32-byte hex) for credentials/data at rest |
DKE_ENCRYPTION_KEY_KID | Key-encryption-key id tag stamped into newly-written credential blobs, so a future ENCRYPTION_KEY rotation can tell old blobs from new. Default k1 — most deployments never need to change it |
HMAC_KEY | HMAC-SHA256 key (32-byte hex) for audit-log integrity |
RUST_ENV | production (or prod) enables the strict fail-closed gates |
Authentication & passkeys
| Variable | Default | Purpose |
|---|---|---|
WEBAUTHN_RP_ID | localhost | WebAuthn relying-party id (prod: required, not localhost) |
WEBAUTHN_RP_NAME | DKE Cockpit | Display name in the passkey prompt |
WEBAUTHN_ALLOWED_ORIGINS | (dev list) | Allowed origins (prod: all https://, no localhost) |
PWNED_PASSWORDS_ENABLED | true | HIBP breached-password check (prod: false panics) |
DKE_ADMIN_PASSWORD | (unset) | Initial admin password on first boot; if unset, a strong random password is generated and printed once to the startup log (must be changed at first login) |
External identity providers (Entra ID, Okta, Keycloak, generic OIDC) are configured per tenant in the Cockpit console, not through environment variables — see Identity & SSO.
Captcha (optional bot protection)
| Variable | Default | Purpose |
|---|---|---|
RECAPTCHA_SECRET | (unset) | Google reCAPTCHA v3 server-side secret. Empty = reCAPTCHA disabled |
RECAPTCHA_MIN_SCORE | 0.5 | Minimum reCAPTCHA v3 score (0.0–1.0) accepted as human; Google recommends 0.5, raise for high-value flows |
HCAPTCHA_SECRET | (unset) | hCaptcha server-side secret. Empty = hCaptcha disabled |
DKE 365
| Variable | Purpose |
|---|---|
DKE_BASE_DOMAIN | DNS base for DKE service URLs — each service is https://{slug}.{DKE_BASE_DOMAIN} (needs wildcard DNS + TLS). This is also the JWT audience / Azure AD identifier-URI apex — there is no separate audience variable; a decoupled audience broke Word/MIP token acquisition and was removed |
DKE_DEFAULT_GRAPH_TENANT_ID / _CLIENT_ID / _CLIENT_SECRET | Fallback Microsoft Graph app to auto-provision Azure AD registrations, used when a service has no per-IdP Graph credentials of its own |
Rate limits & caching
Every cap below has a sane default and only needs overriding under unusual load; all fail open on a Redis outage so a cache blip cannot take the DKE decrypt path down.
| Variable | Default | Purpose |
|---|---|---|
DKE_TENANT_DECRYPT_RPS_MAX | 100/s | Per-tenant cap on /dke/*/decrypt |
DKE_TOKEN_DECRYPT_MAX_PER_MIN | 240/min | Per-access-token (jti) decrypt replay cap — catches a stolen token driven as an unattended decryption oracle |
DKE_GETKEY_MAX_PER_MIN | 600/min | Per-service cap on the unauthenticated public GetKey path |
DKE_GETKEY_PUBKEY_CACHE_TTL_SECS | 3600 | TTL of the cached GetKey public-key material (skips a vault/HSM round-trip on repeat calls) |
DKE_JWKS_CACHE_TTL_SECS | 3600 | TTL of the cached Azure AD JWKS used to validate DKE decrypt tokens; 0 disables caching |
DKE_JWT_LEEWAY_SECS | 60 | Clock-skew leeway applied to exp/nbf/iat checks |
OIDC_JWKS_TTL_SECS | 3600 | TTL of the cached JWKS for tenant-configured OIDC identity providers (floored at 60s) |
Agent-binary storage (S3 / GCS)
Controls how GET /api/pki/scanners/agent/download/{platform} serves the PQC
scanner-agent binaries. Unset (no bucket configured) → the handler streams the
binary from the pod's local filesystem instead. Public buckets are not
supported — only the pre-signed-URL mode is considered production-grade.
| Variable | Default | Purpose |
|---|---|---|
DKE_STORAGE_PROVIDER | aws | Active backend: aws (S3) or gcs |
DKE_SCANNER_AGENT_S3_BUCKET | (unset) | Private S3 bucket holding the agent binaries |
DKE_SCANNER_AGENT_S3_REGION | us-east-1 | AWS region for the signed-URL path |
DKE_SCANNER_AGENT_S3_PREFIX | (empty) | Key prefix inside the bucket, e.g. agents/v1.4.0 |
DKE_SCANNER_AGENT_S3_PRESIGN_TTL_SECS | 300 | Pre-signed URL TTL, clamped to [60, 3600] |
DKE_SCANNER_AGENT_S3_ACCESS_KEY_ID / _SECRET_ACCESS_KEY / _SESSION_TOKEN | (unset) | Explicit static credentials for the presign client — set these when the Cockpit runs outside AWS (e.g. Azure) so it never falls through to IMDS/IRSA |
DKE_SCANNER_AGENT_S3_ENDPOINT_URL | (unset) | S3-compatible endpoint override (e.g. MinIO); forces path-style addressing |
DKE_SCANNER_AGENT_GCS_BUCKET | (unset) | GCS bucket holding the agent binaries; required when DKE_STORAGE_PROVIDER=gcs |
DKE_SCANNER_AGENT_GCS_ENV | (unset) | Deployment segment prepended to the object key ({env}/{prefix}/{platform}/{filename}) |
DKE_SCANNER_AGENT_GCS_PREFIX | (empty) | Key prefix inside the GCS bucket |
DKE_SCANNER_AGENT_GCS_PRESIGN_TTL_SECS | 300 | V4 signed-URL TTL, clamped to [60, 604800] |
DKE_SCANNER_AGENT_GCS_SA_JSON | (unset) | Inline service-account JSON for local V4 signing; falls back to GOOGLE_APPLICATION_CREDENTIALS, then to keyless GKE Workload Identity signing |
DKE_SCANNER_AGENT_GCS_SIGNER_EMAIL | (auto) | Override the signing service-account email in Workload-Identity mode |
Billing (Stripe)
Empty (default) disables billing entirely — the Cockpit runs fine without a Stripe account.
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY | Stripe secret API key (sk_live_… / sk_test_…) |
STRIPE_WEBHOOK_SECRET | Stripe webhook signing secret (whsec_…) |
STRIPE_PUBLISHABLE_KEY | Publishable key sent to the frontend for Checkout.js |
BILLING_SELLER_NAME | Issuer name shown on invoices, overridable per-tenant from Settings; defaults to DKE Cockpit |
Observability
| Variable | Default | Purpose |
|---|---|---|
RUST_LOG | dke_api=info | Log level filter |
METRICS_ENABLED | false | Expose Prometheus metrics at GET /metrics |
METRICS_BEARER_TOKEN | (unset) | Bearer token protecting /metrics — required; in production, enabling metrics without a token disables them (fail-closed) |
Hardening
| Variable | Default | Purpose |
|---|---|---|
DKE_TRUSTED_PROXY_CIDRS | (empty) | Only these source CIDRs may set cf-connecting-ip / cf-ipcountry |
DKE_FAIL_CLOSED_AUDIT | prod=true | Return 503 when a DKE-decrypt / access-policy audit write fails |
DKE_FAIL_CLOSED_HTTP_AUDIT | false | Return 503 when the durable global HTTP audit queue is saturated. Opt-in everywhere (including production) — the global path fires on every request, so auto-escalating to 503 is an availability trade-off operators must consciously accept |
DKE_AUDIT_WORM_PATH | (unset) | Optional write-once (WORM) audit sink path |
DKE_GEOIP_MMDB_PATH | (unset) | Path to a local MaxMind GeoIP database for country-based access ACLs. Unset → all lookups return unknown, so on-prem/air-gapped deployments never silently fall back to a grant |
DKE_HSM_GATEWAY_URL | (derived from PUBLIC_BASE_URL) | Public wss:// URL that on-prem HSM proxy-agents dial to reach the Cockpit's agent gateway |
Dangerous switches — never enable in production
Every one of these defaults to the secure behaviour. They exist for local dev, CI fixtures, or air-gapped labs — flipping any of them on a production, internet-facing deployment removes a real security control.
| Variable | Default | Purpose |
|---|---|---|
DKE_ALLOW_PERMISSIVE_MODE | off | Lets DKE decrypt succeed for a service that has no Azure AD tenant/client configured, skipping JWT validation. Dev/test fixtures only |
OIDC_ALLOW_HMAC_ID_TOKENS | off | Accepts HS256-signed OIDC id-tokens (normally only RS256/ES256 from a JWKS are trusted) |
OIDC_ALLOW_UNVALIDATED_ISSUER | off | Skips issuer validation on tenant-configured OIDC providers |
OIDC_ALLOW_UNVERIFIED_EMAIL_LINK | off | Links an external identity to a local account without a verified email match |
EST_ALLOW_UNVERIFIED_ISSUANCE | off | Lets the EST endpoint issue certificates without verifying the enrollment proof |
CMP_ALLOW_UNVERIFIED_ISSUANCE | off | Same relaxation as above, for the CMP endpoint |
ACME_AUTO_VALIDATE | off | Auto-completes ACME challenge validation instead of performing the real HTTP-01/DNS-01 check |
DEV_INBOX_TOKEN | (unset) | Bearer token required for any non-loopback caller to read the dev email-capture inbox. Loopback callers are always allowed (local dev); with no token configured, every non-loopback caller is refused — this is a safety net, not something to routinely set in production |
Advanced / integration-specific
Most deployments don't need this section. It covers narrow integration paths: reverse-proxy-terminated mTLS passthrough, Kubernetes-federated service-account auth for secret delivery, machine API keys, and the built-in code/dependency scan policy.
| Variable | Default | Purpose |
|---|---|---|
MCP_KEY_REST_AUTH_ENABLED | true | Whether long-lived machine API keys (dke_mcp_…) are accepted on the standard REST surface, in addition to session JWTs — set false/0/off to force session-JWT-only REST auth (the /mcp and Kubernetes secret-delivery paths are unaffected) |
K8S_SA_JWT_AUTH_ENABLED | off | Master switch for accepting an in-cluster Kubernetes service-account JWT as a machine identity for secret delivery. All other K8S_* vars below are ignored unless this is set |
K8S_OIDC_ISSUER | — | Expected iss — the cluster's SA-token issuer URL |
K8S_OIDC_JWKS_URL | {issuer}/openid/v1/jwks | JWKS location, if not derivable from the issuer |
K8S_SA_EXPECTED_AUDIENCE | — | Required aud (e.g. duokey-cockpit) |
K8S_SA_TENANT_ID | — | Tenant every accepted token maps to (UUID) |
K8S_SA_MACHINE_USER_ID | — | User whose live permissions authorize the read (UUID) |
EKM_MTLS_CN_HEADER | (unset) | Header name carrying the reverse-proxy-verified mTLS client-certificate CN for the SQL EKM endpoint. Off unless set — a client-settable header must never be trusted directly, so the proxy must strip it from inbound requests |
AZURE_EKM_CLIENT_CERT_HEADER | x-client-cert | Header carrying the proxy-terminated client leaf certificate (PEM) for Azure EKM endpoints configured with require_mtls |
AZURE_EKM_CLIENT_CERT_VERIFY_HEADER | x-client-cert-verify | Optional proxy verdict header; if present it must read SUCCESS |
MCP_SCAN_TIMEOUT_SECS / MCP_MAX_CONCURRENT_SCANS / MCP_SCAN_STALE_SECS / MCP_SCAN_ALLOW_PRIVATE / MCP_SCAN_ROOT | (sane defaults) | Policy knobs for the MCP-triggered filesystem/dependency scan (timeout, concurrency, staleness, private-network access, scan root) |
SECUROSYS_TSB_URL / SECUROSYS_TSB_TOKEN | (unset) | When both are set, seeds a sandbox Securosys CloudHSM vault on first boot for zero-config dev/demo HSM access. Has no effect once the vault exists; production tenant HSM credentials are configured from the UI, not this |
DKE_REPO_ROOT | (auto-detected) | Override for locating the source tree used by the built-in ASVS code scanner, when the checkout isn't at the auto-detected workspace root |
Example environment (production-shaped)
# Environment
RUST_ENV=production
RUST_LOG=dke_api=info
# Database & cache
DATABASE_URL=postgres://user:pass@db-host:5432/dke_cockpit
REDIS_URL=redis://redis-host:6379
# Secrets (all required in production)
JWT_SECRET=<random string, >= 64 chars>
ENCRYPTION_KEY=<64 hex chars = 32 bytes>
HMAC_KEY=<64 hex chars = 32 bytes>
# Server / TLS
HOST=0.0.0.0
PORT=3000
TLS_PORT=3443
TLS_CERT_PATH=/etc/dke/tls/fullchain.pem
TLS_KEY_PATH=/etc/dke/tls/privkey.pem
CORS_ORIGINS=https://cockpit.example.com
PUBLIC_BASE_URL=https://cockpit-api.example.com
FRONTEND_BASE_URL=https://cockpit.example.com
# Passwords / passkeys
PWNED_PASSWORDS_ENABLED=true
WEBAUTHN_RP_ID=cockpit.example.com
WEBAUTHN_RP_NAME=DuoKey Cockpit
WEBAUTHN_ALLOWED_ORIGINS=https://cockpit.example.com
# DKE 365
DKE_BASE_DOMAIN=dke.example.com
# Observability
METRICS_ENABLED=true
METRICS_BEARER_TOKEN=<random token>
# Hardening
DKE_TRUSTED_PROXY_CIDRS=10.0.0.0/8,172.16.0.0/12
DKE_FAIL_CLOSED_AUDIT=true
Vault/HSM backends (Securosys, cloud KMS, software vault, MPC…) are stored per
tenant in the database with credentials encrypted under ENCRYPTION_KEY, and are
configured from the Cockpit UI — not through environment variables. The
SECUROSYS_TSB_URL / SECUROSYS_TSB_TOKEN pair above is the one exception, and
only seeds a sandbox vault for first-boot convenience.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Pod exits immediately in production | A required secret is missing or weak (JWT_SECRET, ENCRYPTION_KEY, HMAC_KEY, WEBAUTHN_*) | Check the startup log; generate strong values and re-inject via OpenBao |
| Database errors on first boot | The database role lacks DDL rights for the automatic migrations | Grant DDL on the dke_cockpit database for the first start |
| Login works on one replica only | REDIS_URL=memory with multiple replicas | Point REDIS_URL at a shared Redis |
/metrics returns nothing | Metrics enabled without METRICS_BEARER_TOKEN (fail-closed) | Set a bearer token and redeploy |
| Browser CORS errors | CORS_ORIGINS does not list the frontend origin | Add the exact https:// origin |
| Session dropped / CSRF 403 right after login, only when SPA and API are on different subdomains | The auth cookie is scoped host-only to the API origin, so the SPA never sees it | Set COOKIE_DOMAIN to the shared registrable parent (e.g. duokey.cloud), or rely on the auto-derivation if both hosts already share one |
| DKE decrypt / GetKey calls start returning 429 under normal load | One of the DKE_*_MAX_PER_MIN / DKE_TENANT_DECRYPT_RPS_MAX caps is too low for the tenant's real traffic | Raise the relevant cap; caps fail open on a Redis outage, so this is a deliberate limit, not an outage |
Pod fails to start with a Vault/OpenBao or GCP fetch error, even though DATABASE_URL/JWT_SECRET etc. are set directly in the pod env | DKE_BOOT_SECRETS_BACKEND is set to vault/openbao/gcp and that fetch failed — it never falls back to the pod's own environment | Fix the Vault/GCP connectivity or credentials, or unset DKE_BOOT_SECRETS_BACKEND (default env) to use the ESO-materialized Secret directly |
Continue to Secure → Secrets Management for the OpenBao/ESO setup, or back to Installation.