Files
certctl/deploy/helm/certctl
shankar0123 f1fa311191 fix(helm): close BUNDLE 3 — Helm chart hardening + enterprise deploy
Bundle 3 closure (2026-05-12 acquisition diligence audit). Closes the
"chart claims production-ready but lying-fields silently break it"
hazard cluster: README install command had wrong key, required secrets
weren't fail-fast, external Postgres rendered the bundled StatefulSet
hostname, container-only security hardening fields landed at pod scope
(silently dropped by K8s API), and three advertised template surfaces
(ServiceMonitor, PodDisruptionBudget, NetworkPolicy) didn't render at
all even when their values.yaml toggles were on.

Source findings closed:
  C2 C3 D1 D2 D3 D5 D7 D11 D12       (repo audit)
  OPS-L1 OPS-L2                       (cowork audit)
Source findings explicitly deferred (tracked in WORKSPACE-ROADMAP.md):
  D6 OPS-H1   (backup automation — operator must choose target storage)
  D10         (digest pinning of latest `:latest` tags)
  OPS-M1      (prometheus/client_golang migration)
  OPS-M2      (distributed tracing instrumentation)

Chart truth table (rendered with helm 3.16.3):
  -f values.yaml + tls.existingSecret + auth.apiKey + pg.auth.password
    → 12 resources (default mode, no monitoring/PDB/networkpolicy)
  + postgresql.enabled=false + externalDatabase.url=…
    → NO StatefulSet, NO postgres-secret, NO postgres-service (D2)
  + server.tls.certManager.enabled=true
    → +1 Certificate (cert-manager mode)
  + replicas=3 + monitoring.enabled=true + serviceMonitor.enabled=true
    + podDisruptionBudget.enabled=true + networkPolicy.enabled=true
    → +1 ServiceMonitor + 1 PodDisruptionBudget + 1 NetworkPolicy (D5+D11)
  tls.existingSecret AND tls.certManager.enabled both set
    → REFUSED with "EXACTLY ONE TLS ownership path" error (D7)
  Missing required secrets (apiKey / pg password / external URL)
    → REFUSED at template time with operator-actionable guidance (D1)

Closures by source ID:

C2 — README Helm install example fixed. Was `--set postgresql.password=…`
  (does not exist); now `--set postgresql.auth.password=…` matching
  the chart key. README install block also wires TLS, mentions
  fail-fast at template time, and links the external-Postgres example.

C3 — Kubernetes Secrets connector annotated PREVIEW in values.yaml.
  The chart still exposes `kubernetesSecrets.enabled` for the RBAC
  preview wiring, but the values block now states clearly that the
  production K8s client at internal/connector/target/k8ssecret/
  k8ssecret.go::realK8sClient is a stub (verified — go.mod imports
  zero k8s.io/client-go packages). Production landing tracked in
  WORKSPACE-ROADMAP.md.

D1 — `certctl.requiredSecrets` template helper. Fail-fasts at render
  time when (a) server.auth.type=api-key + apiKey empty, (b)
  postgresql.enabled=true + pg.auth.password empty, (c)
  postgresql.enabled=false + externalDatabase.url + legacy env
  CERTCTL_DATABASE_URL all empty. Each branch emits an
  operator-actionable diagnostic with the openssl rand command or
  values override needed. postgres-secret template additionally
  uses Helm's `required` builtin so it can't render with the empty
  fallback that pre-Bundle-3 produced ("changeme" literal).

D2 — externalDatabase.url first-class. New top-level values block.
  certctl.databaseURL helper now branches on postgresql.enabled:
  bundled path uses the helper-emitted in-cluster URL; external
  path uses externalDatabase.url verbatim. postgres-secret,
  postgres-statefulset, and postgres-service ALL gate on
  postgresql.enabled — external mode renders ZERO postgres-*
  resources. POSTGRES_PASSWORD env in server-deployment also gates.

D3 — Container-vs-pod security context split. K8s API silently drops
  readOnlyRootFilesystem / allowPrivilegeEscalation / capabilities /
  privileged when they land at pod scope (`spec.securityContext`);
  they only work at container scope (`spec.containers[].securityContext`).
  Pre-Bundle-3 all fields sat at pod scope so the chart's documented
  "read-only rootfs + drop-all caps" hardening was effectively
  unenforced. New certctl.podSecurityContext + containerSecurityContext
  helpers split the operator-facing securityContext map by field-name
  whitelist so existing values keep working byte-for-byte while
  fields render at the K8s-valid scope. Applied to both
  server-deployment.yaml and agent-daemonset.yaml (DaemonSet + Deployment
  branches).

