Documentation

Everything you need to install, configure, and run CertDax.

Quick Start (Docker Compose)

The fastest way to get CertDax running is with Docker Compose. The repository ships a Makefile and a bootstrap.sh / bootstrap.ps1 helper, so first-time setup is one command.

Prerequisites: make is not installed by default on many systems. Install it first:
sudo apt install build-essential (Debian/Ubuntu)  ·  sudo dnf groupinstall "Development Tools" (Fedora/RHEL 8+)  ·  sudo yum groupinstall "Development Tools" (CentOS/RHEL 7)  ·  sudo pacman -Syu base-devel git (Arch)
If you prefer not to install make, run ./bootstrap.sh && docker compose up -d directly instead.
Terminal (Linux / macOS)
# 1. Clone the repo into /opt/certdax (matches the openbao backup/
#    restore docs and the suggested cron paths out of the box).
sudo git clone https://gitlab.certdax.com/certdax/certdax.git /opt/certdax
sudo chown -R "$USER":"$USER" /opt/certdax
cd /opt/certdax

# 2. One-shot: generate .env with random secrets and start the stack
#    (runs bootstrap.sh + docker compose up -d)
make up

# Other handy targets:
#   make logs     - tail container logs
#   make ps       - show running services
#   make restart  - restart the stack
#   make rebuild  - rebuild images and restart
#   make down     - stop the stack

# 3. Edit .env if you need to tweak ports, domain, or CORS
nano .env

# 4. Open your browser and create the first admin account

Windows users without make: run .\bootstrap.ps1 followed by docker compose up -d, or use the setup.cmd shortcut.

The bootstrap script generates random values for SECRET_KEY and DB_PASSWORD, then writes them to .env. It is idempotent — re-running it never overwrites an existing .env (use --force to regenerate, the previous file is backed up to .env.bak). The bundled OpenBao container generates its own master key + root token via the openbao-init one-shot service on first boot (Shamir 3-of-5, stored in the openbao-keys volume); there is no VAULT_DEV_ROOT_TOKEN or ENCRYPTION_KEY to manage.

Save your recovery file (do this once, right after first install)

OpenBao auto-unseals from the openbao-keys Docker volume. If this host’s disk is ever lost, the per-volume backups are useless without the unseal shares. Export them once and move the file off-host:

terminal
# Plain JSON (mode 0600) — move to your password manager afterwards
make recovery-export FILE=~/certdax-recovery.json

# Or stream straight into GPG so the plaintext never hits disk
./openbao/recovery.sh export - | gpg -e -r you@example.com \
    > certdax-recovery.json.gpg

To restore on a fresh host later, clone the repo and seed the volume from your saved file before bringing the stack up:

terminal
git clone <repo> /opt/certdax && cd /opt/certdax
make recovery-restore FILE=~/certdax-recovery.json
docker compose up -d
# openbao-init reads the shares from the file and unseals automatically

See openbao/README-PROD.md in the repo for backup cron, offsite replication, and optional manual-share hardening.

Custom Ports

By default the frontend binds 8081 (HTTP) and 10443 (HTTPS / mTLS) on the host. If those ports are taken (e.g. GitLab on 8080, mailcow on 8443), override them in .env:

.env
# Host port mappings for the frontend container
FRONTEND_HTTP_PORT=8090
FRONTEND_HTTPS_PORT=8453

# Keep the install bootstrap URL in sync — agents fetch the CA cert
# and signed binary from this URL during enrolment.
INSTALL_BOOTSTRAP_URL=http://localhost:8090
AGENT_BACKEND_URL=https://localhost:8453

Then run make restart (or docker compose up -d) to apply the new bindings.

Quick Start (Kubernetes / Helm)

Deploy CertDax to any Kubernetes cluster with a single Helm command. The chart includes the backend, frontend, a PostgreSQL database, and a built-in OpenBao (Vault-compatible) secrets engine that holds every long-lived secret — customer private keys, agent AppRole credentials, and the internal mTLS PKI. An Ingress resource is created automatically so your existing reverse proxy (Nginx, HAProxy, Apache, Traefik, etc.) can reach CertDax immediately.

Terminal
# 1. Add the Helm repo
helm repo add certdax https://charts.certdax.com
helm repo update

# 2. Install CertDax (built-in OpenBao bootstraps automatically)
#    SECRET_KEY and DB_PASSWORD are auto-generated and stored in
#    Kubernetes Secrets — stable across helm upgrade.
helm install certdax certdax/certdax \
  --namespace certdax --create-namespace \
  --set ingress.host=certdax.example.com \
  --set ingress.className=nginx

# 3. Open your browser and create the first admin account

Want to bring your own secrets? Pass --set certdax.secretKey=... and --set postgresql.auth.password=..., or reference an existing Kubernetes Secret with certdax.existingSecret / postgresql.auth.existingSecret.

Enable TLS

Add TLS to the Ingress (e.g. via cert-manager or the CertDax operator):

Terminal
helm install certdax certdax/certdax \
  --namespace certdax --create-namespace \
  --set ingress.host=certdax.example.com \
  --set ingress.className=nginx \
  --set ingress.tls.enabled=true \
  --set ingress.tls.secretName=certdax-tls

External Database

Use an existing PostgreSQL instance instead of the built-in one:

Terminal
helm install certdax certdax/certdax \
  --namespace certdax --create-namespace \
  --set postgresql.enabled=false \
  --set certdax.externalDatabaseUrl="postgresql://user:pass@db-host:5432/certdax" \
  --set ingress.host=certdax.example.com

Helm Values Reference

ValueDefaultDescription
certdax.secretKey""JWT signing key (auto-generated on first install if empty)
certdax.existingSecret""Existing K8s secret with secret-key
certdax.acmeContactEmailadmin@example.comACME contact email
certdax.externalDatabaseUrl""External database URL (when postgresql.enabled=false)
postgresql.enabledtrueDeploy built-in PostgreSQL
postgresql.auth.password""PostgreSQL password (required when enabled)
postgresql.auth.existingSecret""Existing secret with postgresql-password key
postgresql.storage.size5GiDatabase PVC size
postgresql.storage.storageClass""Storage class (empty = cluster default)
openbao.enabledtrueDeploy a built-in OpenBao instance (Vault-compatible)
openbao.replicaCount1OpenBao replicas. Set to 3+ together with storage.type=raft for HA
openbao.storage.typefilefile (single node, PVC) or raft (multi-replica HA)
openbao.storage.size4GiOpenBao PVC size
openbao.init.keyShares1Shamir unseal key shares (raise for production)
openbao.init.keyThreshold1Threshold of shares required to unseal
openbao.externalAddr""External Vault URL (required when openbao.enabled=false)
vault.authMethodkubernetesHow the backend authenticates to Vault (kubernetes, approle, token)
vault.k8sRolecertdax-backendVault Kubernetes-auth role name
vault.kvMountsecretKV v2 mount used for app secrets
vault.transitMounttransitTransit mount used to encrypt customer private keys
vault.pkiMountpki_internalInternal PKI mount used to issue mTLS certs to agents/operators
ingress.enabledtrueCreate an Ingress resource
ingress.className""Ingress class (nginx, haproxy, traefik, etc.)
ingress.hostcertdax.example.comHostname for the Ingress
ingress.tls.enabledfalseEnable TLS on the Ingress
ingress.tls.secretName""TLS secret name
backend.replicaCount1Backend replicas
frontend.replicaCount1Frontend replicas

Ingress controller compatibility: The chart creates a standard networking.k8s.io/v1 Ingress. This works with nginx-ingress, HAProxy Ingress, Apache APISIX, Traefik (as Ingress provider), and any other controller that implements the Ingress API. Set ingress.className to match your controller.

