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.
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.
# 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:
# 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:
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:
# 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.
# 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):
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:
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
| Value | Default | Description |
|---|---|---|
certdax.secretKey | "" | JWT signing key (auto-generated on first install if empty) |
certdax.existingSecret | "" | Existing K8s secret with secret-key |
certdax.acmeContactEmail | admin@example.com | ACME contact email |
certdax.externalDatabaseUrl | "" | External database URL (when postgresql.enabled=false) |
postgresql.enabled | true | Deploy built-in PostgreSQL |
postgresql.auth.password | "" | PostgreSQL password (required when enabled) |
postgresql.auth.existingSecret | "" | Existing secret with postgresql-password key |
postgresql.storage.size | 5Gi | Database PVC size |
postgresql.storage.storageClass | "" | Storage class (empty = cluster default) |
openbao.enabled | true | Deploy a built-in OpenBao instance (Vault-compatible) |
openbao.replicaCount | 1 | OpenBao replicas. Set to 3+ together with storage.type=raft for HA |
openbao.storage.type | file | file (single node, PVC) or raft (multi-replica HA) |
openbao.storage.size | 4Gi | OpenBao PVC size |
openbao.init.keyShares | 1 | Shamir unseal key shares (raise for production) |
openbao.init.keyThreshold | 1 | Threshold of shares required to unseal |
openbao.externalAddr | "" | External Vault URL (required when openbao.enabled=false) |
vault.authMethod | kubernetes | How the backend authenticates to Vault (kubernetes, approle, token) |
vault.k8sRole | certdax-backend | Vault Kubernetes-auth role name |
vault.kvMount | secret | KV v2 mount used for app secrets |
vault.transitMount | transit | Transit mount used to encrypt customer private keys |
vault.pkiMount | pki_internal | Internal PKI mount used to issue mTLS certs to agents/operators |
ingress.enabled | true | Create an Ingress resource |
ingress.className | "" | Ingress class (nginx, haproxy, traefik, etc.) |
ingress.host | certdax.example.com | Hostname for the Ingress |
ingress.tls.enabled | false | Enable TLS on the Ingress |
ingress.tls.secretName | "" | TLS secret name |
backend.replicaCount | 1 | Backend replicas |
frontend.replicaCount | 1 | Frontend 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:
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 intoconfig.yamlso 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:
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 cluster —
openbao.replicaCount=3withstorage.type=raft. The init Job bootstraps pod-0 as leader and joins the others automatically. Quorum survives a single node loss. - Auto-unseal sidecar —
openbao.autoUnseal.enabled=trueruns 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 nexthelm upgrade. - Shamir 3-of-5 keys —
init.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.) - PodDisruptionBudgets —
maxUnavailable: 1for both OpenBao and backend, so akubectl drainon 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:
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 asecret_idto authenticate.wrap_token— A one-time-use, 10-minute Vault cubbyhole token that wraps thesecret_id. The rawsecret_idnever 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:
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:
_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
| Value | Default | Description |
|---|---|---|
acmeServer.enabled | false | Deploy the acme-server pod |
acmeServer.resolverDomain | acme.certdax.internal | Authoritative 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.pkiMount | pki_internal | PKI secrets engine mount |
acmeServer.vault.pkiRole | acme-server | PKI role name |
acmeServer.vault.pkiCertCN | acme-server.certdax.internal | Certificate CN verified by the backend |
acmeServer.vault.certTTL | 24h | Certificate lifetime (Go duration string) |
acmeServer.vault.caChainPEM | "" | Bootstrap CA chain PEM (first boot only; reloaded from disk thereafter) |
acmeServer.persistence.size | 10Mi | PVC 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:
| Variable | Required | Default | Description |
|---|---|---|---|
SECRET_KEY | Yes | — | JWT signing key. Must be identical across all replicas |
VAULT_ADDR | Yes | http://openbao:8200 | OpenBao / Vault address. CertDax cannot start without it — there is no local-crypto fallback |
VAULT_AUTH_METHOD | Yes | token | How the backend logs in to Vault: token (dev), approle (Docker / VM) or kubernetes (K8s) |
VAULT_TOKEN | If token auth | — | Static Vault token (development / testing only) |
VAULT_ROLE_ID | If approle | — | AppRole role-id, written by the bootstrap process |
VAULT_SECRET_ID_FILE | If approle | — | Path to a file containing the AppRole secret_id (preferred over env) |
VAULT_K8S_ROLE | If kubernetes | certdax-backend | Kubernetes-auth role name |
VAULT_KV_MOUNT | No | secret | KV v2 mount used for app secrets |
VAULT_TRANSIT_MOUNT | No | transit | Transit mount used to encrypt customer private keys |
VAULT_PKI_MOUNT | No | pki_internal | Internal PKI mount used to issue mTLS certs to agents and operators |
VAULT_AGENT_ADDR | No | VAULT_ADDR | Address agents/operators use to reach Vault. Set to the externally-reachable URL in split-network deployments |
DB_PASSWORD | Docker only | — | PostgreSQL password (Docker Compose sets up the database automatically) |
DATABASE_URL | No | sqlite:///./data/certdax.db | Database connection string. Use PostgreSQL for production |
ACME_CONTACT_EMAIL | No | admin@example.com | Contact email for ACME certificate requests |
JWT_EXPIRY_MINUTES | No | 1440 | JWT token lifetime in minutes |
RENEWAL_CHECK_HOURS | No | 12 | How often to check for certificates needing renewal |
RENEWAL_THRESHOLD_DAYS | No | 30 | Default days before expiry to trigger auto-renewal |
CORS_ORIGINS | Yes | — | Comma-separated list of allowed frontend origins |
API_BASE_URL | No | Auto-detected | Public backend URL (used in agent install scripts) |
FRONTEND_URL | Yes | — | Public frontend URL (used in password reset emails) |
AGENT_BINARIES_DIR | No | agent-dist | Directory containing agent binaries |
DEBUG | No | false | Enable 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:
- Creates a StatefulSet with a PVC for storage
- Runs a
post-installJob that initialises the cluster and writes the Shamir unseal keys + root token to a Kubernetes Secret (<release>-openbao-keys) - Unseals every replica and joins followers to the raft quorum (when
storage.type=raft) - Runs a second Job that bootstraps the KV / Transit / PKI mounts, the Kubernetes auth method, and the role used by the backend
- Mounts the Vault address and auth role into the backend Deployment
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):
# 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:
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)
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:
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.
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 secret | Where it lives | What an attacker with read access gets |
|---|---|---|
| Customer / agent private keys | OpenBao KV v2, encrypted with Transit envelope keys | Ciphertext 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 only | A 72-hour client cert at most; rotated automatically at 50% lifetime; backend will not honour an unknown serial |
| Vault root token / unseal keys | Kubernetes Secret <release>-openbao-keys after install | Full 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 once | Hashes 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 persisted | Verification 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-token | One-time-use; unwrap consumes it. Late readers see "wrapping token already used" |
Risk analysis
| Threat scenario | Mitigation |
|---|---|
| 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 compromise | Agent 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 logs | Use 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 compromise | This 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 token | Token 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 ↔ backend | Agent 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 access | Reads 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
- Open the application and create the first admin account
- Go to Settings → configure SMTP for email notifications (optional)
- Go to Settings → configure OIDC/SSO for single sign-on (optional)
- Go to Providers and add a DNS provider (e.g. Cloudflare) and/or Certificate Authority
- Go to Certificates → New certificate and request your first ACME certificate
- Go to Self-Signed to generate internal certificates or create a CA
- (Optional) Set up Agents and install the deploy agent on your servers — see Linux or Windows
- (Optional) Go to API to create API keys for scripting and automation
Development Setup
Backend
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
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.
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
# 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:
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
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
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
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:
- Go to Agents in the CertDax dashboard
- Click Add agent and select Windows as the OS type
- Select a Self-Signed CA to code-sign the agent binary (this suppresses SmartScreen warnings)
- Fill in the agent name and hostname, then click Create
- Click the Install button on the newly created agent
- Copy the PowerShell one-liner and run it in an elevated PowerShell session on the target machine
# 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
.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 → Install → Advanced / scripted install options → certdax-agent.exe (signed)
2. Place the binary
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
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
# 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
# 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
| Method | When to use |
|---|---|
| PowerShell one-liner | Interactive 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 binary | Air-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
.exe→ Properties → Unblock → OK
Certificate Deployment Path
By default the agent deploys certificates to:
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:
| Format | Files written | Use case |
|---|---|---|
crt | <name>.crt, <name>.key, <name>.fullchain.crt, <name>.chain.crt | Default. 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. |
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.
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.
| URL | OS | Service installer |
|---|---|---|
GET {CERTDAX_URL}/api/agents/install/ansible.sh | Linux | systemd |
GET {CERTDAX_URL}/api/agents/install/ansible.ps1 | Windows | Windows service |
GET {CERTDAX_URL}/api/agents/install/ansible-uninstall.sh | Linux | cleanup |
GET {CERTDAX_URL}/api/agents/install/ansible-uninstall.ps1 | Windows | cleanup |
What the scripts do, in order:
- Detect identity — read the system FQDN, derive the agent name from the first label (e.g.
dietpi.local→ namedietpi,WIN-FOO.corp.local→WIN-FOO). - Create the agent via
POST /api/agentsusing your API key (CertDax → API Keys). - Download and execute the existing per-agent install script — the same flow as clicking Install in the dashboard.
Linux example
- 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
- 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
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:
ansible/
├── playbook.yml
├── inventory.yml
└── group_vars/
└── all/
├── vars.yml # non-secret config, safe to commit
└── vault.yml # encrypted with `ansible-vault encrypt`
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 }}"
vault_certdax_api_key: "ck_live_xxxxxxxxxxxxxxxxxxxx"
Encrypt the vault file once with:
ansible-vault encrypt group_vars/all/vault.yml
Run the playbook with the vault password:
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:
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
| Variable | Default | Purpose |
|---|---|---|
CERTDAX_URL | required | Backend base URL |
CERTDAX_API_KEY | required | API key with agent-create permission |
CERTDAX_CA_ID | required (Windows only) | Code-signing CA for the agent .exe |
CERTDAX_NAME | first label of hostname | Override the auto-detected agent name |
CERTDAX_HOSTNAME | system FQDN | Override 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_GROUPS | unset | Comma-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.
all:
children:
webservers:
hosts: { web-01: {}, web-02: {} }
haproxy:
hosts: { lb-01: {}, lb-02: {} }
prod:
children: { webservers: {}, haproxy: {} }
- 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.
- 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
- 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 / flag | Purpose |
|---|---|
CERTDAX_URL + CERTDAX_API_KEY | Optional. When both are set the agent record is also deleted from CertDax. Without them the script does a pure local cleanup. |
CERTDAX_NAME / --name / -Name | Override the agent name used to look up the record server-side (default: first label of hostname). |
CERTDAX_HOSTNAME / --hostname / -Hostname | Match agents by this hostname instead of name — useful if you customised --hostname during install. |
--keep-remote / -KeepRemote | Skip the API delete; only clean up locally. |
--keep-local / -KeepLocal | Skip local cleanup; only delete the record from CertDax. |
DNS Provider Configuration
Cloudflare
{
"api_token": "your-cloudflare-api-token"
}
Create an API token in Cloudflare with Zone:DNS:Edit permissions.
TransIP
{
"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
{
"api_token": "your-hetzner-dns-api-token"
}
Create an API token in the Hetzner DNS Console.
DigitalOcean
{
"api_token": "your-digitalocean-api-token"
}
Create a personal access token with read/write scope.
Vultr
{
"api_key": "your-vultr-api-key"
}
Create an API key in the Vultr customer portal.
OVH
{
"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
{
"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
{
"project_id": "your-gcp-project-id",
"service_account_json": "{...}"
}
Create a service account with the DNS Administrator role and export the JSON key.
Manual
{}
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.
./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.
:10443 and your reverse proxy ends up in a redirect loop.
# 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.)
# 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.
sudo certbot --nginx -d certdax.example.com
Apache2
Enable the required modules first:
sudo a2enmod proxy proxy_http ssl rewrite headers
sudo systemctl restart apache2
# 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.
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.
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
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:
- The dashboard via standard HTTP reverse proxy (Let's Encrypt termination on the VPS).
- 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):
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):
# 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:
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:
curl -kv https://certdax.example.com:10443/health
# Expected: TLS handshake with cert issued by "CertDax Internal CA" (NOT Let's Encrypt).
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.
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:
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:
# 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.
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.
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:
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:'
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):
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):
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):
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:
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 {
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:
ufw allow 10443/tcp
ufw allow 53/udp
- You cannot mix
http {}server blocks andstream {}server blocks on the same port. Pick one per port. ssl_prereadonly 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
locationmatching instream {}. There are no paths — only ports and SNI. - For the CertDax mTLS port specifically: do not use
ssl_modulein 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.
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
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
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.
export KIND_EXPERIMENTAL_PROVIDER=podman
echo 'export KIND_EXPERIMENTAL_PROVIDER=podman' >> ~/.bashrc
~/.bashrc with ~/.zshrc.
Step 3 — Create a Local Kubernetes Cluster
This creates a mini Kubernetes cluster running inside a Podman container on your machine.
kind create cluster --name certdax-demo
Check that it's running:
kubectl get nodes
You should see something like:
NAME STATUS ROLES AGE VERSION
certdax-demo-control-plane Ready control-plane 30s v1.31.0
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.
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:
kubectl get pods -n certdax-system
You should see:
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.
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:
kubectl get cdxcert
When READY is true, your certificate is synced:
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:
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:
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:
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:
kubectl get cdxcert
kubectl get ingressroute
6e. Test it with curl. Port-forward Traefik to your local machine and make an HTTPS request:
kubectl port-forward -n traefik svc/traefik 8443:443 &
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:
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:
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
sudo sed -i '/myapp.local/d' /etc/hosts
Cleanup
Done testing? One command removes everything:
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
helm repo add certdax https://charts.certdax.com
helm repo update
Step 2 — Install the Operator
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
apiUrl must end with /api. The operatorToken and clusterName are optional but recommended for dashboard integration (status reporting, certificate overview, cluster info).
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:
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
# 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
# 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
CertDaxCertificateresources 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
- Go to K8s Operators → Create in the CertDax dashboard
- Give the operator a name (e.g.
production-cluster) - An API key and operator token are automatically created
- Copy the pre-filled Helm install command from the setup guide
- Run it in your cluster — the operator will appear as Online within seconds
Helm Values for Dashboard Integration
| Value | Purpose |
|---|---|
certdax.operatorToken | Authenticates heartbeat reports to the CertDax API |
certdax.existingOperatorTokenSecret | Reference an existing K8s secret (key: operator-token) instead of passing the token in values |
clusterName | Human-readable cluster name shown in the dashboard (e.g. production, staging) |
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:
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
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:
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
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:
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
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
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
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
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.
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
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.
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.
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
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.
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
kubectl describe cdxcert internal-ca to find the assigned certificateId in the status. Use that ID as caId in other certificate requests.
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:
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.
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
kubectl describe cdxcert public-cert.
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
| Field | Type | Default | Description |
|---|---|---|---|
commonName | string | required | Primary domain / CN for the certificate |
sanDomains | string | "" | Comma-separated Subject Alternative Names |
providerId | int | — | ACME provider ID (required when type: acme) |
dnsProviderId | int | — | DNS provider ID for dns-01 challenge (required when type: acme) |
caId | int | — | CA certificate ID for CA-signed self-signed certs |
isCA | bool | false | Create a CA certificate instead of a regular certificate |
autoRenew | bool | true | Enable automatic renewal in CertDax |
validityDays | int | 365 | Validity period (self-signed only, ACME is determined by the CA) |
country | string | — | ISO 3166-1 alpha-2 country code (e.g. NL) |
state | string | — | State or province name |
locality | string | — | City or locality name |
organization | string | — | Organization name |
organizational_unit | string | — | Department / Organizational Unit (OU) |
How It Works
- You apply a
CertDaxCertificatewithcertificateId: 0and arequestblock. - The operator calls
POST /api/k8s/certificates/requeston the CertDax backend. - CertDax creates the certificate and returns the new certificate ID.
- The operator stores the ID in
status.certificateIdand starts syncing the TLS secret. - For ACME certificates, the operator retries every 30 seconds until the certificate is issued.
CRD Reference
Spec Fields
| Field | Type | Default | Description |
|---|---|---|---|
certificateId | int | 0 | Certificate ID in CertDax. Set to 0 with a request block to create a new certificate. |
type | string | selfsigned | selfsigned or acme |
request | object | — | Request block to create a new certificate (see Request Certificates via YAML) |
secretName | string | required | Name of the TLS secret to create |
secretNamespace | string | CR namespace | Override namespace for the secret |
syncInterval | string | 1h | Re-sync interval (e.g. 30m, 1h, 24h) |
includeCA | bool | true | Include CA cert in ca.crt field of the secret |
secretLabels | map | {} | Additional labels for the TLS secret |
secretAnnotations | map | {} | Additional annotations for the TLS secret |
Status Fields
| Field | Description |
|---|---|
ready | Whether the TLS secret is up to date |
certificateId | Certificate ID assigned by CertDax (set after a request) |
secretName | Name of the managed TLS secret |
commonName | Certificate CN |
expiresAt | Certificate expiry (ISO 8601) |
lastSyncedAt | Last successful sync time |
message | Human-readable status message |
conditions | Standard Kubernetes conditions |
TLS Secret Contents
For leaf (non-CA) certificates the operator creates a standard kubernetes.io/tls secret with these keys:
| Key | Content | When |
|---|---|---|
tls.crt | PEM-encoded certificate | Leaf certs |
tls.key | PEM-encoded private key | Leaf certs |
ca.crt | PEM-encoded CA certificate | When 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.
| Key | Content | When |
|---|---|---|
ca.crt | PEM-encoded CA certificate (public only) | Always (for CA certs) |
Secret Labels & Annotations
The operator automatically adds these to every managed secret:
| Type | Key | Value |
|---|---|---|
| Label | app.kubernetes.io/managed-by | certdax-operator |
| Label | certdax.com/certificate-id | Certificate ID |
| Label | certdax.com/certificate-type | selfsigned or acme |
| Annotation | certdax.com/common-name | Certificate CN |
| Annotation | certdax.com/expires-at | Expiry timestamp |
| Annotation | certdax.com/synced-at | Last sync timestamp |
Advanced Configuration
Helm Values
| Value | Default | Description |
|---|---|---|
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.repository | ghcr.io/certdax/certdax-operator | Operator container image |
image.tag | latest | Image tag |
image.pullPolicy | Always | Image pull policy |
resources.limits.cpu | 200m | CPU limit |
resources.limits.memory | 128Mi | Memory limit |
resources.requests.cpu | 100m | CPU request |
resources.requests.memory | 64Mi | Memory 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:
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
Custom Labels for Pod Selectors
Add custom labels and annotations to the TLS secret for filtering or integration with other tools:
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:
# 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
# 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
| Message | Cause | Solution |
|---|---|---|
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
helm repo update
helm upgrade certdax-operator certdax/certdax-operator \
--namespace certdax-system \
--reuse-values
Uninstall
# 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
- Go to Settings → Internal ACME Server in the CertDax dashboard.
- Toggle Enable resolver on. CertDax records the resolver IP/hostname and the internal ACME directory URL.
- Point a DNS delegation in your internal nameserver so that
_acme-challenge.yourdomain.internalqueries 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
LoadBalancerorNodePortservice so your upstream resolver can reach it, or use split-horizon DNS with a CoreDNSforwardrule.
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):
{
"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:
# 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:
{
"<hostname-or-wildcard>": {
"username": "<KID>",
"password": "<HMAC>",
"fulldomain": "_acme-challenge.<hostname>",
"subdomain": "_acme-challenge.<hostname>",
"allowfrom": []
}
}
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:
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": []
}
}
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
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
- Your ACME client places an order against the CertDax OpenBao ACME directory, authenticating with the EAB credential.
- The ACME client posts the
_acme-challengeTXT token to the CertDax acme-dns HTTP API (/acme-dns/update), authenticated with the same EAB KID + HMAC. - The certdax-acme-server forwards the TXT to the CertDax backend, which validates the HMAC against OpenBao’s EAB store and persists the record.
- 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. - OpenBao marks the challenge valid, the ACME order completes, and your client downloads the issued certificate.
- 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-01 — tls-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:
| Name | Type | Value |
|---|---|---|
ns.auth.certdax.example.com. | A | Your CertDax host’s public IP |
auth.certdax.example.com. | NS | ns.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:
| Deployment | Setting |
|---|---|
| Docker Compose | Add CERTDAX_ACMEDNS_AUTH_ZONE=auth.certdax.example.com to .env |
| Helm | acmeServer.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:
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:
_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:
{
"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):
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
- lego places an ACME order with Let’s Encrypt and receives a
dns-01challenge for_acme-challenge.myapp.example.com. - 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. - CertDax stores the TXT value and certdax-acme-server serves it for the
cbe2043a-….auth.certdax.example.comDNS name. - Let’s Encrypt resolves
_acme-challenge.myapp.example.com→ CNAME →cbe2043a-….auth.certdax.example.com→ TXT record. Challenge passes. - Let’s Encrypt issues the certificate. lego writes it to
.lego/certificates/.
API Endpoints
Authentication
| Endpoint | Method | Description |
|---|---|---|
/api/auth/register | POST | Create first admin account |
/api/auth/login | POST | Login (returns JWT token) |
ACME Certificates
| Endpoint | Method | Description |
|---|---|---|
/api/certificates | GET | List ACME certificates |
/api/certificates/request | POST | Request new ACME certificate (supports country, state, locality, organization, organizational_unit subject fields) |
/api/certificates/{id}/renew | POST | Renew ACME certificate |
/api/providers/cas | GET | List Certificate Authorities |
/api/providers/dns | GET/POST | Manage DNS providers |
Self-Signed & CA-Signed Certificates
| Endpoint | Method | Description |
|---|---|---|
/api/self-signed | GET | List certificates (filter: ?is_ca=true, ?search=) |
/api/self-signed | POST | Create self-signed or CA-signed certificate |
/api/self-signed/{id} | GET | Get certificate details (incl. PEM) |
/api/self-signed/{id} | DELETE | Delete certificate (?force=true) |
/api/self-signed/{id}/renew | POST | Renew certificate (?validity_days=365) |
/api/self-signed/{id}/parsed | GET | Parsed X.509 certificate details |
/api/self-signed/{id}/download/zip | GET | Download cert + key as ZIP |
/api/self-signed/{id}/download/pem/{type} | GET | Download PEM (certificate, privatekey, combined, chain, ca) |
/api/self-signed/{id}/download/pfx | GET | Download as PFX/PKCS#12 |
Agents & Deployment
| Endpoint | Method | Description |
|---|---|---|
/api/agents | GET/POST | Manage deploy agents |
/api/agent-groups | GET/POST | Manage agent groups |
/api/agent/poll | GET | Agent: fetch pending deployments |
/api/agent/heartbeat | POST | Agent: heartbeat |
Kubernetes Operator
| Endpoint | Method | Description |
|---|---|---|
/api/k8s/certificate/selfsigned/{id} | GET | Fetch self-signed certificate + key for K8s |
/api/k8s/certificate/acme/{id} | GET | Fetch ACME certificate + key + chain for K8s |
/api/k8s/certificates/request | POST | Request new certificate (supports country, state, locality, organization, organizational_unit subject fields) |
Self-Signed Certificate API Examples
Create a self-signed certificate:
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:
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:
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)
| Field | Type | Default | Description |
|---|---|---|---|
common_name | string | required | Certificate CN (e.g. myserver.local) |
san_domains | string[] | null | Additional Subject Alternative Names |
organization | string | null | Organization (O) |
organizational_unit | string | null | Organizational Unit (OU) |
country | string | null | Country code (C), e.g. NL |
state | string | null | State/Province (ST) |
locality | string | null | City (L) |
key_type | string | ec | rsa or ec |
key_size | int | 256 | RSA: 2048/4096, EC: 256/384 |
validity_days | int | 365 | Certificate validity (1–3650 days) |
is_ca | bool | false | Create a CA certificate |
ca_id | int | null | ID of CA to sign with (omit for self-signed) |
auto_renew | bool | false | Enable automatic renewal |
renewal_threshold_days | int | null | Days before expiry to auto-renew (default: 30) |
custom_oids | object[] | null | Custom 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
| Setting | Why |
|---|---|
ENCRYPTION_KEY | Must be identical across all replicas |
SECRET_KEY | Must be identical for JWT validation |
DATABASE_URL | Must point to a shared PostgreSQL instance |
Docker Swarm
# 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_PASSWORDin a K8s Secret - Use a
Deploymentwith multiple replicas for the backend - Point
DATABASE_URLto 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
KIND_EXPERIMENTAL_PROVIDER=podman kind create cluster --name certdax-test
2. Install metrics-server (required for HPA)
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
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:
kubectl port-forward svc/certdax-frontend 8081:80 -n certdax
Then open http://localhost:8081 in your browser.
5. Verify HPA is running
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
# 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
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
securityContextand 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:
| Path | mTLS required? | What it serves |
|---|---|---|
/api/agent/* | Yes — 401 without a valid client cert | Agent runtime API (heartbeats, certificate pickup) |
/api/k8s-operator/* | Yes | Kubernetes operator runtime API |
/api/* (rest) | No | Dashboard / browser API — protected by JWT or API-key auth |
/openbao/* | No (at TLS layer) | OpenBao reverse-proxy — auth enforced by Vault tokens |
/health, /version | No | Liveness / version banner |
/, /assets/* | No | Single-page dashboard frontend |
What an attacker can see without credentials
- TLS handshake metadata — server certificate, cipher suites, ALPN. Identical to any other HTTPS endpoint.
/healthand/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 get401/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/*— immediate401without 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) andpki/issue/<role>(cert issuance). It does not grant access totransit/*or to any other agent's data. - Use the dashboard without a valid login (local password + bcrypt, or OIDC).
Residual risks & recommended hardening
- Information disclosure via the SPA and version banner. Anyone can tell you're running CertDax. Mitigate by hiding the
Serverheader and putting/versionbehind auth if you want a low-profile deployment. - 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.
- TLS-stack CVEs in nginx / OpenBao. Patch discipline. Port
10443is just another HTTPS target for scanners. - 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_zonerules. - Reduce the "loud" attack surface.
ssl_verify_client optionalcurrently accepts any inbound connection up to the application-level401check. You can move/api/agent/and/api/k8s-operator/into a dedicatedserverblock on a separate SNI withssl_verify_client on; the result is identical, but unauthenticated clients are dropped at handshake time. - Enforce TLS 1.2+. Verify your
ssl_protocolsdirective doesn't allow legacy versions. - Audit log shipping. Forward OpenBao's audit log to a central sink and alert on any
403/404against/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.
# 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.
# 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:
- Issue a certificate (ACME or self-signed)
- Download the
.zipbundle from the certificate detail page - Run the backend's Transit decrypt against the row's ciphertext and diff the two PEMs:
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:
# 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.
# 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:
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:
- Bring up a fresh CertDax instance (new init, new shares)
- Re-issue every certificate from its CA (ACME re-orders, self-signed re-signs)
- 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:
# 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.
# 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):
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.