D5 — Prometheus ServiceMonitor template. New
  templates/servicemonitor.yaml. Renders when monitoring.enabled AND
  monitoring.serviceMonitor.enabled. Scrapes /api/v1/metrics/prometheus
  (rbac-gated on metrics.read — needs bearerTokenSecret with an API
  key holding that perm). values.yaml block extended with bearerTokenSecret,
  tlsConfig, and relabelings knobs and the operator-facing comment
  documenting the auth requirement.

D7 — TLS both-set rejection. certctl.tls.required helper extended.
  Pre-Bundle-3 only the NEITHER-set case was caught; setting BOTH
  rendered a dangling cert-manager Certificate alongside an
  existing-Secret mount, two conflicting TLS sources of truth.
  Now refuses with "EXACTLY ONE TLS ownership path" + remediation
  steps for both possible operator intents.

D11 — PodDisruptionBudget + NetworkPolicy templates. New
  templates/pdb.yaml (renders when podDisruptionBudget.enabled +
  server.replicas > 1) + templates/networkpolicy.yaml (renders when
  networkPolicy.enabled). PDB uses minAvailable / maxUnavailable
  exclusivity per K8s spec. NetworkPolicy default-allows in-namespace
  agent → server traffic, kube-DNS egress, and bundled-postgres
  egress (when postgresql.enabled), with operator-extensible
  extraIngress / extraEgress for CA / OIDC / SMTP egress. Both
  default off so existing deploys don't lose network reach
  unannounced.

D12 — Database max-conn config wired. Pre-Bundle-3
  internal/repository/postgres/db.go::NewDB hard-coded
  SetMaxOpenConns(25). config.go loaded CERTCTL_DATABASE_MAX_CONNS,
  Validate() enforced the >= 1 floor, values.yaml documented it,
  and docs/reference/configuration.md surfaced it — but the pool
  ignored every operator setting. New NewDBWithMaxConns threads
  the operator value into the pool with maxIdle = maxOpen / 5
  (≥ 1) so the historical ratio carries forward. cmd/server/main.go
  calls the new constructor; NewDB stays for compat at the default 25.

OPS-L1 — Chart version 0.1.0 → 1.0.0. Chart has shipped through 8 audit
  closures since 2026-02 (M-018, U-1, U-2, U-3, H-1, G-1, B1, B2);
  pre-1.0 version was implying instability the chart no longer has.

OPS-L2 — External-Postgres path is now properly documented in values.yaml
  (externalDatabase block with mode-2 example), README install command
  links the existing examples/values-external-db.yaml, and the chart
  truth table above proves the external mode renders cleanly.

Receipts:
  helm lint deploy/helm/certctl/                                # clean
  helm template c deploy/helm/certctl/ \
      --set server.tls.existingSecret=ci \
      --set postgresql.auth.password=p \
      --set server.auth.apiKey=k                                # 12 kinds, default
  helm template c deploy/helm/certctl/ \
      --set server.tls.existingSecret=ci \
      --set postgresql.enabled=false \
      --set externalDatabase.url='postgres://u:p@h:5432/db?sslmode=require' \
      --set server.auth.apiKey=k                                # 9 kinds, no postgres-*
  helm template c deploy/helm/certctl/ \
      --set server.tls.certManager.enabled=true \
      --set server.tls.certManager.issuerRef.name=letsencrypt \
      --set postgresql.auth.password=p --set server.auth.apiKey=k
                                                                # +1 Certificate (cert-manager)
  helm template c deploy/helm/certctl/ \
      --set server.tls.existingSecret=ci \
      --set postgresql.auth.password=p --set server.auth.apiKey=k \
      --set server.replicas=3 \
      --set monitoring.enabled=true \
      --set monitoring.serviceMonitor.enabled=true \
      --set podDisruptionBudget.enabled=true \
      --set networkPolicy.enabled=true                          # +ServiceMonitor +PDB +NetworkPolicy
  (TLS both-set + missing apiKey + missing pg password + missing extDb URL all REFUSED.)

  gofmt -l                                                      # clean
  go vet ./internal/repository/postgres ./cmd/server            # clean
  go build ./cmd/server                                         # clean
  bash scripts/ci-guards/B3-helm-chart-coherence.sh             # clean