Behind a public reverse proxy (Let's Encrypt + agent mTLS)

When CertDax sits behind your own nginx/HAProxy/Apache that already terminates a Let's Encrypt certificate for the dashboard, agents still need to reach the mTLS endpoint with CertDax's internal CA — a public-CA proxy can never terminate mTLS for them, because client certificates are signed by your private CertDax CA. The chart ships an example values file for exactly this topology:

Terminal
helm install certdax certdax/certdax \
  -n certdax --create-namespace \
  -f helm/certdax/examples/values-reverse-proxy.yaml \
  --set ingress.host=certdax.example.com \
  --set certdax.secretKey="$(openssl rand -hex 32)" \
  --set postgresql.auth.password="$(openssl rand -hex 16)"

The file exposes three NodePorts — dashboard HTTP 30080, mTLS 30443, and mTLS + PROXY-protocol 30444 — and sets the env vars the agent install one-liner needs:

  • AGENT_BACKEND_URL=https://certdax.example.com:10443 — baked into config.yaml so agents reach the mTLS port at runtime.
  • INSTALL_BOOTSTRAP_URL=https://certdax.example.com — the install one-liner downloads the CA + binary from the LE-fronted port (PowerShell does not yet trust the internal CA at that point).
  • BEHIND_REVERSE_PROXY=true — serve the dashboard over plain HTTP instead of redirecting to :10443.
  • STREAM_PROXY_ENABLED=true + STREAM_PROXY_TRUSTED_IPS — activates the PROXY-protocol mTLS listener on container port 8444 so real client IPs survive the TLS pass-through.

Edit the file to point at your real public FQDN and reverse-proxy CIDR, then drop the sample nginx server / stream blocks at the bottom of the file into your reverse proxy. Your proxy then needs two server blocks: one HTTP server for the dashboard with Let's Encrypt forwarding to NodePort 30080, and one stream {} block for agent mTLS pass-through with proxy_protocol on forwarding to NodePort 30444.

High-availability install

For production, switch from single-replica everything to a 3-node OpenBao raft cluster with multi-replica CertDax backend & frontend. The chart ships examples/values-ha.yaml with sensible defaults:

Terminal
helm install certdax certdax/certdax \
  -n certdax --create-namespace \
  -f helm/certdax/examples/values-ha.yaml \
  --set ingress.host=certdax.example.com \
  --set certdax.secretKey="$(openssl rand -hex 32)" \
  --set postgresql.auth.password="$(openssl rand -hex 16)"

What you get:

  • OpenBao raft clusteropenbao.replicaCount=3 with storage.type=raft. The init Job bootstraps pod-0 as leader and joins the others automatically. Quorum survives a single node loss.
  • Auto-unseal sidecaropenbao.autoUnseal.enabled=true runs a tiny sidecar in every Vault pod that watches its local seal-status and replays unseal keys from the bootstrap Secret if the pod comes back sealed (after a crash, eviction, or node reboot). Without this, sealed pods would stay sealed until the next helm upgrade.
  • Shamir 3-of-5 keysinit.keyShares: 5 / init.keyThreshold: 3. Distribute the keys to 5 different operators; any 3 can together perform manual recovery. (For stricter security, leave the auto-unseal sidecar disabled and configure a real cloud-KMS auto-unseal seal stanza instead.)
  • PodDisruptionBudgetsmaxUnavailable: 1 for both OpenBao and backend, so a kubectl drain on one node won't take down quorum.
  • Anti-affinity — hard pod-anti-affinity on raft pods (one per node) plus zone-level topologySpreadConstraints. Requires at least 3 schedulable worker nodes; on smaller clusters the soft default rule is more permissive.
  • Backend & frontend HPA — both auto-scale on CPU. Backend 2–5 replicas, frontend 2–4.

Edit the file before applying to set your real storageClass for the raft + Postgres PVCs.

Internal ACME DNS resolver (Helm)

The chart ships an optional certdax-acme-server pod — a small Go service that acts as an authoritative DNS resolver and acme-dns-compatible HTTP API for ACME dns-01 challenges (RFC 0002). Enable it with:

Terminal
helm upgrade certdax certdax/certdax \
  -n certdax \
  --set acmeServer.enabled=true \
  --set acmeServer.resolverDomain=acme.certdax.internal \
  --set acmeServer.resolverIP=10.0.0.50

For production, enable mTLS so the acme-server authenticates to the backend with a Vault-issued client certificate (CN acme-server.certdax.internal) instead of only a shared bearer token.

The role_id and wrap_token are generated by CertDax using the same flow as for deploy agents and the Kubernetes operator. Open Settings → ACME Server in the CertDax dashboard and click Generate enrolment credentials. You will see two values:

  • role_id — A stable identifier for the acme-server’s Vault AppRole. It is not secret on its own but must be paired with a secret_id to authenticate.
  • wrap_token — A one-time-use, 10-minute Vault cubbyhole token that wraps the secret_id. The raw secret_id never leaves Vault — the acme-server unwraps it on first boot and the token is permanently consumed. If it expires before the pod starts, click Regenerate in the dashboard for a fresh pair.

Copy both values from the dashboard and paste them into the upgrade command:

Terminal
helm upgrade certdax certdax/certdax \
  -n certdax \
  --set acmeServer.enabled=true \
  --set acmeServer.resolverDomain=acme.certdax.internal \
  --set acmeServer.resolverIP=10.0.0.50 \
  --set acmeServer.vault.addr=https://certdax-openbao:8200 \
  --set acmeServer.vault.roleId=<role_id from dashboard> \
  --set acmeServer.vault.wrapToken=<wrap_token from dashboard> \
  --set acmeServer.vault.caChainPEM="$(kubectl get secret certdax-openbao-keys \
      -n certdax -o jsonpath='{.data.ca-chain}' | base64 -d)"

The caChainPEM is the internal CA that signed the backend’s TLS certificate. The command above reads it from the bootstrap Secret created during helm install. You only need to pass it on the very first enrolment; subsequent reboots reload the CA from the pod’s state PVC.

The resolver enrols against OpenBao on startup, caches the cert + key in a PVC (/var/lib/certdax-acme-server), and renews automatically at 50 % cert lifetime. The bearer token (acmeServer.serverToken) can be left empty once mTLS is working but is accepted as a fallback during migration.

For joohoi-mode (UUID-based acme-dns accounts, RFC 0003), also set acmeServer.acmednsAuthZone=auth.certdax.example.com. Operators then add a single permanent CNAME in their public DNS:

DNS
_acme-challenge.<fqdn>. CNAME <uuid>.auth.certdax.example.com.

CertDax handles every subsequent renewal automatically without delegating to an external acme-dns instance.

ACME resolver Helm values

ValueDefaultDescription
acmeServer.enabledfalseDeploy the acme-server pod
acmeServer.resolverDomainacme.certdax.internalAuthoritative zone for _acme-challenge.*
acmeServer.resolverIP""Reachable IP(s) of the pod (comma-separated v4+v6)
acmeServer.acmednsAuthZone""Public auth zone for joohoi-mode (RFC 0003); empty disables
acmeServer.serverToken""Shared bearer token (auto-generated if empty); optional when mTLS active
acmeServer.vault.addr""OpenBao URL — enables mTLS when set
acmeServer.vault.roleId""AppRole role_id
acmeServer.vault.wrapToken""One-time wrap token (cleared from env after first use)
acmeServer.vault.secretId""AppRole secret_id (alternative to wrapToken)
acmeServer.vault.pkiMountpki_internalPKI secrets engine mount
acmeServer.vault.pkiRoleacme-serverPKI role name
acmeServer.vault.pkiCertCNacme-server.certdax.internalCertificate CN verified by the backend
acmeServer.vault.certTTL24hCertificate lifetime (Go duration string)
acmeServer.vault.caChainPEM""Bootstrap CA chain PEM (first boot only; reloaded from disk thereafter)
acmeServer.persistence.size10MiPVC size for the mTLS state directory
acmeServer.persistence.storageClass""Storage class (empty = cluster default)

Environment Configuration

Copy .env.example to .env and configure the following variables:

VariableRequiredDefaultDescription
SECRET_KEYYesJWT signing key. Must be identical across all replicas
VAULT_ADDRYeshttp://openbao:8200OpenBao / Vault address. CertDax cannot start without it — there is no local-crypto fallback
VAULT_AUTH_METHODYestokenHow the backend logs in to Vault: token (dev), approle (Docker / VM) or kubernetes (K8s)
VAULT_TOKENIf token authStatic Vault token (development / testing only)
VAULT_ROLE_IDIf approleAppRole role-id, written by the bootstrap process
VAULT_SECRET_ID_FILEIf approlePath to a file containing the AppRole secret_id (preferred over env)
VAULT_K8S_ROLEIf kubernetescertdax-backendKubernetes-auth role name
VAULT_KV_MOUNTNosecretKV v2 mount used for app secrets
VAULT_TRANSIT_MOUNTNotransitTransit mount used to encrypt customer private keys
VAULT_PKI_MOUNTNopki_internalInternal PKI mount used to issue mTLS certs to agents and operators
VAULT_AGENT_ADDRNoVAULT_ADDRAddress agents/operators use to reach Vault. Set to the externally-reachable URL in split-network deployments
DB_PASSWORDDocker onlyPostgreSQL password (Docker Compose sets up the database automatically)
DATABASE_URLNosqlite:///./data/certdax.dbDatabase connection string. Use PostgreSQL for production
ACME_CONTACT_EMAILNoadmin@example.comContact email for ACME certificate requests
JWT_EXPIRY_MINUTESNo1440JWT token lifetime in minutes
RENEWAL_CHECK_HOURSNo12How often to check for certificates needing renewal
RENEWAL_THRESHOLD_DAYSNo30Default days before expiry to trigger auto-renewal
CORS_ORIGINSYesComma-separated list of allowed frontend origins
API_BASE_URLNoAuto-detectedPublic backend URL (used in agent install scripts)
FRONTEND_URLYesPublic frontend URL (used in password reset emails)
AGENT_BINARIES_DIRNoagent-distDirectory containing agent binaries
DEBUGNofalseEnable Swagger/OpenAPI docs at /docs and /redoc

OpenBao / Vault Integration

Starting with CertDax 2.1, every long-lived secret is stored in OpenBao — an open-source, Vault-compatible secrets engine. CertDax does not include a local crypto fallback: the backend will refuse to start until it can reach an unsealed Vault and bootstrap its mounts.

What CertDax stores in Vault

  • KV v2 (mount: secret) — application secrets, SMTP/OIDC settings, distributed-lock state and the bootstrap marker
  • Transit (mount: transit) — envelope encryption of customer private keys; key material never leaves Vault
  • PKI (mount: pki_internal) — the internal CA used to issue short-lived mTLS client certs to agents and Kubernetes operators (RFC 0001)

Agent & operator certificate rotation

Once enrolled, the deploy agent and the Kubernetes operator both rotate their own mTLS client certificate automatically at 50% of its lifetime (typically every ~36h for a 72h cert) by re-issuing from pki_internal/issue/{agent,operator}. The current cert + key live in the on-host state directory (/var/lib/certdax/ on Linux, C:\ProgramData\CertDax\state on Windows, an in-pod volume for the operator) — not in config.yaml.

The ca_chain_pem block embedded in config.yaml is the long-lived internal Root CA (10-year lifetime) used as the trust anchor for verifying the backend. It is static and only changes if you re-enroll the agent against a CertDax instance with a different internal CA.

Helm: built-in OpenBao

The CertDax Helm chart ships with a complete OpenBao deployment. On helm install the chart:

  1. Creates a StatefulSet with a PVC for storage
  2. Runs a post-install Job that initialises the cluster and writes the Shamir unseal keys + root token to a Kubernetes Secret (<release>-openbao-keys)
  3. Unseals every replica and joins followers to the raft quorum (when storage.type=raft)
  4. Runs a second Job that bootstraps the KV / Transit / PKI mounts, the Kubernetes auth method, and the role used by the backend
  5. Mounts the Vault address and auth role into the backend Deployment
Break-glass material: the unseal keys and root token live in secret/<release>-openbao-keys in the release namespace. PVC backups of openbao-data are useless on their own — without this Secret you cannot unseal a restored cluster. Export it the moment install completes and move the result off-cluster.

Save your recovery file (Helm)

Right after a successful helm install, dump the keys Secret and store it somewhere safe (password manager, GPG vault, hardware token):

Terminal
# Plain YAML — protect the resulting file (chmod 0600, off-host)
kubectl get secret certdax-openbao-keys -n certdax -o yaml \
    > certdax-recovery.yaml

# Or encrypt straight away so the plaintext never hits disk
kubectl get secret certdax-openbao-keys -n certdax -o yaml \
    | gpg -e -r you@example.com > certdax-recovery.yaml.gpg

To restore on a fresh cluster: first restore the openbao-data PVC from your backup tooling (Velero, restic, CSI clone, etcd snapshot, ...), then re-apply the Secret before running helm install:

Terminal
kubectl create namespace certdax
kubectl apply -f certdax-recovery.yaml -n certdax
helm install certdax certdax/certdax -n certdax ...

The init Job detects the already-initialised cluster, skips re-initialising, and the auto-unseal sidecar replays the shares from the restored Secret on every pod start. Restoring only the Secret without the PVC is not safe — OpenBao re-initialises against an empty data dir and the init Job overwrites your restored Secret with brand new keys.

HA example (Raft, 3 replicas)

Terminal
helm install certdax certdax/certdax \
  --namespace certdax --create-namespace \
  --set ingress.host=certdax.example.com \
  --set openbao.replicaCount=3 \
  --set openbao.storage.type=raft \
  --set openbao.storage.size=10Gi \
  --set openbao.init.keyShares=5 \
  --set openbao.init.keyThreshold=3

External Vault / OpenBao

Already running an OpenBao or HashiCorp Vault cluster? Disable the built-in instance and point CertDax at it:

Terminal
helm install certdax certdax/certdax \
  --namespace certdax --create-namespace \
  --set ingress.host=certdax.example.com \
  --set openbao.enabled=false \
  --set openbao.externalAddr=https://certdax.example.com/openbao \
  --set vault.authMethod=kubernetes \
  --set vault.k8sRole=certdax-backend

You'll have to provision the KV / Transit / PKI mounts and the Kubernetes-auth role yourself. The bootstrap module (python -m app.bootstrap.vault) is idempotent — running it once against your external Vault is the easiest path; it skips any mount that already exists.

Docker Compose

The shipped docker-compose.yml brings up a single-node OpenBao alongside CertDax. The bootstrap.sh / bootstrap.ps1 helper script initialises Vault, writes AppRole credentials to ./vault-bootstrap/, and wires the backend up to use them. Re-run it after a fresh docker compose down -v.

Terminal
docker compose up -d openbao
./bootstrap.sh                # Linux / macOS
# .\bootstrap.ps1             # Windows
docker compose up -d

Why this design is secure

The split between OpenBao (long-lived secret material) and the application database (operational metadata + bearer-token hashes) is intentional. Each storage class has a different threat model:

Type of secretWhere it livesWhat an attacker with read access gets
Customer / agent private keysOpenBao KV v2, encrypted with Transit envelope keysCiphertext only — raw key material is never written to disk in plaintext, even Vault operators with KV read can't decrypt without Transit policy
Internal mTLS client certs (agent / operator)Issued just-in-time from pki_internal, stored on the agent host onlyA 72-hour client cert at most; rotated automatically at 50% lifetime; backend will not honour an unknown serial
Vault root token / unseal keysKubernetes Secret <release>-openbao-keys after installFull Vault control — treat as break-glass material: copy offline, then rotate the root token and delete the Secret. Use cloud-KMS auto-unseal in production
API keys (cm_...)Application DB — SHA-256 hash only, raw key shown onceHashes are useless for authentication. Rotate the affected key in API Keys and lookups stop matching immediately
Install tokens (one-shot, 10 min)Signed by Vault Transit Ed25519 key install-tokens, never persistedVerification key lives in Vault — the backend cannot forge tokens, only request signatures. Stolen tokens expire fast and are single-use
Agent enrolment bundles (one-shot, 10 min)Vault cubbyhole wrap-tokenOne-time-use; unwrap consumes it. Late readers see "wrapping token already used"

Risk analysis

Threat scenarioMitigation
Application DB dump leaks (SQL injection, backup theft)Contains no plaintext private keys, no plaintext API keys, no Vault credentials. Only metadata + hashes + ciphertext references. Rotate any leaked API key, otherwise the dump is largely inert.
Backend host compromise (RCE in FastAPI process)Backend has only the AppRole secret-id and the Vault-issued tokens it currently holds. It can decrypt only what its policies allow. The Vault root token is not on the backend host. Rotate the AppRole and the affected Transit data-keys.
Single agent host compromiseAgent has its own short-lived mTLS cert + key only. No API key, no peer-agent credentials. Revoke the cert serial in Vault (pki_internal/revoke); other agents are unaffected.
API key leaks via shell history / CI logsUse Ansible Vault / GitLab CI masked variables for the key. Set short scopes. The API Keys page shows last_used_at + source IP — rotate the moment you see unexpected activity.
Vault host compromiseThis is the worst case — an attacker with file-system access to a sealed Vault still needs the unseal keys. With auto-unseal via cloud KMS, sealing on shutdown is the default. Limit access to the <release>-openbao-keys Secret with RBAC; copy keys offline; rotate root token after install.
Replay of a captured install tokenToken TTL is 10 minutes, single-use, and verified by Ed25519 signature against a Vault-only key. Even with the full token, a second use is rejected.
Man-in-the-middle on agent ↔ backendAgent pins the internal CA chain (ca_chain_pem embedded in config.yaml). The backend pins each agent's cert by serial. Both sides do mutual TLS — a TLS-terminating proxy that doesn't pass through client certs will fail to authenticate.
Cluster operator with KV-read but no Transit accessReads ciphertext blobs only. Without Transit decrypt policy the data is unrecoverable.

For deeper detail on the agent / operator enrolment flow, see RFC 0001 — OpenBao integration.

First Use

  1. Open the application and create the first admin account
  2. Go to Settings → configure SMTP for email notifications (optional)
  3. Go to Settings → configure OIDC/SSO for single sign-on (optional)
  4. Go to Providers and add a DNS provider (e.g. Cloudflare) and/or Certificate Authority
  5. Go to CertificatesNew certificate and request your first ACME certificate
  6. Go to Self-Signed to generate internal certificates or create a CA
  7. (Optional) Set up Agents and install the deploy agent on your servers — see Linux or Windows
  8. (Optional) Go to API to create API keys for scripting and automation

Development Setup

Backend

Terminal
cd backend
python3 -m venv venv
source venv/bin/activate
pip3 install -r requirements.txt

# Create a .env for development
cat > .env << EOF
SECRET_KEY=$(python -c "import secrets; print(secrets.token_urlsafe(64))")
CORS_ORIGINS=http://localhost:5173
FRONTEND_URL=http://localhost:5173
DEBUG=true
EOF

uvicorn app.main:app --reload --port 8000

Frontend

Terminal
cd frontend
npm install
npm run dev

Open http://localhost:5173 in your browser.

Linux Deploy Agent — Quick Install

The deploy agent is a statically compiled Go binary that runs on any Linux distribution without dependencies. Available for amd64, arm64, arm, and 386 architectures. A Windows agent is also available.

Terminal (target server, as root)
cd agent/
sudo ./install.sh

This automatically detects the architecture, copies the binary to /usr/local/bin/certdax-agent, creates the config directory, and installs the systemd service.

Manual Installation

Terminal
# Choose the correct binary for your architecture
# Options: certdax-agent-linux-amd64, -arm64, -arm, -386
sudo install -m 755 dist/certdax-agent-linux-amd64 /usr/local/bin/certdax-agent

# Create config directory and configure
sudo mkdir -p /etc/certdax
sudo cp config.example.yaml /etc/certdax/config.yaml
sudo chmod 600 /etc/certdax/config.yaml
sudo nano /etc/certdax/config.yaml

# Install systemd service
sudo cp certdax-agent.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now certdax-agent

Agent CLI Commands

The certdax-agent binary supports a few CLI commands that work the same on Linux and Windows:

Terminal
certdax-agent --version          # Print version and exit
certdax-agent --logs             # Stream live logs (journalctl on Linux, log file on Windows)
sudo certdax-agent --uninstall   # Stop the service and remove binary, config and certs

On Windows, run --logs and --uninstall from an elevated PowerShell (Run as Administrator). On Linux, both require sudo (or root) for --uninstall; --logs works for any user that can read the journal.

In addition to the system journal / Event Log, the agent always writes a persistent on-disk log at /var/log/certdax-agent.log (Linux) or C:\ProgramData\CertDax\logs\certdax-agent.log (Windows). The log is rotated weekly (Sunday 02:00 local time), so --logs keeps working even after a service restart or a journald purge.

Usage Without Config File

Terminal
certdax-agent --api-url https://certdax.example.com --token YOUR_AGENT_TOKEN

# Or via environment variables
export CERTDAX_API_URL=https://certdax.example.com
export CERTDAX_AGENT_TOKEN=your-token
certdax-agent

Building From Source

Terminal
cd agent/

# Build for all platforms
make all

# Or build for current platform only
make build

# Binaries are in dist/
ls -la dist/

Windows Deploy Agent

The Windows agent is the same statically compiled Go binary, packaged for Windows. Available for x64 (amd64), ARM64, and x86 (32-bit) architectures.

Prerequisites

  • Windows 10 / Windows Server 2016 or later
  • PowerShell 5.1 or later (pre-installed on all supported Windows versions)
  • An elevated (Administrator) PowerShell session
Microsoft Defender Tamper Protection: the installer is compatible with Tamper Protection enabled. Since v2.1.8 the script uses certutil.exe -addstore to import the internal CA into the Trusted Root + Trusted Publishers stores, which works through Tamper Protection. (The older Import-Certificate PowerShell cmdlet was blocked by Tamper Protection on hardened hosts and produced an opaque Access is denied error.)

Quick Install — PowerShell One-Liner

The easiest way is to use the PowerShell one-liner from the CertDax UI:

  1. Go to Agents in the CertDax dashboard
  2. Click Add agent and select Windows as the OS type
  3. Select a Self-Signed CA to code-sign the agent binary (this suppresses SmartScreen warnings)
  4. Fill in the agent name and hostname, then click Create
  5. Click the Install button on the newly created agent
  6. Copy the PowerShell one-liner and run it in an elevated PowerShell session on the target machine
PowerShell (Administrator)
# Example (actual command comes from the UI with a pre-embedded token):
iwr -useb "https://certdax.example.com/api/agents/1/install/windows-script?ca_id=1&token=..." | iex

The script will:

  • Automatically detect the CPU architecture (amd64 / ARM64 / x86)
  • Download and verify the signed agent binary
  • Install it to C:\ProgramData\CertDax\certdax-agent.exe
  • Create the configuration file at C:\ProgramData\CertDax\config.yaml
  • Register and start a Windows Service (CertDaxAgent) set to start automatically
Note: The NSIS setup wizard (.exe) installs the binary to C:\Program Files\CertDax\ instead.

Manual Installation (Windows)

If you prefer to install without running a remote script:

1. Download the binary

Download certdax-agent-windows-amd64.exe (or arm64 / 386) from the CertDax UI:

  • Go to Agents → your agent → InstallAdvanced / scripted install optionscertdax-agent.exe (signed)

2. Place the binary

PowerShell (Administrator)
New-Item -ItemType Directory -Force "C:\ProgramData\CertDax"
Move-Item certdax-agent-windows-amd64.exe "C:\ProgramData\CertDax\certdax-agent.exe"

3. Create the configuration file

PowerShell (Administrator)
New-Item -ItemType Directory -Force "C:\ProgramData\CertDax"

@"
api_url: "https://certdax.example.com"
agent_token: "your-agent-token-here"
poll_interval: 30
"@ | Set-Content "C:\ProgramData\CertDax\config.yaml"

The agent token is shown when you create the agent in the UI. You can also retrieve it from Agents → your agent → Token.

4. Install as a Windows Service

PowerShell (Administrator)
# Create the service
New-Service -Name "CertDaxAgent" `
            -DisplayName "CertDax Deploy Agent" `
            -Description "Deploys certificates from CertDax to this machine" `
            -BinaryPathName '"C:\ProgramData\CertDax\certdax-agent.exe" --config "C:\ProgramData\CertDax\config.yaml"' `
            -StartupType Automatic

# Start the service
Start-Service CertDaxAgent

# Verify it is running
Get-Service CertDaxAgent

Managing the Windows Service

PowerShell
# View service status
Get-Service CertDaxAgent

# Stop / Start / Restart
Stop-Service CertDaxAgent
Start-Service CertDaxAgent
Restart-Service CertDaxAgent

# View recent log output (Windows Event Log)
Get-EventLog -LogName Application -Source CertDaxAgent -Newest 20

# Uninstall
Stop-Service CertDaxAgent
Remove-Service CertDaxAgent           # PowerShell 6+ / Windows Server 2019+
# Or on older systems:
sc.exe delete CertDaxAgent

Alternative Install Methods

MethodWhen to use
PowerShell one-linerInteractive install on a single machine — recommended
Windows Installer (.exe)UI-driven wizard; SmartScreen may warn, right-click → Properties → Unblock if needed
PowerShell script (.ps1)Scripted/RMM deployments (e.g. Intune, PDQ, Ansible)
Manual binaryAir-gapped or policy-restricted environments

All options are available in the Install modal of each agent in the CertDax dashboard.

SmartScreen & Code Signing

Binaries downloaded through the browser may be blocked by Windows SmartScreen. The recommended workaround is to use the PowerShell one-liner, which downloads the binary directly from PowerShell and bypasses the browser mark-of-the-web. Alternatively:

  • Install the signing CA as a Trusted Root on the target machine before downloading the binary — SmartScreen will then trust it automatically
  • Right-click the downloaded .exePropertiesUnblockOK

Certificate Deployment Path

By default the agent deploys certificates to:

Path
C:\ProgramData\CertDax\certs\

You can change this in the Deploy path field when creating the agent in the UI, or directly in config.yaml.

Deploy Formats

When you assign a certificate to an agent or agent group you pick one of the following file layouts:

FormatFiles writtenUse case
crt<name>.crt, <name>.key, <name>.fullchain.crt, <name>.chain.crtDefault. Nginx, Apache, HAProxy, anything that wants separate cert + key files.
pem<name>.pem (key + cert + chain concatenated)HAProxy single-file, Postfix, Dovecot.
pfx<name>.pfx (PKCS#12 bundle, no password)Windows IIS, .NET, anything consuming PKCS#12.
ca-cert<name>-ca.crt (public certificate only, no key file)v2.2.0+ only. Distributing your internal Root / intermediate CA as a trust anchor. On Windows the cert is also installed into the Trusted Root store.
v2.2.0+ — CA assignments: Self-signed CAs may only be assigned with deploy_format: ca-cert. The backend rejects any other format for CA certs and never sends the CA private key to the agent. The dashboard auto-locks the format dropdown to ca-cert when a CA is selected.

Ansible / Unattended Bootstrap Installers

For fleet-wide rollouts CertDax ships two API-key-driven bootstrap installers that auto-detect the host's identity and create the agent server-side without any manual UI interaction. The backend serves them so Ansible playbooks can curl | sh / iwr | iex them at runtime — no vendoring required.

Authentication required. The script-download endpoints require the same Bearer token (CERTDAX_API_KEY) the script itself consumes. The examples below already set Authorization: Bearer ... on the curl / Invoke-WebRequest; without it the endpoints return 401.
URLOSService installer
GET {CERTDAX_URL}/api/agents/install/ansible.shLinuxsystemd
GET {CERTDAX_URL}/api/agents/install/ansible.ps1WindowsWindows service
GET {CERTDAX_URL}/api/agents/install/ansible-uninstall.shLinuxcleanup
GET {CERTDAX_URL}/api/agents/install/ansible-uninstall.ps1Windowscleanup

What the scripts do, in order:

  1. Detect identity — read the system FQDN, derive the agent name from the first label (e.g. dietpi.local → name dietpi, WIN-FOO.corp.localWIN-FOO).
  2. Create the agent via POST /api/agents using your API key (CertDax → API Keys).
  3. Download and execute the existing per-agent install script — the same flow as clicking Install in the dashboard.

Linux example

playbook.yml
- hosts: linux_servers
  become: true
  tasks:
    - name: Install CertDax agent
      ansible.builtin.shell: |
        curl -fsSL -H "Authorization: Bearer {{ certdax_api_key }}" \
             "{{ certdax_url }}/api/agents/install/ansible.sh" \
          | env CERTDAX_URL="{{ certdax_url }}" \
                CERTDAX_API_KEY="{{ certdax_api_key }}" sh
      args:
        creates: /usr/local/bin/certdax-agent

Windows example

playbook.yml
- hosts: windows_servers
  tasks:
    - name: Install CertDax agent
      ansible.windows.win_powershell:
        script: |
          $env:CERTDAX_URL     = '{{ certdax_url }}'
          $env:CERTDAX_API_KEY = '{{ certdax_api_key }}'
          $env:CERTDAX_CA_ID   = '{{ certdax_code_signing_ca_id }}'
          [Net.ServicePointManager]::SecurityProtocol = `
            [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13
          iwr "$env:CERTDAX_URL/api/agents/install/ansible.ps1" -UseBasicParsing `
              -Headers @{ Authorization = "Bearer $env:CERTDAX_API_KEY" } `
            | Select-Object -ExpandProperty Content | Invoke-Expression
Why is CERTDAX_CA_ID required for Windows? The Windows agent .exe is Authenticode-signed on demand using a self-signed CA you manage in CertDax (Self-Signed Certificates). The backend can't pick the CA for you because the matching root must already be installed in the target machine's Trusted Root store — that's an operator decision. Keeping the CA id only in your Ansible vault (and not in the script body) means a leaked script alone is useless to an attacker: they still don't know which CA your fleet trusts.

Where do {{ ... }} values come from?

The double-brace placeholders in the playbook examples are Jinja2 variables. Ansible substitutes them before the script body is sent to the target host. The variables themselves come from your Ansible config — not from CertDax. The recommended layout looks like this:

repository layout
ansible/
├── playbook.yml
├── inventory.yml
└── group_vars/
    └── all/
        ├── vars.yml      # non-secret config, safe to commit
        └── vault.yml     # encrypted with `ansible-vault encrypt`
group_vars/all/vars.yml
certdax_url: "https://certdax.example.com"
certdax_code_signing_ca_id: 3
# pull the secret value from the encrypted vault file:
certdax_api_key: "{{ vault_certdax_api_key }}"
group_vars/all/vault.yml (encrypted)
vault_certdax_api_key: "ck_live_xxxxxxxxxxxxxxxxxxxx"

Encrypt the vault file once with:

terminal
ansible-vault encrypt group_vars/all/vault.yml

Run the playbook with the vault password:

terminal
ansible-playbook playbook.yml --ask-vault-pass

With this in place, every {{ certdax_url }} / {{ certdax_api_key }} / {{ certdax_code_signing_ca_id }} reference in the playbook is resolved automatically.

Quick test without vault

If you just want to try it once, pass the values straight on the CLI — no group_vars or vault required:

terminal
ansible-playbook playbook.yml \
  -e certdax_url=https://certdax.example.com \
  -e certdax_code_signing_ca_id=3 \
  -e certdax_api_key=ck_live_xxx

(Acceptable for ad-hoc testing — don't use it for production rollouts because the API key ends up in your shell history and process list.)

Variables

VariableDefaultPurpose
CERTDAX_URLrequiredBackend base URL
CERTDAX_API_KEYrequiredAPI key with agent-create permission
CERTDAX_CA_IDrequired (Windows only)Code-signing CA for the agent .exe
CERTDAX_NAMEfirst label of hostnameOverride the auto-detected agent name
CERTDAX_HOSTNAMEsystem FQDNOverride the hostname stored on the agent
CERTDAX_DEPLOY_PATH/etc/ssl/certs (Linux) / C:\ProgramData\CertDax\certs (Windows)Where the agent writes deployed certs
CERTDAX_GROUPSunsetComma-separated agent-group names. Groups are auto-created if missing. Pair with Ansible's group_names to mirror your inventory layout (e.g. webservers,prod).

The source for both scripts lives in agent/ansible/. For air-gapped environments you can vendor them into your Ansible repository and run them via ansible.builtin.script / ansible.windows.win_copy instead of fetching them at runtime — the behaviour is identical.

Bundling agents into groups

Groups in CertDax let you assign certificates and view rollouts per logical fleet (web tier, HAProxy LBs, prod vs. staging, …). The bootstrap installers can attach the new agent to one or more groups by name — missing groups are created on the fly. The most ergonomic way is to feed Ansible's built-in group_names variable straight into CERTDAX_GROUPS, so your inventory layout becomes the source of truth in CertDax too.

inventory.yml
all:
  children:
    webservers:
      hosts: { web-01: {}, web-02: {} }
    haproxy:
      hosts: { lb-01: {}, lb-02: {} }
    prod:
      children: { webservers: {}, haproxy: {} }
playbook.yml
- hosts: all
  become: true
  tasks:
    - name: Install CertDax agent
      ansible.builtin.shell: |
        curl -fsSL -H "Authorization: Bearer {{ certdax_api_key }}" \
             "{{ certdax_url }}/api/agents/install/ansible.sh" \
          | env CERTDAX_URL="{{ certdax_url }}" \
                CERTDAX_API_KEY="{{ certdax_api_key }}" \
                CERTDAX_GROUPS="{{ group_names | reject('equalto','all') | join(',') }}" sh

With this playbook web-01 ends up in CertDax groups webservers + prod, lb-01 in haproxy + prod, and so on — without ever clicking through the UI.

Uninstalling

Two matching uninstaller scripts (uninstall.sh / uninstall.ps1) stop and remove the service, delete the binary, config and state directories, and — if CERTDAX_URL + CERTDAX_API_KEY are provided — also delete the matching agent record from the CertDax dashboard. The agent is matched by name first, falling back to hostname (both default to the host's auto-detected values, the same logic the installer uses), so most uninstalls don't need any extra flags.

linux-uninstall.yml
- hosts: linux_servers
  become: true
  tasks:
    - name: Uninstall CertDax agent
      ansible.builtin.shell: |
        curl -fsSL -H "Authorization: Bearer {{ certdax_api_key }}" \
             "{{ certdax_url }}/api/agents/install/ansible-uninstall.sh" \
          | env CERTDAX_URL="{{ certdax_url }}" \
                CERTDAX_API_KEY="{{ certdax_api_key }}" sh
windows-uninstall.yml
- hosts: windows_servers
  tasks:
    - name: Uninstall CertDax agent
      ansible.windows.win_powershell:
        script: |
          $env:CERTDAX_URL     = '{{ certdax_url }}'
          $env:CERTDAX_API_KEY = '{{ certdax_api_key }}'
          [Net.ServicePointManager]::SecurityProtocol = `
            [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13
          iwr "$env:CERTDAX_URL/api/agents/install/ansible-uninstall.ps1" -UseBasicParsing `
              -Headers @{ Authorization = "Bearer $env:CERTDAX_API_KEY" } `
            | Select-Object -ExpandProperty Content | Invoke-Expression
Variable / flagPurpose
CERTDAX_URL + CERTDAX_API_KEYOptional. When both are set the agent record is also deleted from CertDax. Without them the script does a pure local cleanup.
CERTDAX_NAME / --name / -NameOverride the agent name used to look up the record server-side (default: first label of hostname).
CERTDAX_HOSTNAME / --hostname / -HostnameMatch agents by this hostname instead of name — useful if you customised --hostname during install.
--keep-remote / -KeepRemoteSkip the API delete; only clean up locally.
--keep-local / -KeepLocalSkip local cleanup; only delete the record from CertDax.

DNS Provider Configuration

Cloudflare

JSON
{
  "api_token": "your-cloudflare-api-token"
}

Create an API token in Cloudflare with Zone:DNS:Edit permissions.

TransIP

JSON
{
  "login": "your-transip-login",
  "private_key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
}

Generate a key pair in the TransIP control panel.

Hetzner

JSON
{
  "api_token": "your-hetzner-dns-api-token"
}

Create an API token in the Hetzner DNS Console.

DigitalOcean

JSON
{
  "api_token": "your-digitalocean-api-token"
}

Create a personal access token with read/write scope.

Vultr

JSON
{
  "api_key": "your-vultr-api-key"
}

Create an API key in the Vultr customer portal.

OVH

JSON
{
  "endpoint": "ovh-eu",
  "application_key": "your-app-key",
  "application_secret": "your-app-secret",
  "consumer_key": "your-consumer-key"
}

Generate credentials at https://api.ovh.com/createToken/.

AWS Route 53

JSON
{
  "access_key_id": "AKIAIOSFODNN7EXAMPLE",
  "secret_access_key": "your-secret-access-key",
  "region": "us-east-1"
}

Create an IAM user with route53:ChangeResourceRecordSets and route53:ListHostedZones permissions.

Google Cloud DNS

JSON
{
  "project_id": "your-gcp-project-id",
  "service_account_json": "{...}"
}

Create a service account with the DNS Administrator role and export the JSON key.

Manual

JSON
{}

With manual validation, DNS records are shown in the server logs.

Reverse Proxy & Let's Encrypt

CertDax exposes two TLS endpoints — and yes, that is on purpose:

  • Port 8081 (plain HTTP) — the dashboard for human users. Front this with your own reverse proxy and a public CA certificate (Let's Encrypt). Browsers reach it as https://certdax.example.com/.
  • Port 10443 (HTTPS + mTLS) — agent and Kubernetes-operator traffic. Served with a certificate issued by CertDax' internal CA inside OpenBao. The same CA verifies agent client certificates (RFC 0001 §6.3). Do not put this behind a public-CA reverse proxy: a Let's Encrypt cert can never terminate mTLS for agents, because the client certs are signed by your private CertDax CA.

One DNS A-record, two ports. The setup wizard asks for the public FQDN; agents are configured automatically with :10443.

Easiest path: ./bootstrap.sh (or .\bootstrap.ps1 on Windows) asks "Will CertDax sit behind a reverse proxy with an ACME certificate?" on first run. Answer y and provide your public hostname — the script writes BEHIND_REVERSE_PROXY=true, FRONTEND_HOSTNAME, AGENT_BACKEND_URL and INSTALL_BOOTSTRAP_URL to .env for you. Skip ahead to the proxy config below.
Manual setup: if you skipped the bootstrap interview (or want to reconfigure), set the variables yourself before starting the stack. Without them, the HTTP listener redirects every request to :10443 and your reverse proxy ends up in a redirect loop.
.env
# Tell the frontend container to serve the dashboard over plain HTTP
# (a TLS-terminating reverse proxy is in front of it).
BEHIND_REVERSE_PROXY=true

# Public URL the dashboard is reachable on (used for CORS / setup wizard).
FRONTEND_HOSTNAME=certdax.example.com

# Agents reach the mTLS endpoint on :10443 directly — same FQDN, separate port.
AGENT_BACKEND_URL=https://certdax.example.com:10443
INSTALL_BOOTSTRAP_URL=https://certdax.example.com

Nginx

Two server blocks: one for the dashboard with Let's Encrypt, one stream-style passthrough for agents on 10443. (Or simpler: open port 10443 directly on the host firewall and let the frontend container handle it itself — the example below shows that.)

/etc/nginx/sites-available/certdax.conf
# Public dashboard (Let's Encrypt) — proxies plain HTTP to the container.
server {
    listen 443 ssl http2;
    server_name certdax.example.com;

    ssl_certificate     /etc/letsencrypt/live/certdax.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/certdax.example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;

    location / {
        proxy_pass http://127.0.0.1:8081;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_read_timeout 300s;
        client_max_body_size 10m;
    }
}

# HTTP → HTTPS redirect for the dashboard (also serves the ACME challenge).
server {
    listen 80;
    server_name certdax.example.com;

    location /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
    location / { return 301 https://$host$request_uri; }
}

# Agent / operator mTLS endpoint — bypass nginx entirely.
# Either:
#   * Open host port 10443 on your firewall (the frontend container
#     already binds it directly), or
#   * Use an nginx stream{} block with `proxy_pass 127.0.0.1:10443;`
#     and `ssl_preread on` to passthrough by SNI.
Issue the Let's Encrypt cert with certbot:
sudo certbot --nginx -d certdax.example.com

Apache2

Enable the required modules first:

Terminal
sudo a2enmod proxy proxy_http ssl rewrite headers
sudo systemctl restart apache2
/etc/apache2/sites-available/certdax.conf
# Public dashboard (Let's Encrypt).
<VirtualHost *:443>
    ServerName certdax.example.com

    SSLEngine On
    SSLCertificateFile    /etc/letsencrypt/live/certdax.example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/certdax.example.com/privkey.pem

    ProxyPreserveHost On
    ProxyPass        / http://127.0.0.1:8081/
    ProxyPassReverse / http://127.0.0.1:8081/

    RequestHeader set X-Forwarded-Proto "https"
    RequestHeader set X-Forwarded-Port  "443"
</VirtualHost>

# HTTP → HTTPS redirect (and ACME challenge).
<VirtualHost *:80>
    ServerName certdax.example.com
    Alias /.well-known/acme-challenge/ /var/www/letsencrypt/.well-known/acme-challenge/
    RewriteEngine On
    RewriteCond %{REQUEST_URI} !^/.well-known/acme-challenge/
    RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>

# Agent / operator mTLS endpoint stays on :10443 with the internal CA.
# Apache cannot proxy mTLS without forwarding the client certificate
# chain — easiest path is to open host port 10443 on your firewall and
# let the frontend container terminate it directly.
Issue the Let's Encrypt cert with certbot:
sudo certbot --apache -d certdax.example.com

HAProxy

HAProxy can route both endpoints from a single instance using SNI passthrough for the mTLS port — no separate firewall opening required.

/etc/haproxy/haproxy.cfg
frontend dashboard_https
    bind *:443 ssl crt /etc/haproxy/certs/certdax.pem
    bind *:80
    http-request redirect scheme https unless { ssl_fc }
    http-request set-header X-Forwarded-Proto https if { ssl_fc }
    default_backend certdax_dashboard

backend certdax_dashboard
    option httpchk GET /health
    server certdax 127.0.0.1:8081 check

# Agent / operator mTLS — TCP passthrough on :10443 (no TLS termination).
# HAProxy forwards the raw TLS bytes; the frontend container terminates
# mTLS with the internal CertDax CA.
frontend agents_mtls
    bind *:10443
    mode tcp
    default_backend certdax_agents

backend certdax_agents
    mode tcp
    server certdax 127.0.0.1:10443 check
Note: Set BEHIND_REVERSE_PROXY=true, FRONTEND_HOSTNAME, AGENT_BACKEND_URL and INSTALL_BOOTSTRAP_URL in your .env as shown above. The setup wizard reads FRONTEND_HOSTNAME and pre-fills the public URL.

Remote reverse proxy (WireGuard / VPN)

If your reverse proxy lives on a different host than CertDax (e.g. a public-facing VPS while CertDax runs on a private host reachable over WireGuard), you need to forward two things:

  1. The dashboard via standard HTTP reverse proxy (Let's Encrypt termination on the VPS).
  2. The mTLS port via raw TCP passthrough — nginx stream {} module is the cleanest tool. It does not decrypt the TLS, so the agent client certificates pass through untouched to the CertDax frontend container.

Verify the stream module is built into your nginx (most distro packages and the official nginx.org packages include it):

Terminal
nginx -V 2>&1 | tr ' ' '\n' | grep -i stream
# Expected: --with-stream, --with-stream_ssl_module, --with-stream_ssl_preread_module

Then add a stream {} block to your nginx.conf at the top level (next to http {}, not inside it):

/etc/nginx/nginx.conf
# Top-level: TCP/TLS passthrough for the CertDax mTLS port.
# Replace 10.8.3.100 with the WireGuard / VPN IP of your CertDax host.
stream {
    upstream certdax_mtls {
        server 10.8.3.100:10443;
    }

    server {
        listen 10443;
        proxy_pass certdax_mtls;
        proxy_timeout 300s;
        proxy_connect_timeout 10s;
    }
}

# http {} block stays as-is and proxies the dashboard normally:
#   server { listen 443 ssl; ... proxy_pass http://10.8.3.100:8081; }

Reload nginx and open the firewall:

Terminal
nginx -t && systemctl reload nginx
ufw allow 10443/tcp           # or firewalld / iptables equivalent
ss -tlnp | grep ':10443'      # must show 0.0.0.0:10443 ... nginx

Test from an external host:

Terminal
curl -kv https://certdax.example.com:10443/health
# Expected: TLS handshake with cert issued by "CertDax Internal CA" (NOT Let's Encrypt).
Tip: port 8443 on the VPS is often already in use by other Docker stacks (mailcow's nginx-mailcow binds 127.0.0.1:8443 for SOGo / SnappyMail). That is exactly why CertDax defaults to 10443.

Recipe: stream a TCP port through nginx

The nginx stream {} module forwards raw TCP (or UDP) traffic without inspecting or terminating it. It is the right tool whenever you need to expose a backend service through a frontend host but you cannot let the frontend decrypt the traffic — mTLS, custom binary protocols, SSH, databases, mail, and so on. CertDax uses this for the agent / k8s-operator mTLS port, but the recipe applies to any TCP service.

When stream {} vs http {}:
  • http {} — nginx terminates TLS, parses HTTP, can rewrite headers / paths. Required for Let's Encrypt termination, WAF, caching, etc.
  • stream {} — nginx is a dumb TCP forwarder. Client sees the backend's TLS certificate directly. Required for mTLS passthrough, SSH, MySQL, Redis, IMAPS, custom protocols.

1. Verify the stream module is available

The stream module is a separate compile-time module. Most distro packages and the official nginx.org packages include it. Check with:

Terminal
nginx -V 2>&1 | tr ' ' '\n' | grep -i stream
# Expected:
#   --with-stream
#   --with-stream_ssl_module           (only if you need TLS termination in stream)
#   --with-stream_ssl_preread_module   (only if you want to route by SNI)

If the output is empty, install the full package:

Terminal
# Debian / Ubuntu
apt install nginx-full

# RHEL / Rocky / Alma
dnf install nginx-mod-stream

2. Where the stream {} block goes

Critical detail: stream {} lives at the top level of nginx.conf, as a sibling of http {}not inside it. Putting it inside http {} is the most common mistake and will fail with "stream" directive is not allowed here.

/etc/nginx/nginx.conf (top-level layout)
events { worker_connections 1024; }

http {
    # your existing virtual hosts / Let's Encrypt servers
    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

stream {
    # TCP / UDP forwarders go here
    include /etc/nginx/streams-enabled/*.conf;
}

If your distro uses split configs, create /etc/nginx/streams-enabled/ and drop one file per service into it.

3. Minimal example: forward the CertDax mTLS port

This is the exact recipe for the CertDax agent / k8s-operator endpoint. The CertDax host listens on 10443 internally; the public VPS forwards 10443 straight through without decrypting (so client certs reach the frontend container intact). Two listen directives are used so the same forwarder accepts both IPv4 and IPv6 clients — unlike the http {} block, nginx stream {} does not support the ipv6only=off dual-stack shortcut, so you must declare each family explicitly.

/etc/nginx/streams-enabled/certdax-mtls.conf
upstream certdax_mtls {
    server 10.8.3.100:10443;      # WireGuard / VPN IP of the CertDax host
}

server {
    listen 0.0.0.0:10443;         # IPv4
    listen [::]:10443;            # IPv6 (separate socket; required in stream {})
    proxy_pass certdax_mtls;
    proxy_timeout 300s;
    proxy_connect_timeout 10s;
}

Reload, open the firewall, and verify both stacks are listening:

Terminal
nginx -t && systemctl reload nginx
ufw allow 10443/tcp           # ufw rules cover both v4 and v6 by default

# You should see two lines \u2014 one 0.0.0.0:10443 and one [::]:10443:
ss -tlnp | grep ':10443'

# From an external host \u2014 you should see the CertDax internal CA, NOT Let's Encrypt:
curl -kv      https://certdax.example.com:10443/health 2>&1 | grep -E 'subject:|issuer:'
curl -kv -6   https://certdax.example.com:10443/health 2>&1 | grep -E 'subject:|issuer:'
Why two listen lines? In http {} you can write listen [::]:443 ipv6only=off; to bind v4 + v6 with a single socket. The stream {} module does not accept the ipv6only parameter, so you need one listen per address family. If you only had listen 10443;, nginx would bind 0.0.0.0 only and IPv6 clients would get connection refused.

4. Common patterns

Multiple backends with health checks (round-robin):

streams-enabled/redis.conf
upstream redis_pool {
    server 10.0.0.10:6379 max_fails=2 fail_timeout=10s;
    server 10.0.0.11:6379 max_fails=2 fail_timeout=10s;
    server 10.0.0.12:6379 backup;
}

server {
    listen 6379;
    proxy_pass redis_pool;
    proxy_connect_timeout 2s;
}

UDP (DNS, syslog, WireGuard control—not the tunnel itself):

streams-enabled/dns.conf
upstream dns_backend {
    server 10.0.0.53:53;
}

server {
    listen 53 udp reuseport;
    proxy_pass dns_backend;
    proxy_responses 1;
    proxy_timeout 5s;
}

SNI-based routing (one port, multiple TLS backends):

streams-enabled/sni-router.conf
map $ssl_preread_server_name $tls_backend {
    certdax.example.com    127.0.0.1:10443;
    git.example.com        127.0.0.1:2222;
    default                127.0.0.1:443;
}

server {
    listen 443;
    ssl_preread on;            # peek at SNI without decrypting
    proxy_pass $tls_backend;
}

This lets a single VPS terminate ACME for some hosts (via http {}) and passthrough mTLS for others (e.g. CertDax) on different listening ports, or even multiplex everything onto port 443 with SNI preread.

Preserve client IP with PROXY protocol:

streams-enabled/with-proxy-protocol.conf
server {
    listen 10443;
    proxy_pass 10.8.3.100:10443;
    proxy_protocol on;          # requires backend to accept PROXY protocol
}

Without proxy_protocol, the backend sees nginx as the client. Most TLS terminators (HAProxy, nginx, Traefik, Envoy) understand PROXY protocol v1/v2 and will surface the real client IP in their access logs.

5. Logging

Stream traffic is not logged to access.log by default — that file is HTTP-only. Add a stream-specific log:

stream {} block
stream {
    log_format basic '$remote_addr [$time_local] '
                     '$protocol $status $bytes_sent $bytes_received '
                     '$session_time "$upstream_addr"';

    access_log /var/log/nginx/stream-access.log basic;

    include /etc/nginx/streams-enabled/*.conf;
}

6. Firewall

nginx still needs the host firewall to allow inbound on the listening port. For ufw:

Terminal
ufw allow 10443/tcp
ufw allow 53/udp
Gotchas:
  • You cannot mix http {} server blocks and stream {} server blocks on the same port. Pick one per port.
  • ssl_preread only works on TLS connections that send SNI. Plain TCP, opportunistic STARTTLS, and very old clients won't route correctly.
  • nginx does not support HTTP-style location matching in stream {}. There are no paths — only ports and SNI.
  • For the CertDax mTLS port specifically: do not use ssl_module in stream (which would terminate TLS at nginx). The agent client certs are signed by your private CertDax CA — only the frontend container can verify them.

Kubernetes Operator

The CertDax Kubernetes Operator syncs certificates from your CertDax instance into Kubernetes TLS secrets — similar to cert-manager, but backed by CertDax. Works with Traefik, nginx-ingress, HAProxy Ingress, and any controller that reads standard TLS secrets.

How it works: You create a CertDaxCertificate custom resource with a certificate ID from CertDax. The operator fetches the certificate material via the CertDax API and creates a standard kubernetes.io/tls secret. It periodically re-syncs so that renewals are automatically picked up.

┌──────────────────┐      watch       ┌───────────────────┐     API call     ┌─────────────┐
│ CertDaxCertificate│ ◄─────────────── │  CertDax Operator │ ──────────────► │   CertDax   │
│      (CRD)       │                  │   (controller)    │   fetch cert     │   Backend   │
└──────────────────┘                  └────────┬──────────┘                  └─────────────┘
                                               │
                                        create / update
                                               │
                                               ▼
                                      ┌──────────────┐
                                      │  TLS Secret   │
                                      │ (kubernetes.  │
                                      │  io/tls)      │
                                      └──────┬───────┘
                                             │
                                      referenced by
                                             │
                                    ┌────────┴────────┐
                                    │                 │
                              ┌─────▼─────┐   ┌──────▼──────┐
                              │  Traefik  │   │ nginx-ingress│
                              │IngressRoute│   │   Ingress   │
                              └───────────┘   └─────────────┘

Try It Locally with Kind & Podman

Follow these steps to run the CertDax Kubernetes Operator on your own machine. No cloud account needed — just a Linux PC with Podman.

Before you begin: You need a running CertDax instance. Go to K8s Operators → Create in the CertDax dashboard to register the operator — this automatically creates an API key and operator token with a ready-to-use Helm command. Alternatively, create an API key manually via Profile → API Keys. Also note the certificate ID of the certificate you want to sync (visible on the certificate detail page).

Step 1 — Install Podman, Kind, kubectl & Helm

Pick your OS and run the commands below. Already have some of these installed? Skip what you don't need.

Ubuntu / Debian
sudo apt update && sudo apt install -y podman

curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64
sudo install -o root -g root -m 0755 ./kind /usr/local/bin/kind && rm ./kind

curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl && rm ./kubectl

curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
Fedora / RHEL (Podman is pre-installed)
curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64
sudo install -o root -g root -m 0755 ./kind /usr/local/bin/kind && rm ./kind

curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl && rm ./kubectl

curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
macOS (Homebrew)
brew install podman kind kubectl helm
podman machine init && podman machine start

Step 2 — Tell Kind to Use Podman

Run this in your terminal. The second line saves it so you don't have to type it again next time.

Terminal
export KIND_EXPERIMENTAL_PROVIDER=podman
echo 'export KIND_EXPERIMENTAL_PROVIDER=podman' >> ~/.bashrc
Using zsh? Replace ~/.bashrc with ~/.zshrc.

Step 3 — Create a Local Kubernetes Cluster

This creates a mini Kubernetes cluster running inside a Podman container on your machine.

Terminal
kind create cluster --name certdax-demo

Check that it's running:

Terminal
kubectl get nodes

You should see something like:

Expected output
NAME                          STATUS   ROLES           AGE   VERSION
certdax-demo-control-plane    Ready    control-plane   30s   v1.31.0
Permission error? Run systemctl --user start podman.socket and try again.

Step 4 — Install the CertDax Operator

Replace https://certdax.example.com/api with your CertDax API URL (note the /api suffix) and your-api-key with the API key you created earlier.

Tip: You can also create a K8s Operator in the CertDax dashboard (K8s Operators → Create). This gives you an operator token and API key automatically — copy the Helm command from the setup guide on the detail page.

Terminal
helm repo add certdax https://charts.certdax.com
helm repo update

helm install certdax-operator certdax/certdax-operator \
  --namespace certdax-system \
  --create-namespace \
  --set certdax.apiUrl=https://certdax.example.com/api \
  --set certdax.apiKey=your-api-key

Wait until the operator is running:

Terminal
kubectl get pods -n certdax-system

You should see:

Expected output
NAME                                READY   STATUS    RESTARTS   AGE
certdax-operator-xxxxxxxxxx-xxxxx   1/1     Running   0          30s

Step 5 — Sync Your First Certificate

Now tell the operator to pull a certificate from CertDax and store it as a Kubernetes secret. Replace 1 with your actual certificate ID.

Terminal
cat <<EOF | kubectl apply -f -
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: my-cert
spec:
  certificateId: 1
  type: selfsigned
  secretName: my-cert-tls
EOF

After a few seconds, check the result:

Terminal
kubectl get cdxcert

When READY is true, your certificate is synced:

Expected output
NAME      TYPE         SECRET        READY   EXPIRES                AGE
my-cert   selfsigned   my-cert-tls   true    2027-04-13T00:00:00Z   10s

Done! The TLS secret my-cert-tls now exists in your cluster. If you renew the certificate in CertDax, the operator automatically updates the secret.

Step 6 — (Optional) See It in Action with Traefik

Want to actually use the certificate? Install Traefik as a reverse proxy and point it at a test app — using the same my-cert-tls secret from Step 5.

6a. Install Traefik:

Terminal
helm repo add traefik https://traefik.github.io/charts
helm repo update
helm install traefik traefik/traefik \
  --namespace traefik --create-namespace

6b. Deploy a simple test app:

Terminal
kubectl create deployment whoami --image=traefik/whoami
kubectl expose deployment whoami --port=80

6c. Create an IngressRoute that serves the test app over HTTPS with your CertDax certificate:

Terminal
cat <<EOF | kubectl apply -f -
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: whoami
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(\`myapp.local\`)
      kind: Rule
      services:
        - name: whoami
          port: 80
  tls:
    secretName: my-cert-tls
EOF

6d. Verify everything is running:

Terminal
kubectl get cdxcert
kubectl get ingressroute

6e. Test it with curl. Port-forward Traefik to your local machine and make an HTTPS request:

Terminal
kubectl port-forward -n traefik svc/traefik 8443:443 &
Terminal
curl -k --resolve myapp.local:8443:127.0.0.1 https://myapp.local:8443/

You should get a response from the whoami app. To see the certificate details:

Terminal
curl -kv --resolve myapp.local:8443:127.0.0.1 https://myapp.local:8443/ 2>&1 | grep -E "subject:|issuer:|expire"

This shows the CN, issuer and expiry date of your CertDax certificate being served by Traefik.

6f. Want to test in a browser? Add myapp.local to your hosts file:

Linux / macOS
echo '127.0.0.1 myapp.local' | sudo tee -a /etc/hosts

Then open https://myapp.local:8443/ in your browser. You'll see a certificate warning because it's self-signed — click through it to see the whoami response.

Tip: You can avoid the browser warning by importing the CA certificate into your trust store. Download the CA from CertDax and:

  • Ubuntu / Debian: sudo cp ca.pem /usr/local/share/ca-certificates/ca.crt && sudo update-ca-certificates
  • Arch / Manjaro / Fedora: sudo cp ca.pem /etc/ca-certificates/trust-source/anchors/ && sudo update-ca-trust
  • Firefox: Go to Settings → Privacy & Security → Certificates → View Certificates → Import
Don't forget: Remove the hosts entry when you're done: sudo sed -i '/myapp.local/d' /etc/hosts

Cleanup

Done testing? One command removes everything:

Terminal
kind delete cluster --name certdax-demo

Installation (Production)

Prerequisites

  • Kubernetes 1.24+ (any distribution: AKS, EKS, GKE, k3s, RKE2, etc.)
  • Helm 3.x
  • A running CertDax instance
  • A CertDax API key (create one in Profile → API Keys or via the K8s Operator page)

Recommended: Go to K8s Operators → Create in the CertDax dashboard to register the operator. This automatically creates an API key and operator token, and shows you a pre-filled Helm install command. The operator will then appear in the dashboard with status, certificate sync info, and cluster details.

New to Kubernetes? Try the operator on your local machine first using Kind & Podman before deploying to a production cluster.

Step 1 — Add the Helm Repository

Terminal
helm repo add certdax https://charts.certdax.com
helm repo update

Step 2 — Install the Operator

Terminal
helm install certdax-operator certdax/certdax-operator \
  --namespace certdax-system \
  --create-namespace \
  --set certdax.apiUrl=https://certdax.example.com/api \
  --set certdax.apiKey=your-api-key-here \
  --set certdax.operatorToken=your-operator-token \
  --set clusterName=my-cluster
Note: The apiUrl must end with /api. The operatorToken and clusterName are optional but recommended for dashboard integration (status reporting, certificate overview, cluster info).
Vault-based enrolment (recommended, RFC 0001 §6.3): instead of the long-lived operatorToken bearer, the operator can authenticate to the backend via a Vault-issued mTLS client certificate. Click Register operator in the dashboard to obtain a single-use role_id + wrap_token, then install with:
Terminal
helm install certdax-operator certdax/certdax-operator \
  --namespace certdax-system --create-namespace \
  --set certdax.apiUrl=https://certdax.example.com/api \
  --set clusterName=my-cluster \
  --set vault.enabled=true \
  --set vault.addr=https://certdax.example.com/openbao \
  --set vault.roleId=<role-id-from-ui> \
  --set vault.wrapToken=<wrap-token-from-ui> \
  --set vault.commonName=operator-my-cluster \
  --set-file certdax.caBundle=./ca.crt
The wrap token is single-use and short-lived; the operator stores its rotated client cert in an in-pod state directory and renews it automatically before expiry.
Security tip: For production, use Kubernetes Secrets instead of passing credentials in Helm values:
Terminal
# Create the namespace first
kubectl create namespace certdax-system

# Create secrets for API key and operator token
kubectl create secret generic certdax-api-credentials \
  --namespace certdax-system \
  --from-literal=api-key=your-api-key-here

kubectl create secret generic certdax-operator-token \
  --namespace certdax-system \
  --from-literal=operator-token=your-operator-token

# Install referencing the existing secrets
helm install certdax-operator certdax/certdax-operator \
  --namespace certdax-system \
  --set certdax.apiUrl=https://certdax.example.com/api \
  --set certdax.existingSecret=certdax-api-credentials \
  --set certdax.existingOperatorTokenSecret=certdax-operator-token \
  --set clusterName=my-cluster

Step 3 — Verify Installation

Terminal
# Check operator pod
kubectl get pods -n certdax-system

# Expected output:
# NAME                                READY   STATUS    AGE
# certdax-operator-6d4f5b7c8-x9k2l   1/1     Running   30s

# Verify the CRD is installed
kubectl get crd certdaxcertificates.certdax.com

Dashboard Integration

The CertDax dashboard provides full visibility into your Kubernetes operators. When an operator is registered and configured with an operator token, it sends periodic heartbeats with cluster status.

What the Dashboard Shows

  • Online / Offline status — based on heartbeat activity
  • Cluster info — Kubernetes version, node count, cluster name
  • Resource usage — CPU and memory of the operator pod
  • Deployed certificates — all CertDaxCertificate resources with their sync status, secret name, and which Ingress / IngressRoute references them
  • Recent logs — the operator streams its last log entries to the dashboard

How to Set It Up

  1. Go to K8s Operators → Create in the CertDax dashboard
  2. Give the operator a name (e.g. production-cluster)
  3. An API key and operator token are automatically created
  4. Copy the pre-filled Helm install command from the setup guide
  5. Run it in your cluster — the operator will appear as Online within seconds

Helm Values for Dashboard Integration

ValuePurpose
certdax.operatorTokenAuthenticates heartbeat reports to the CertDax API
certdax.existingOperatorTokenSecretReference an existing K8s secret (key: operator-token) instead of passing the token in values
clusterNameHuman-readable cluster name shown in the dashboard (e.g. production, staging)
Note: Without an operator token, the operator still works — it syncs certificates normally. You just won't see status or certificate info in the CertDax dashboard.

Quick Start: Traefik

This guide shows how to deploy a CertDax certificate for use with Traefik IngressRoute in 3 steps.

Step 1 — Find Your Certificate ID

In the CertDax UI, go to Self-Signed or Certificates and note the certificate ID (visible in the URL or the detail page). For this example we'll use ID 42.

Step 2 — Create the CertDaxCertificate Resource

Save this as webapp-cert.yaml:

webapp-cert.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: webapp-cert
  namespace: default
spec:
  # Certificate ID from CertDax (visible in the UI)
  certificateId: 42
  # "selfsigned" for self-signed/CA-signed, "acme" for Let's Encrypt
  type: selfsigned
  # Name of the TLS secret that will be created
  secretName: webapp-tls
  # Check for renewals every hour
  syncInterval: "1h"
  # Include CA chain in the secret (for CA-signed certs)
  includeCA: true
Terminal
kubectl apply -f webapp-cert.yaml

# Check the status
kubectl get cdxcert
# NAME          TYPE         SECRET       READY   EXPIRES                  AGE
# webapp-cert   selfsigned   webapp-tls   true    2027-04-13T00:00:00Z     10s

Step 3 — Create the Traefik IngressRoute

Reference the TLS secret in your IngressRoute:

webapp-ingress.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: webapp
  namespace: default
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`webapp.example.com`)
      kind: Rule
      services:
        - name: webapp-svc
          port: 80
  tls:
    secretName: webapp-tls
Terminal
kubectl apply -f webapp-ingress.yaml

That's it! Traefik now serves your CertDax certificate. When the certificate is renewed in CertDax, the operator will automatically update the TLS secret and Traefik picks up the change.

Complete Copy-Paste Example

All resources in a single file:

traefik-complete.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: webapp-cert
  namespace: default
spec:
  certificateId: 42
  type: selfsigned
  secretName: webapp-tls
  syncInterval: "1h"
  includeCA: true
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: webapp
  namespace: default
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`webapp.example.com`)
      kind: Rule
      services:
        - name: webapp-svc
          port: 80
  tls:
    secretName: webapp-tls
---
apiVersion: v1
kind: Service
metadata:
  name: webapp-svc
  namespace: default
spec:
  selector:
    app: webapp
  ports:
    - port: 80
      targetPort: 8080

Quick Start: Nginx Ingress

Using the CertDax operator with nginx-ingress is almost identical. The only difference is you use a standard Ingress resource instead of a Traefik IngressRoute.

Step 1 — Create the CertDaxCertificate

api-cert.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: api-cert
  namespace: default
spec:
  certificateId: 10
  type: acme
  secretName: api-tls
  syncInterval: "30m"

Step 2 — Create the Ingress

api-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  namespace: default
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - api.example.com
      secretName: api-tls
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-service
                port:
                  number: 80

Step 3 — Apply and Verify

Terminal
kubectl apply -f api-cert.yaml -f api-ingress.yaml

# Verify
kubectl get cdxcert
kubectl get ingress api-ingress

# Test TLS
curl -v https://api.example.com

Complete Copy-Paste Example

nginx-complete.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: api-cert
  namespace: default
spec:
  certificateId: 10
  type: acme
  secretName: api-tls
  syncInterval: "30m"
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  namespace: default
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - api.example.com
      secretName: api-tls
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-service
                port:
                  number: 80
---
apiVersion: v1
kind: Service
metadata:
  name: api-service
  namespace: default
spec:
  selector:
    app: api
  ports:
    - port: 80
      targetPort: 3000

Traefik: Default Certificate (TLSStore)

You can set a CertDax certificate as the default certificate for Traefik, so all HTTPS requests that don't match a specific IngressRoute get this certificate.

default-cert.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: default-cert
  namespace: traefik
spec:
  certificateId: 1
  type: selfsigned
  secretName: default-tls
  includeCA: true
---
apiVersion: traefik.io/v1alpha1
kind: TLSStore
metadata:
  name: default
  namespace: traefik
spec:
  defaultCertificate:
    secretName: default-tls
Note: The TLSStore named default in the Traefik namespace applies to all entrypoints. The CertDaxCertificate must be in the same namespace as Traefik.

Request Certificates via YAML

Instead of creating certificates in the CertDax UI first and referencing them by ID, the operator can request new certificates directly from a YAML manifest. Simply omit certificateId (or set it to 0) and add a request block.

The operator will call the CertDax API, create the certificate, and automatically sync the resulting TLS secret to your cluster.

Self-Signed Certificate

The simplest option — the operator creates a self-signed certificate with no external dependencies.

selfsigned-request.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: my-selfsigned
  namespace: default
spec:
  type: selfsigned
  secretName: my-selfsigned-tls
  request:
    commonName: myapp.internal
    sanDomains: "myapp.internal,*.myapp.internal"
    validityDays: 365
    autoRenew: true

CA-Signed Self-Signed Certificate

Sign the certificate with an existing CA certificate managed in CertDax. You need the CA certificate ID from the CertDax self-signed certificates page.

ca-signed-request.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: ca-signed-cert
  namespace: default
spec:
  type: selfsigned
  secretName: ca-signed-tls
  includeCA: true
  request:
    commonName: api.internal.example.com
    sanDomains: "api.internal.example.com,grpc.internal.example.com"
    # ID of your CA certificate in CertDax
    caId: 3
    validityDays: 90
    autoRenew: true
Tip: Find your CA certificate ID on the Self-Signed Certificates page in the CertDax dashboard. Only certificates marked as CA can be used here.

Self-Signed CA Certificate

Create a brand new Certificate Authority directly from YAML. Set isCA: true in the request block. You can then reference this CA's ID in other CertDaxCertificate resources using caId.

ca-request.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: internal-ca
  namespace: default
spec:
  type: selfsigned
  secretName: internal-ca-tls
  includeCA: true
  request:
    commonName: My Internal CA
    isCA: true
    validityDays: 3650
    autoRenew: false
Tip: After the CA is created, check kubectl describe cdxcert internal-ca to find the assigned certificateId in the status. Use that ID as caId in other certificate requests.
v2.3.0+ — CA secret format: When the resolved certificate is a CA, the operator writes an Opaque secret with a single ca.crt entry (the CA's public certificate). The CA private key is never sent over the wire and never written to the cluster. Mount it as a trust anchor with subPath: ca.crt.

Mounting the CA secret as a trust anchor

The Opaque secret produced for a CA contains a single ca.crt key. Mount it into your workload (or sidecar / init container) so applications can validate certificates issued by that CA:

deployment-trust-ca.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - name: app
          image: my-app:latest
          volumeMounts:
            # Drop ca.crt straight into the system trust bundle (Debian/Ubuntu).
            # Run `update-ca-certificates` in your image build if you want it baked in,
            # otherwise mount it where your app expects (e.g. /etc/ssl/certs/ca.crt).
            - name: internal-ca
              mountPath: /usr/local/share/ca-certificates/internal-ca.crt
              subPath: ca.crt
              readOnly: true
      volumes:
        - name: internal-ca
          secret:
            secretName: internal-ca-tls
            items:
              - key: ca.crt
                path: ca.crt

For Go / Python / Node apps that read a custom CA bundle via env var, mount the secret at any path and point e.g. SSL_CERT_FILE / NODE_EXTRA_CA_CERTS / REQUESTS_CA_BUNDLE at it.

ACME Certificate (Let's Encrypt)

Request a publicly trusted certificate via ACME (e.g. Let's Encrypt). You need the ACME provider ID from the CertDax providers page. The DNS challenge is handled automatically by CertDax.

acme-request.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: public-cert
  namespace: default
spec:
  type: acme
  secretName: public-tls
  request:
    commonName: www.example.com
    sanDomains: "www.example.com,example.com"
    # ACME provider ID from CertDax dashboard
    providerId: 1
    # DNS provider ID for dns-01 challenge (from Providers page)
    dnsProviderId: 1
    autoRenew: true
Note: ACME certificates are not available immediately. The operator will wait for the certificate to be issued and periodically retry until the ACME challenge is completed. You can monitor the progress with kubectl describe cdxcert public-cert.
Subject fields: You can add optional subject information (country, state, locality, organization, organizational_unit) to any certificate request. These fields are included in the CSR. Note that Let's Encrypt ignores subject fields and only includes the domain name, but other CA providers (e.g. Sectigo, DigiCert, ZeroSSL) may include them in the issued certificate.

Request Block Reference

FieldTypeDefaultDescription
commonNamestringrequiredPrimary domain / CN for the certificate
sanDomainsstring""Comma-separated Subject Alternative Names
providerIdintACME provider ID (required when type: acme)
dnsProviderIdintDNS provider ID for dns-01 challenge (required when type: acme)
caIdintCA certificate ID for CA-signed self-signed certs
isCAboolfalseCreate a CA certificate instead of a regular certificate
autoRenewbooltrueEnable automatic renewal in CertDax
validityDaysint365Validity period (self-signed only, ACME is determined by the CA)
countrystringISO 3166-1 alpha-2 country code (e.g. NL)
statestringState or province name
localitystringCity or locality name
organizationstringOrganization name
organizational_unitstringDepartment / Organizational Unit (OU)

How It Works

  1. You apply a CertDaxCertificate with certificateId: 0 and a request block.
  2. The operator calls POST /api/k8s/certificates/request on the CertDax backend.
  3. CertDax creates the certificate and returns the new certificate ID.
  4. The operator stores the ID in status.certificateId and starts syncing the TLS secret.
  5. For ACME certificates, the operator retries every 30 seconds until the certificate is issued.

CRD Reference

Spec Fields

FieldTypeDefaultDescription
certificateIdint0Certificate ID in CertDax. Set to 0 with a request block to create a new certificate.
typestringselfsignedselfsigned or acme
requestobjectRequest block to create a new certificate (see Request Certificates via YAML)
secretNamestringrequiredName of the TLS secret to create
secretNamespacestringCR namespaceOverride namespace for the secret
syncIntervalstring1hRe-sync interval (e.g. 30m, 1h, 24h)
includeCAbooltrueInclude CA cert in ca.crt field of the secret
secretLabelsmap{}Additional labels for the TLS secret
secretAnnotationsmap{}Additional annotations for the TLS secret

Status Fields

FieldDescription
readyWhether the TLS secret is up to date
certificateIdCertificate ID assigned by CertDax (set after a request)
secretNameName of the managed TLS secret
commonNameCertificate CN
expiresAtCertificate expiry (ISO 8601)
lastSyncedAtLast successful sync time
messageHuman-readable status message
conditionsStandard Kubernetes conditions

TLS Secret Contents

For leaf (non-CA) certificates the operator creates a standard kubernetes.io/tls secret with these keys:

KeyContentWhen
tls.crtPEM-encoded certificateLeaf certs
tls.keyPEM-encoded private keyLeaf certs
ca.crtPEM-encoded CA certificateWhen includeCA: true and cert is CA-signed

CA certificates (v2.3.0+): When the referenced certificate is a CA, the operator instead writes an Opaque secret containing only the public CA cert. The CA private key never leaves CertDax.

KeyContentWhen
ca.crtPEM-encoded CA certificate (public only)Always (for CA certs)

Secret Labels & Annotations

The operator automatically adds these to every managed secret:

TypeKeyValue
Labelapp.kubernetes.io/managed-bycertdax-operator
Labelcertdax.com/certificate-idCertificate ID
Labelcertdax.com/certificate-typeselfsigned or acme
Annotationcertdax.com/common-nameCertificate CN
Annotationcertdax.com/expires-atExpiry timestamp
Annotationcertdax.com/synced-atLast sync timestamp

Advanced Configuration

Helm Values

ValueDefaultDescription
certdax.apiUrl""CertDax backend URL including /api suffix (required)
certdax.apiKey""API key for authentication
certdax.existingSecret""Existing K8s secret with api-key
certdax.operatorToken""Operator token for dashboard heartbeat reporting
certdax.existingOperatorTokenSecret""Existing K8s secret with operator-token
certdax.syncInterval"1h"Default sync interval for all resources
clusterName""Cluster name shown in CertDax dashboard
watchNamespace""Restrict to a single namespace (empty = all)
image.repositoryghcr.io/certdax/certdax-operatorOperator container image
image.taglatestImage tag
image.pullPolicyAlwaysImage pull policy
resources.limits.cpu200mCPU limit
resources.limits.memory128MiMemory limit
resources.requests.cpu100mCPU request
resources.requests.memory64MiMemory request

Cross-Namespace Secrets

By default the TLS secret is created in the same namespace as the CertDaxCertificate. Use secretNamespace to target a different namespace:

cross-namespace.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: shared-cert
  namespace: certdax-system
spec:
  certificateId: 42
  type: selfsigned
  secretName: shared-tls
  # Create the secret in the traefik namespace
  secretNamespace: traefik
Note: Cross-namespace secrets do not get automatic garbage collection (owner references only work within the same namespace). Delete the secret manually when removing the resource.

Custom Labels for Pod Selectors

Add custom labels and annotations to the TLS secret for filtering or integration with other tools:

labeled-cert.yaml
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: labeled-cert
  namespace: default
spec:
  certificateId: 5
  type: selfsigned
  secretName: labeled-tls
  secretLabels:
    environment: production
    team: platform
    app.kubernetes.io/part-of: my-app
  secretAnnotations:
    description: "Production TLS certificate managed by CertDax"

Multiple Certificates

Deploy certificates for multiple services at once:

multi-certs.yaml
# Frontend wildcard certificate
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: frontend-cert
spec:
  certificateId: 10
  type: acme
  secretName: frontend-tls
---
# API certificate
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: api-cert
spec:
  certificateId: 15
  type: acme
  secretName: api-tls
---
# Internal mTLS CA
apiVersion: certdax.com/v1alpha1
kind: CertDaxCertificate
metadata:
  name: internal-ca
spec:
  certificateId: 3
  type: selfsigned
  secretName: internal-ca-tls
  includeCA: true

Troubleshooting

Common kubectl Commands

Terminal
# List all CertDax certificates (short name: cdxcert)
kubectl get cdxcert -A

# Detailed status of a specific certificate
kubectl describe cdxcert webapp-cert

# Watch for changes in real-time
kubectl get cdxcert -w

# Check operator logs
kubectl logs -n certdax-system -l app.kubernetes.io/name=certdax-operator -f

# Verify the TLS secret was created
kubectl get secret webapp-tls -o yaml

# Check TLS secret expiry date
kubectl get secret webapp-tls -o jsonpath='{.metadata.annotations.certdax\.com/expires-at}'

Status Not Ready

MessageCauseSolution
Failed to fetch certificate: API returned 401 Invalid API key Check certdax.apiKey or the referenced secret
Failed to fetch certificate: API returned 404 Certificate ID not found or no permission Verify the certificateId exists in CertDax and the API key has access
Failed to fetch certificate: connection refused Cannot reach CertDax API Check certdax.apiUrl and network connectivity from the cluster
Failed to create secret: forbidden RBAC issue Check that the operator service account has permission to create secrets in the target namespace

Upgrade the Operator

Terminal
helm repo update
helm upgrade certdax-operator certdax/certdax-operator \
  --namespace certdax-system \
  --reuse-values

Uninstall

Terminal
# Remove the operator (TLS secrets managed by the operator remain)
helm uninstall certdax-operator -n certdax-system

# Remove the CRD (this deletes all CertDaxCertificate resources)
kubectl delete crd certdaxcertificates.certdax.com

# Clean up namespace
kubectl delete namespace certdax-system

Internal ACME Server

The built-in certdax-acme-server is an authoritative DNS resolver for _acme-challenge.* and an acme-dns-compatible HTTP API. Together they let lego, certbot, cert-manager, and any other ACME client obtain certificates from the internal OpenBao ACME CA (pki_acme) using standard dns-01 validation — without any external DNS provider, without per-domain CNAME records, and without an external acme-dns instance.

Scope: The internal ACME CA issues certificates for internal names validated on your private network. For publicly-trusted Let's Encrypt certificates use the regular ACME provider flow in the dashboard (DNS-provider integrations under Settings → Providers), or use CertDax as your acme-dns backend for Let’s Encrypt.

Step 1 — Enable the resolver

  1. Go to Settings → Internal ACME Server in the CertDax dashboard.
  2. Toggle Enable resolver on. CertDax records the resolver IP/hostname and the internal ACME directory URL.
  3. Point a DNS delegation in your internal nameserver so that _acme-challenge.yourdomain.internal queries are forwarded to the certdax-acme-server pod/container:
    BIND / Unbound / pfSense example
    # Delegate the _acme-challenge sub-zone to CertDax
    zone "_acme-challenge.yourdomain.internal" {
        type forward;
        forwarders { <certdax-acme-server-ip>; };
    };

    For Kubernetes the pod listens on port 53 inside the cluster. Expose it via a LoadBalancer or NodePort service so your upstream resolver can reach it, or use split-horizon DNS with a CoreDNS forward rule.

Step 2 — Create an EAB credential

Go to Settings → Internal ACME Server → EAB credentials and click New credential. CertDax creates a key-ID (KID) and an HMAC secret and displays them once. Copy both — the HMAC is stored encrypted in OpenBao and can be revealed again by an admin from the same page.

The same EAB credential authorises both the ACME order (presented to the OpenBao ACME CA) and the DNS TXT updates (presented to the certdax-acme-server over the acme-dns API). You need only one credential per issuing identity.

Step 3 — Request a certificate

With lego (recommended)

lego supports the acme-dns provider out of the box. Before running lego you must create a storage file that maps each domain to its acme-dns credentials. Lego reads this file instead of calling the /register endpoint (which CertDax does not expose):

/etc/lego/acme-dns.json
{
  "myapp.internal": {
    "username": "<KID>",
    "password": "<HMAC>",
    "fulldomain": "_acme-challenge.myapp.internal",
    "subdomain": "_acme-challenge.myapp.internal",
    "allowfrom": []
  },
  "*.myapp.internal": {
    "username": "<KID>",
    "password": "<HMAC>",
    "fulldomain": "_acme-challenge.myapp.internal",
    "subdomain": "_acme-challenge.myapp.internal",
    "allowfrom": []
  }
}

Then run lego with ACME_DNS_STORAGE_PATH pointing at that file:

Terminal
# Replace the values in <> with those from the dashboard
ACME_DNS_API_BASE=https://<certdax-host>/acme-dns \
ACME_DNS_STORAGE_PATH=/etc/lego/acme-dns.json \
lego \
  --server https://<certdax-host>/v1/pki_acme/acme/directory \
  --eab --kid <KID> --hmac <HMAC> \
  --email ops@example.com \
  --dns acme-dns \
  --dns.propagation-disable-ans \
  -d myapp.internal -d "*.myapp.internal" \
  run

Add one entry per domain (or wildcard) you want to issue. Both fulldomain and subdomain must include the _acme-challenge. prefix — CertDax keys TXT records by the challenge FQDN. Subsequent renewals use lego renew with the same environment variables and storage file.

With certbot + certbot-dns-acmedns

Install the certbot-dns-acmedns plugin, then write a credentials file:

/etc/certbot/acmedns.json
{
  "<hostname-or-wildcard>": {
    "username": "<KID>",
    "password": "<HMAC>",
    "fulldomain": "_acme-challenge.<hostname>",
    "subdomain": "_acme-challenge.<hostname>",
    "allowfrom": []
  }
}
Terminal
certbot certonly \
  --server https://<certdax-host>/v1/pki_acme/acme/directory \
  --eab-kid <KID> --eab-hmac-key <HMAC> \
  --authenticator dns-acmedns \
  --dns-acmedns-credentials /etc/certbot/acmedns.json \
  --dns-acmedns-api-base https://<certdax-host>/acme-dns \
  -d myapp.internal

With cert-manager (Kubernetes)

Create an Issuer or ClusterIssuer that uses the acme-dns solver and points at the CertDax internal ACME directory:

acmedns-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: certdax-acmedns-credentials
  namespace: cert-manager
type: Opaque
stringData:
  # acmedns.json format expected by cert-manager
  acmedns.json: |
    {
      "*.myapp.internal": {
        "username": "<KID>",
        "password": "<HMAC>",
        "fulldomain": "_acme-challenge.myapp.internal",
        "subdomain": "_acme-challenge.myapp.internal",
        "allowfrom": []
      }
    }
cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: certdax-internal
spec:
  acme:
    server: https://<certdax-host>/v1/pki_acme/acme/directory
    email: ops@example.com
    privateKeySecretRef:
      name: certdax-internal-acme-account
    externalAccountBinding:
      keyID: <KID>
      keySecretRef:
        name: certdax-acmedns-credentials
        key: hmac
    solvers:
      - dns01:
          acmeDNS:
            host: https://<certdax-host>/acme-dns
            accountSecretRef:
              name: certdax-acmedns-credentials
              key: acmedns.json
certificate.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: myapp-internal
  namespace: default
spec:
  secretName: myapp-internal-tls
  issuerRef:
    name: certdax-internal
    kind: ClusterIssuer
  commonName: myapp.internal
  dnsNames:
    - myapp.internal
    - "*.myapp.internal"

How it works end-to-end

  1. Your ACME client places an order against the CertDax OpenBao ACME directory, authenticating with the EAB credential.
  2. The ACME client posts the _acme-challenge TXT token to the CertDax acme-dns HTTP API (/acme-dns/update), authenticated with the same EAB KID + HMAC.
  3. The certdax-acme-server forwards the TXT to the CertDax backend, which validates the HMAC against OpenBao’s EAB store and persists the record.
  4. OpenBao performs a DNS lookup for _acme-challenge.<domain>, which hits the certdax-acme-server DNS listener (:53). It reads the TXT from the CertDax database and returns it.
  5. OpenBao marks the challenge valid, the ACME order completes, and your client downloads the issued certificate.
  6. The dashboard’s ACME Activity tab shows every step in real time.

Directory URL format: https://<certdax-host>/v1/pki_acme/acme/directory — the frontend nginx proxies /v1/pki_acme/ to OpenBao and /acme-dns/ to certdax-acme-server, so you only need the dashboard hostname. Direct OpenBao access (port 8200) is also supported: https://<certdax-host>:8200/v1/pki_acme/acme/directory.

Wildcard certificates require dns-01tls-alpn-01 cannot validate wildcards. The internal ACME server is the recommended path for any *.internal wildcard.

Let’s Encrypt via CertDax acme-dns

certdax-acme-server implements the joohoi/acme-dns protocol, which means it can also act as the dns-01 backend for any public ACME CA — including Let’s Encrypt — for both public and split-horizon domains. You set up a single public NS delegation once; after that you add one permanent CNAME per domain and never touch DNS again.

Step 1 — Public DNS setup (one-time per CertDax deployment)

Pick a sub-zone name for your CertDax deployment, e.g. auth.certdax.example.com. Add two records in your public DNS:

NameTypeValue
ns.auth.certdax.example.com.AYour CertDax host’s public IP
auth.certdax.example.com.NSns.auth.certdax.example.com.

This delegates the auth.certdax.example.com zone to certdax-acme-server. Let’s Encrypt will follow CNAME records into this zone when it validates challenges.

Port 53 behind NAT or Kubernetes? If certdax-acme-server’s DNS listener is not directly reachable on port 53 from the internet (e.g. CertDax runs on a WireGuard/VPN host or inside a Kubernetes cluster with a non-standard NodePort), run an nginx stream proxy on your public IP to forward UDP and TCP port 53:

upstream certdax_dns {
    server 10.8.3.100:53;   # WireGuard / VPN / internal IP of the CertDax host
}
server {
    listen 0.0.0.0:53;
    listen 0.0.0.0:53 udp;
    listen [::]:53;
    listen [::]:53 udp;
    proxy_pass certdax_dns;
    proxy_timeout 300s;
    proxy_connect_timeout 10s;
}

Place this in an nginx stream { } block (not http { }). The A record points at the machine running this proxy, not at the CertDax host itself.

Tell certdax-acme-server about the auth zone so it knows which sub-zone it is authoritative for:

DeploymentSetting
Docker ComposeAdd CERTDAX_ACMEDNS_AUTH_ZONE=auth.certdax.example.com to .env
HelmacmeServer.acmednsAuthZone=auth.certdax.example.com

Step 2 — Create an acme-dns account in CertDax

Go to Settings → DNS Providers → ACME-DNS (built-in / joohoi) and click Register account. CertDax mints a username, password, and UUID tied to the auth zone. Copy the credentials — the password is shown only once.

You’ll receive values like:

Account details (shown once)
username:   a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
password:   <40-char hex string>
fulldomain: cbe2043a-a68d-46c0-ae6f-b982d92625db.auth.certdax.example.com
subdomain:  cbe2043a-a68d-46c0-ae6f-b982d92625db

Step 3 — Add a CNAME per domain (once, permanent)

In your public DNS, add one CNAME for every domain you want to issue a certificate for:

Public DNS (at your registrar / DNS provider)
_acme-challenge.myapp.example.com. CNAME cbe2043a-a68d-46c0-ae6f-b982d92625db.auth.certdax.example.com.

You never need to touch this record again — CertDax updates the TXT value behind it on every renewal.

Step 4 — Request the certificate

Create the storage file for lego’s acme-dns provider. Use the fulldomain and subdomain (UUID) from Step 2:

/etc/lego/acme-dns.json
{
  "myapp.example.com": {
    "username": "<username>",
    "password": "<password>",
    "fulldomain": "cbe2043a-a68d-46c0-ae6f-b982d92625db.auth.certdax.example.com",
    "subdomain": "cbe2043a-a68d-46c0-ae6f-b982d92625db",
    "allowfrom": []
  }
}

Then run lego against the public Let’s Encrypt ACME directory (no --eab flags needed):

Terminal
ACME_DNS_API_BASE=https://certdax.example.com/acme-dns \
ACME_DNS_STORAGE_PATH=/etc/lego/acme-dns.json \
lego \
  --server https://acme-v02.api.letsencrypt.org/directory \
  --email ops@example.com \
  --dns acme-dns \
  -d myapp.example.com \
  run

The same storage file and CNAME work for renewals (lego renew) and for any ACME CA — swap --server for Let’s Encrypt staging, ZeroSSL, Buypass, or your own private CA. No DNS changes are needed between issuances.

How it works

  1. lego places an ACME order with Let’s Encrypt and receives a dns-01 challenge for _acme-challenge.myapp.example.com.
  2. lego’s acme-dns provider POSTs the TXT token to https://certdax.example.com/acme-dns/update, authenticated with the username/password from the storage file.
  3. CertDax stores the TXT value and certdax-acme-server serves it for the cbe2043a-….auth.certdax.example.com DNS name.
  4. Let’s Encrypt resolves _acme-challenge.myapp.example.com → CNAME → cbe2043a-….auth.certdax.example.com → TXT record. Challenge passes.
  5. Let’s Encrypt issues the certificate. lego writes it to .lego/certificates/.

API Endpoints

Authentication

EndpointMethodDescription
/api/auth/registerPOSTCreate first admin account
/api/auth/loginPOSTLogin (returns JWT token)

ACME Certificates

EndpointMethodDescription
/api/certificatesGETList ACME certificates
/api/certificates/requestPOSTRequest new ACME certificate (supports country, state, locality, organization, organizational_unit subject fields)
/api/certificates/{id}/renewPOSTRenew ACME certificate
/api/providers/casGETList Certificate Authorities
/api/providers/dnsGET/POSTManage DNS providers

Self-Signed & CA-Signed Certificates

EndpointMethodDescription
/api/self-signedGETList certificates (filter: ?is_ca=true, ?search=)
/api/self-signedPOSTCreate self-signed or CA-signed certificate
/api/self-signed/{id}GETGet certificate details (incl. PEM)
/api/self-signed/{id}DELETEDelete certificate (?force=true)
/api/self-signed/{id}/renewPOSTRenew certificate (?validity_days=365)
/api/self-signed/{id}/parsedGETParsed X.509 certificate details
/api/self-signed/{id}/download/zipGETDownload cert + key as ZIP
/api/self-signed/{id}/download/pem/{type}GETDownload PEM (certificate, privatekey, combined, chain, ca)
/api/self-signed/{id}/download/pfxGETDownload as PFX/PKCS#12

Agents & Deployment

EndpointMethodDescription
/api/agentsGET/POSTManage deploy agents
/api/agent-groupsGET/POSTManage agent groups
/api/agent/pollGETAgent: fetch pending deployments
/api/agent/heartbeatPOSTAgent: heartbeat

Kubernetes Operator

EndpointMethodDescription
/api/k8s/certificate/selfsigned/{id}GETFetch self-signed certificate + key for K8s
/api/k8s/certificate/acme/{id}GETFetch ACME certificate + key + chain for K8s
/api/k8s/certificates/requestPOSTRequest new certificate (supports country, state, locality, organization, organizational_unit subject fields)

Self-Signed Certificate API Examples

Create a self-signed certificate:

Terminal
curl -X POST https://certdax.example.com/api/self-signed \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "common_name": "myserver.local",
    "san_domains": ["*.myserver.local"],
    "organization": "MyCompany",
    "country": "NL",
    "key_type": "ec",
    "key_size": 256,
    "validity_days": 365
  }'

Create a CA certificate:

Terminal
curl -X POST https://certdax.example.com/api/self-signed \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "common_name": "My Internal CA",
    "organization": "MyCompany",
    "country": "NL",
    "key_type": "ec",
    "key_size": 256,
    "validity_days": 3650,
    "is_ca": true
  }'

Sign a certificate with an existing CA:

Terminal
curl -X POST https://certdax.example.com/api/self-signed \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "common_name": "app.internal",
    "san_domains": ["app.internal", "api.internal"],
    "organization": "MyCompany",
    "key_type": "ec",
    "key_size": 256,
    "validity_days": 365,
    "ca_id": 1
  }'

Set ca_id to the ID of a certificate created with "is_ca": true. The certificate will be signed by that CA instead of being self-signed. The CA chain is automatically included in ZIP and PFX downloads.

Request Body Reference (POST /api/self-signed)

FieldTypeDefaultDescription
common_namestringrequiredCertificate CN (e.g. myserver.local)
san_domainsstring[]nullAdditional Subject Alternative Names
organizationstringnullOrganization (O)
organizational_unitstringnullOrganizational Unit (OU)
countrystringnullCountry code (C), e.g. NL
statestringnullState/Province (ST)
localitystringnullCity (L)
key_typestringecrsa or ec
key_sizeint256RSA: 2048/4096, EC: 256/384
validity_daysint365Certificate validity (1–3650 days)
is_caboolfalseCreate a CA certificate
ca_idintnullID of CA to sign with (omit for self-signed)
auto_renewboolfalseEnable automatic renewal
renewal_threshold_daysintnullDays before expiry to auto-renew (default: 30)
custom_oidsobject[]nullCustom OIDs: [{"oid": "1.3.6...", "value": "desc"}]

Scaling (Multi-Node)

CertDax supports horizontal scaling with multiple backend replicas:

  • Distributed locking — Scheduled tasks use database-backed locks so only one instance executes them
  • Atomic status transitions — Certificate processing uses atomic database updates to prevent race conditions
  • Stateless API — JWT authentication is stateless; any replica can serve any request
  • PostgreSQL required — SQLite only supports single-node; use PostgreSQL for multi-node

Requirements for multi-node

SettingWhy
ENCRYPTION_KEYMust be identical across all replicas
SECRET_KEYMust be identical for JWT validation
DATABASE_URLMust point to a shared PostgreSQL instance

Docker Swarm

Terminal
# Build and push images
docker compose build
docker tag certdax-backend registry.example.com/certdax-backend:latest
docker tag certdax-frontend registry.example.com/certdax-frontend:latest
docker push registry.example.com/certdax-backend:latest
docker push registry.example.com/certdax-frontend:latest

# Deploy as a stack (scales backend replicas)
docker stack deploy -c docker-compose.yml certdax
docker service scale certdax_backend=3

Kubernetes

Use the Docker images with a standard deployment. Key points:

  • Store SECRET_KEY, ENCRYPTION_KEY, DB_PASSWORD in a K8s Secret
  • Use a Deployment with multiple replicas for the backend
  • Point DATABASE_URL to a managed PostgreSQL (e.g. CloudSQL, RDS)

Testing Autoscaling (Podman + Kind)

The Helm chart includes optional HorizontalPodAutoscaler resources for the backend and frontend. Enable them with backend.autoscaling.enabled=true and/or frontend.autoscaling.enabled=true. You can test autoscaling locally using Podman as the container runtime for a Kind cluster.

Prerequisites: podman, kind, kubectl, helm

1. Create a Kind cluster on Podman

Terminal
KIND_EXPERIMENTAL_PROVIDER=podman kind create cluster --name certdax-test

2. Install metrics-server (required for HPA)

Terminal
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

# Patch for Kind (no real TLS certs)
kubectl patch deployment metrics-server -n kube-system \
  --type=json \
  -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'

3. Install CertDax with autoscaling enabled

Terminal
helm repo add certdax https://charts.certdax.com
helm repo update

helm install certdax certdax/certdax \
  --namespace certdax --create-namespace \
  --set certdax.secretKey="$(python3 -c 'import secrets; print(secrets.token_urlsafe(64))')" \
  --set postgresql.auth.password="testpassword123" \
  --set backend.autoscaling.enabled=true \
  --set backend.autoscaling.targetCPUUtilizationPercentage=50 \
  --set frontend.autoscaling.enabled=true \
  --set ingress.enabled=false

4. Access the frontend

Since ingress is disabled, use kubectl port-forward to access CertDax:

Terminal
kubectl port-forward svc/certdax-frontend 8081:80 -n certdax

Then open http://localhost:8081 in your browser.

5. Verify HPA is running

Terminal
kubectl get hpa -n certdax
# Wait ~60s for metrics-server to start reporting, then watch:
kubectl get hpa -n certdax -w

6. Generate load to trigger scaling

Terminal
# In another terminal, generate traffic against the backend
kubectl run -n certdax load-gen --rm -i --tty \
  --image=busybox -- /bin/sh -c \
  "while true; do wget -q -O- http://certdax-backend:8000/health; done"

Watch the HPA scale up with kubectl get hpa -n certdax -w. You should see TARGETS rise and REPLICAS increase.

7. Cleanup

Terminal
KIND_EXPERIMENTAL_PROVIDER=podman kind delete cluster --name certdax-test

Security

  • Customer private keys encrypted with Vault Transit envelope encryption — key material never leaves OpenBao
  • Application secrets (SMTP, OIDC, locks, bootstrap state) stored in OpenBao KV v2
  • Agents and Kubernetes operators authenticate to the backend over mTLS using short-lived certs issued by the internal Vault PKI
  • Backend logs in to Vault via Kubernetes-auth (in-cluster) or AppRole (Docker / VM) — no static long-lived tokens in production
  • User passwords hashed with bcrypt
  • Agent enrolment uses one-time install tokens; long-lived bearer tokens are replaced with mTLS at first heartbeat
  • JWT tokens for web authentication; API keys for programmatic access
  • Full audit log for sensitive operations (create / revoke / rotate / sign-in) with PDF export
  • CORS configurable per environment; Swagger/OpenAPI docs disabled in production
  • Non-root container user for backend; pod-level securityContext and read-only root filesystem on the operator
  • Distributed locking for cluster-safe scheduled tasks

Exposing port 10443 to the internet

CertDax is designed around a single inbound TLS port (10443 by default). To many operators that initially looks alarming — "you want me to put my certificate manager on the public internet?" — so this section explains exactly what is and isn't reachable on that port, and why the design is safe.

What runs on :10443

One nginx terminator with ssl_verify_client optional and the following routes:

PathmTLS required?What it serves
/api/agent/*Yes401 without a valid client certAgent runtime API (heartbeats, certificate pickup)
/api/k8s-operator/*YesKubernetes operator runtime API
/api/* (rest)NoDashboard / browser API — protected by JWT or API-key auth
/openbao/*No (at TLS layer)OpenBao reverse-proxy — auth enforced by Vault tokens
/health, /versionNoLiveness / version banner
/, /assets/*NoSingle-page dashboard frontend

What an attacker can see without credentials

  • TLS handshake metadata — server certificate, cipher suites, ALPN. Identical to any other HTTPS endpoint.
  • /health and /version — liveness and version string. Useful for fingerprinting (so is every other patched-or-not service on the internet).
  • The dashboard SPA at / — HTML/JS bundle. The only meaningful disclosure is "this is a CertDax instance". No secrets, no user data.
  • /api/* (browser-side) — reachable, but every endpoint requires a valid session JWT or API key. Without credentials you get 401/403.
  • /openbao/* — reachable, but OpenBao itself enforces auth. Externally issued tokens carry policies scoped to their own agent or operator paths only; transit/* (where customer keys are encrypted) is not in any externally issued policy. No token, no data.
  • /api/agent/* and /api/k8s-operator/* — immediate 401 without a client certificate signed by the internal CA.

What an attacker cannot do

  • Reach agent endpoints without a certificate signed by your internal CA. That CA lives inside OpenBao and is not issued via :10443 — only through the bootstrap flow, which itself requires a JWT-authenticated dashboard session to mint a one-shot wrap token.
  • Pull customer secrets out of OpenBao, even with a valid agent client cert. The cert grants access to kv/agents/<id>/* (that agent's own credentials) and pki/issue/<role> (cert issuance). It does not grant access to transit/* or to any other agent's data.
  • Use the dashboard without a valid login (local password + bcrypt, or OIDC).

Residual risks & recommended hardening

  1. Information disclosure via the SPA and version banner. Anyone can tell you're running CertDax. Mitigate by hiding the Server header and putting /version behind auth if you want a low-profile deployment.
  2. Confidentiality of the intermediate CA. The whole model relies on that CA's private key staying in OpenBao. Keep encrypted backups, enable Vault audit logging, and restrict who can unseal Vault.
  3. TLS-stack CVEs in nginx / OpenBao. Patch discipline. Port 10443 is just another HTTPS target for scanners.
  4. DDoS / connection exhaustion. The default nginx config has no rate limit. For public-internet deployments, sit behind Cloudflare (or a Cloudflare Tunnel so the backend stays on a private IP) or add limit_req_zone rules.
  5. Reduce the "loud" attack surface. ssl_verify_client optional currently accepts any inbound connection up to the application-level 401 check. You can move /api/agent/ and /api/k8s-operator/ into a dedicated server block on a separate SNI with ssl_verify_client on; the result is identical, but unauthenticated clients are dropped at handshake time.
  6. Enforce TLS 1.2+. Verify your ssl_protocols directive doesn't allow legacy versions.
  7. Audit log shipping. Forward OpenBao's audit log to a central sink and alert on any 403/404 against /openbao/* paths that are not in any policy — that's the canary for someone probing the Vault proxy.

Bottom line

Exposing :10443 is acceptable by design — it is the single inbound endpoint the architecture was built around. Sensitive paths are cryptographically gated (mTLS for agents/operators, Vault tokens for OpenBao, JWT/API key for the dashboard). What you publicly disclose is functionally limited to "a CertDax instance is running here, version X.Y.Z".

FAQ

How can I verify that ACME and self-signed certificates are stored in OpenBao?

Short answer: every certificate's private key is held as a Vault Transit ciphertext (vault:v1:...) in the database, and the corresponding plaintext only ever exists in memory inside the backend after a Transit decrypt round-trip. Both ACME-issued certificates and CA-signed self-signed certificates use the same customer-certs Transit key.

Three independent ways to verify, from cheapest to most thorough:

1. Inspect the database column directly

The two relevant tables are certificates (ACME + self-signed leaves) and selfsigned_authorities (the local CA private keys). The private-key column for both is private_key_pem_encrypted and must always start with the Vault Transit prefix vault:v1:. If you ever see raw -----BEGIN in that column, secret storage is mis-configured.

Terminal (Docker Compose)
# ACME-issued or self-signed leaves
docker compose exec db psql -U certdax -d certdax -c \
  "SELECT id, common_name, LEFT(private_key_pem_encrypted, 12) AS prefix
     FROM certificates
    WHERE private_key_pem_encrypted IS NOT NULL
    ORDER BY id DESC LIMIT 10;"

# Self-signed CA private keys
docker compose exec db psql -U certdax -d certdax -c \
  "SELECT id, name, LEFT(private_key_pem_encrypted, 12) AS prefix
     FROM selfsigned_authorities
    WHERE private_key_pem_encrypted IS NOT NULL;"

The prefix column should be exactly vault:v1: for every row. Anything else (especially -----BEGIN) means the row was written before Vault was available, or the backend fell through to a misconfigured fallback — treat it as a bug and re-issue the certificate.

2. Check the Transit key exists and is being used

The encryption side of the trip lands in OpenBao's audit log every time a private key is wrapped or unwrapped. If you have a fresh certificate request, you should see a matching transit/encrypt/customer-certs entry within seconds.

Terminal (Docker Compose)
# bao calls require a token; pull it from your recovery file
ROOT=$(docker run --rm -v certdax_openbao-keys:/k alpine:3 \
  sh -c 'cat /k/init.json' | jq -r .root_token)

# Confirm the Transit key is mounted
docker compose exec -e VAULT_TOKEN="$ROOT" openbao \
  bao read transit/keys/customer-certs

# Watch the audit log while you issue a certificate from the UI
# (the audit log is a plain file — no token needed for tail)
docker compose exec openbao tail -f /openbao/audit/audit.log \
  | grep -E '"path":"transit/(encrypt|decrypt)/customer-certs"'

You should see a JSON record with "type":"request" immediately followed by "type":"response" for every issue / renewal / download. The audit log never contains the plaintext — only HMAC'd metadata — so it's safe to ship to a central sink (see the security section).

3. Round-trip a known certificate end-to-end

The strongest check is to download a certificate that you just issued and confirm the private key the backend hands back is byte-for-byte the one OpenBao decrypted, not a residual on-disk copy. From the dashboard:

  1. Issue a certificate (ACME or self-signed)
  2. Download the .zip bundle from the certificate detail page
  3. Run the backend's Transit decrypt against the row's ciphertext and diff the two PEMs:
Terminal (Docker Compose)
CERT_ID=42  # the ID from the dashboard URL

CT=$(docker compose exec -T db psql -U certdax -d certdax -tAc \
  "SELECT private_key_pem_encrypted FROM certificates WHERE id=$CERT_ID")

docker compose exec -T backend python -c "
import sys
from app.services.secret_store import decrypt_private_key
sys.stdout.write(decrypt_private_key('$CT'))" \
  | diff - downloads/certificate-$CERT_ID/privkey.pem && echo "MATCH"

A MATCH proves the bundle the user downloads is reconstructed from Vault on demand — the plaintext private key is not persisted anywhere outside OpenBao's encrypted storage.

What about ACME account keys, EAB credentials, DNS / SMTP / OIDC secrets?

Those don't go through Transit; they live under the KV v2 mount (secret/data/app/...). All bao calls need a token — export the root token from your recovery file first:

Terminal (Docker Compose)
# Pull the root token straight out of the openbao-keys volume
ROOT=$(docker run --rm -v certdax_openbao-keys:/k alpine:3 \
  sh -c 'cat /k/init.json' | jq -r .root_token)

docker compose exec -e VAULT_TOKEN="$ROOT" openbao bao kv list secret/app
docker compose exec -e VAULT_TOKEN="$ROOT" openbao bao kv list secret/app/providers
docker compose exec -e VAULT_TOKEN="$ROOT" openbao bao kv list secret/app/acme

Every provider, ACME CA, EAB credential, and SMTP/OIDC secret has its own KV entry under these prefixes. Same audit-log story: every read shows up as "path":"secret/data/app/...".

Helm / Kubernetes

Replace docker compose exec openbao with kubectl exec -it -n certdax sts/<release>-openbao -- and docker compose exec db with kubectl exec -it -n certdax sts/<release>-postgresql -- . Everything else is identical.

How do I view and ship the OpenBao audit log?

The audit device is enabled declaratively in openbao/server.hcl and writes JSON Lines to the openbao-audit volume at /openbao/audit/audit.log. Every API call (reads, writes, login attempts, policy changes) lands there with HMAC'd request/response bodies — safe to ship to a SIEM.

Terminal (Docker Compose)
# Tail live
docker compose exec openbao tail -f /openbao/audit/audit.log

# Pretty-print recent failures (auth errors, denied paths)
docker compose exec openbao sh -c \
  "tail -200 /openbao/audit/audit.log" \
  | jq -c 'select(.error != null or (.response.data.error // empty) != \"\")'

# Forward to journald via a sidecar (compose snippet)
logging:
  driver: journald
  options:
    tag: openbao-audit

For a central sink, mount the volume into a Vector / Promtail / Filebeat container reading /openbao/audit/audit.log and ship to Loki, Elasticsearch, or your log warehouse of choice. Rotate with logrotate on the host (the file is append-only and grows linearly).

I lost init.json / the recovery file. Now what?

If the host is still running and OpenBao is unsealed, you can re-export the same shares from the live keys volume:

Terminal (Docker Compose)
cd /opt/certdax
make recovery-export FILE=~/certdax-recovery.json
# move the file off-host immediately

If the host is gone and you never exported the recovery file and you have no backup of the openbao-keys volume: the data in openbao-data is unrecoverable. Every customer private key encrypted with the customer-certs Transit key is permanently sealed. The only path forward is to:

  1. Bring up a fresh CertDax instance (new init, new shares)
  2. Re-issue every certificate from its CA (ACME re-orders, self-signed re-signs)
  3. Roll any DNS / SMTP / OIDC / EAB credential that was stored in KV

This is exactly what the Save your recovery file step in Quick Start is designed to prevent — do it before going to production.

How do I rotate the OpenBao root token?

The bootstrap root token is generated at init and stored in the recovery file. CertDax itself does not use the root token at runtime — the backend authenticates with AppRole, agents with response-wrapped tokens, the K8s operator with Kubernetes auth. So you can rotate the root token at any time without restarting any service:

Terminal (Docker Compose)
# 1. Authenticate with the current root token from your recovery file
ROOT=$(jq -r .root_token ~/certdax-recovery.json)
docker compose exec -e VAULT_TOKEN="$ROOT" openbao bao token lookup

# 2. Generate a new root token via the rekey-style operator workflow
docker compose exec -e VAULT_TOKEN="$ROOT" openbao \
  bao operator generate-root -init
# Follow the prompts: paste 3 of 5 unseal shares, get the new token

# 3. Revoke the old one
docker compose exec -e VAULT_TOKEN="$NEW_ROOT" openbao \
  bao token revoke "$ROOT"

# 4. Update the recovery file: replace .root_token, re-export off-host

Same procedure under Helm: kubectl exec -n certdax sts/<release>-openbao-0 -- instead of docker compose exec openbao, and pull the token out of secret/<release>-openbao-keys.

How do I rotate the customer-certs Transit key?

OpenBao Transit supports versioned keys: rotate creates a new version, new encrypts use it, old ciphertexts stay readable. To re-wrap existing ciphertexts onto the new version, run rewrap on each row.

Terminal (Docker Compose)
# 1. Bump the key version
docker compose exec openbao bao write -f transit/keys/customer-certs/rotate

# 2. Optional: set a min-decrypt-version once you've re-wrapped, so
#    older ciphertexts can no longer be decrypted (defence in depth).
docker compose exec openbao bao write \
  transit/keys/customer-certs/config min_decryption_version=2

Per-row re-wrap via the OpenBao API (idempotent — safe to re-run):

Terminal (Docker Compose)
ROOT=$(jq -r .root_token ~/certdax-recovery.json)

docker compose exec -T -e VAULT_TOKEN="$ROOT" db \
  psql -U certdax -d certdax -tAc \
    "SELECT 'certificates', id, private_key_pem_encrypted FROM certificates
       WHERE private_key_pem_encrypted IS NOT NULL
     UNION ALL
     SELECT 'selfsigned_authorities', id, private_key_pem_encrypted
       FROM selfsigned_authorities
       WHERE private_key_pem_encrypted IS NOT NULL" \
  | while IFS='|' read -r table id ct; do
      new=$(docker compose exec -T -e VAULT_TOKEN="$ROOT" openbao \
        bao write -field=ciphertext transit/rewrap/customer-certs \
          ciphertext="$ct")
      docker compose exec -T db psql -U certdax -d certdax -c \
        "UPDATE $table SET private_key_pem_encrypted='$new' WHERE id=$id"
    done

Schedule this annually (or after any suspected breach of an unseal share). Audit-log evidence: you'll see one transit/rewrap/customer-certs entry per row.