Remaining operator warnings (deferred, tracked in WORKSPACE-ROADMAP.md):
  - Backup CronJob + restore script (D6 + OPS-H1): operator chooses
    target (S3, GCS, Azure Blob, NFS). Sample CronJob yaml may ship
    in deploy/helm/examples/ once an operator workstation has run
    one full backup-restore cycle.
  - Distributed tracing (OPS-M2): otel/* are go.mod indirect deps,
    not actively instrumented. Adding spans is a v3 work item.
  - Prometheus client_golang migration (OPS-M1): the hand-rolled
    /metrics/prometheus exposition format works today; client_golang
    migration unlocks histograms + exemplars + native label sets.

Audit-Closes: BUNDLE-3 C2 C3 D1 D2 D3 D5 D7 D11 D12 OPS-L1 OPS-L2
Audit-Defers: D6 D10 OPS-H1 OPS-M1 OPS-M2
2026-05-13 00:40:42 +00:00
..

certctl Helm Chart

Production-ready Helm chart for deploying certctl on Kubernetes. Wires up the certctl server (Deployment), PostgreSQL (StatefulSet with PVC), and the agent (DaemonSet — one per node) on a private cluster, with health probes, security contexts, and optional Ingress.

Quick install

helm install certctl deploy/helm/certctl/ \
  --create-namespace --namespace certctl \
  --set server.auth.apiKey="$(openssl rand -base64 32)" \
  --set postgresql.auth.password="$(openssl rand -base64 24)"

This brings up:

  • <release>-server Deployment (HTTPS-only on port 8443; TLS 1.3)
  • <release>-postgres StatefulSet (PostgreSQL 16-alpine, 1 replica, 10Gi PVC by default)
  • <release>-agent DaemonSet (polls server, generates ECDSA P-256 keys locally)
  • Service objects, optional Ingress, and ServiceAccount with RBAC

See values.yaml for the full configuration surface — issuer settings, target connectors, scheduler intervals, notifier credentials, and resource requests/limits all live there.

Operational notes

Postgres password rotation — read this before changing postgresql.auth.password

The trap. postgresql.auth.password is bound to pg_authid exactly once — when the StatefulSet's PVC is provisioned and initdb runs. The official postgres:16-alpine image only runs initdb when /var/lib/postgresql/data is empty, so on every subsequent rollout the POSTGRES_PASSWORD env var is read into the container but ignored by postgres itself. The certctl-server container also picks up the new value (via the database URL helper template), so the two halves diverge: server presents the new password, postgres still expects the old one.

Symptom. The certctl-server pod's startup log shows:

failed to ping database: postgres rejected the configured credentials
(SQLSTATE 28P01 — invalid_password). If you recently rotated POSTGRES_PASSWORD ...

That diagnostic is emitted by internal/repository/postgres/db.go::wrapPingError — it points operators at the two remediation paths below.

Remediation, non-destructive (preferred for any environment with real data):

# 1. Rotate the password in postgres directly
kubectl -n certctl exec -it <release>-postgres-0 -- \
  psql -U certctl -c "ALTER ROLE certctl PASSWORD '<new-password>';"

# 2. Update the secret / Helm values to the same value
helm upgrade <release> deploy/helm/certctl/ \
  --reuse-values \
  --set postgresql.auth.password='<new-password>'

# 3. Bounce the certctl-server pod so it re-reads the secret
kubectl -n certctl rollout restart deployment/<release>-server

Remediation, destructive (DESTROYS ALL CERTCTL DATA — only acceptable on dev/demo clusters):

helm uninstall <release> -n certctl
kubectl -n certctl delete pvc -l \
  app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres
helm install <release> deploy/helm/certctl/ \
  --namespace certctl \
  --set postgresql.auth.password='<new-password>'

The PVC re-creates empty, initdb runs on first boot of the new postgres pod, and pg_authid is seeded with the new password.

Why we don't fix this in the chart. The env-vs-pg_authid divergence is intrinsic to how the upstream postgres image bootstraps — initdb is run-once-per-empty-data-dir, and there is no upstream-supported way to make subsequent boots re-seed pg_authid from POSTGRES_PASSWORD. The ergonomic answer is the runtime diagnostic plus this operational note.

Cross-references. Same root cause is documented for the docker-compose path in docs/quickstart.md (Warning callout after the cp .env.example .env block) and in deploy/ENVIRONMENTS.md (Stateful volume — first-boot password binding section). The runtime diagnostic itself lives in internal/repository/postgres/db.go::wrapPingError with regression coverage in internal/repository/postgres/db_test.go.

Server API key rotation

Unlike the postgres password, server.auth.apiKey accepts a comma-separated list, so zero-downtime rotation is straightforward:

# 1. Add the new key alongside the old
helm upgrade <release> deploy/helm/certctl/ \
  --reuse-values \
  --set server.auth.apiKey='new-key,old-key'

# 2. Roll your agents / clients over to the new key

# 3. Remove the old key
helm upgrade <release> deploy/helm/certctl/ \
  --reuse-values \
  --set server.auth.apiKey='new-key'

JWT / OIDC via authenticating gateway

certctl's in-process auth surface is intentionally narrow: server.auth.type=api-key for production deployments and server.auth.type=none for development. There is no in-process JWT, OIDC, mTLS, or SAML middleware. (server.auth.type=jwt was accepted pre-G-1 but silently routed every request through the api-key bearer middleware — silent auth downgrade. The chart now fails at helm install/helm upgrade template time via the certctl.validateAuthType helper if you set it. See ../../../docs/upgrade-to-v2-jwt-removal.md if you previously had this in your values.)

For deployments that need JWT/OIDC, the canonical Kubernetes-flavored shape is to put oauth2-proxy in front of the certctl Service, attach an authenticating Ingress middleware, and run certctl with server.auth.type=none:

# 1. Install oauth2-proxy (or any OIDC-terminating sidecar) in the same namespace
helm install oauth2-proxy oauth2-proxy/oauth2-proxy \
  --namespace certctl \
  --set config.clientID="$OIDC_CLIENT_ID" \
  --set config.clientSecret="$OIDC_CLIENT_SECRET" \
  --set config.cookieSecret="$(openssl rand -base64 32)" \
  --set config.configFile='|
    provider = "oidc"
    oidc_issuer_url = "https://your-issuer/"
    upstreams = ["http://<release>-server.certctl.svc.cluster.local:8443"]
    pass_authorization_header = true
    set_authorization_header = true
    email_domains = ["*"]
  '

# 2. Install certctl with type=none (gateway terminates auth)
helm install certctl deploy/helm/certctl/ \
  --namespace certctl \
  --set server.auth.type=none \
  --set postgresql.auth.password="$(openssl rand -base64 24)"

# 3. Attach an Ingress that routes through oauth2-proxy
#    (Traefik ForwardAuth, nginx auth_request, Envoy ext_authz, etc.)

Same root pattern works with Pomerium, Authelia, Caddy forward_auth, Apache mod_auth_openidc, or any service-mesh ext_authz. See ../../../docs/architecture.md "Authenticating-gateway pattern" for the full design rationale and ../../../docs/upgrade-to-v2-jwt-removal.md for the migration walkthrough.

TLS certificate sourcing

By default the chart provisions a self-signed cert via the same init-container pattern as the docker-compose deploy. For production, supply an operator-managed Secret (cert-manager, internal CA, etc.) — see docs/tls.md for the full provisioning matrix and docs/upgrade-to-tls.md for upgrade-from-HTTP procedures.

Disabling embedded postgres

If you have an existing PostgreSQL cluster, disable the embedded one and point at it directly:

helm install certctl deploy/helm/certctl/ \
  --set postgresql.enabled=false \
  --set server.databaseUrl='postgres://certctl:<pw>@my-pg-host:5432/certctl?sslmode=require'

The volume-trap section above does not apply to this configuration — your postgres operator (or cloud DB) handles password rotation, and you control pg_authid directly.

Uninstall

helm uninstall <release> -n certctl
# Optional — also delete the postgres PVC (DESTROYS DATA):
kubectl -n certctl delete pvc -l \
  app.kubernetes.io/name=certctl,app.kubernetes.io/component=postgres

By default helm uninstall retains the StatefulSet's PVCs, so reinstalling with the same release name preserves the database. If you've changed postgresql.auth.password in your values between uninstall and reinstall, you'll hit the trap on the reinstall — apply the non-destructive remediation above, or also delete the PVC.