Files
homelab/k8s/infra/iam/zitadel-values.yaml
T
pi d4e9dbf350 refactor(iam): retire terraform/zitadel, provision through the API
The Terraform root module duplicated a writer that the provisioning script could
be, and its state carried generated client secrets. Declare the objects in the
script instead: project, 19 roles, 12 OIDC apps, applied through the management
API with the iam-admin PAT. State is a 0600 JSON file under ~/.config/zitadel.

Also add zitadel to the Cloudflare tunnel hostname list, with the import command
and the note that the tunnel's Public Hostname entry is a separate dashboard
step: DNS alone yields a 404 from the tunnel.
2026-10-25 19:35:01 -07:00

227 lines
9.2 KiB
YAML

# k8s/infra/iam/zitadel-values.yaml
# ZITADEL — Authentik replacement. Coexistence is deliberate: Authentik keeps
# running, read-only, in the same `iam` namespace until the last consumer moves.
# Migration plan: ~/workplace/homelab-doc/ZITADEL_MIGRATION.html (outside git).
#
# Chart: zitadel/zitadel 10.4.0, app v4.19.2. The plan cites v4.19.4; the chart
# has not published it yet. Pin the chart, never `*`, until the v4.19.4 chart
# exists — a silent app bump invalidates the verified masterkey/passwap facts.
#
# URL: https://zitadel.riotpiao.com. TLS terminates at ingress-nginx, which
# serves the wildcard riotpiao-com-tls as its default-ssl-certificate, so
# ExternalSecure=true and TLS.Enabled=false (no per-rule tls: block needed).
#
# Database: CNPG cluster zitadel-db (wave 2). The DSN comes from CNPG's
# generated `zitadel-db-app` secret. When ZITADEL_DATABASE_POSTGRES_DSN is set,
# ZITADEL ignores the discrete Database.Postgres.* fields, so neither a
# password nor a host is written into this file.
#
# Masterkey: Secret `zitadel-masterkey` (k8s/argocd/secrets/
# zitadel-masterkey.enc.yaml), key `masterkey`, exactly 32 bytes. It wraps the
# rows in system.encryption_keys, which encrypt every client secret and private
# key. Losing it makes those values unreadable: keep the offline sealed copy.
#
# Ingress uses the chart's own objects (controller: nginx adds the gRPC backend
# annotation ZITADEL needs for its HTTP/2 API). Do not hand-write a second
# Ingress for this host.
zitadel:
configmapConfig:
ExternalSecure: true
ExternalDomain: zitadel.riotpiao.com
TLS:
Enabled: false
# First boot is COMPLETE (2026-10-26): the instance, the system org and the
# `iam-admin` machine key all exist. Skip stops the setup hook from running
# the first-instance step again. Without it, every sync re-runs the
# machinekeyWriter, which silently rotates the iam-admin key Terraform
# reads. ZITADEL objects are declared by scripts/iam/zitadel-provision.py.
# Never set this back to false.
FirstInstance:
Skip: true
# Rendered into a Secret by the chart; read by pods and init jobs alike.
masterkeySecretName: zitadel-masterkey
resources:
requests: { memory: "512Mi", cpu: "250m" }
limits: { memory: "2Gi", cpu: "1" }
# Best practice: run as non-root, drop all capabilities, and refuse
# privilege escalation. The chart's image already supports a non-root UID.
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
# FirstInstance defaults stay for the FIRST sync: the chart creates org
# `zitadel` plus the `iam-admin` machine user and its machine-key Secret.
# Terraform consumes that key to declare projects, roles, OIDC apps and the
# groups action.
#
# SECOND-SYNC HAZARD — read before enabling automated sync.
# The chart marks eight objects as `pre-install,pre-upgrade` hooks (two
# ServiceAccounts, two ConfigMaps, Role, RoleBinding, zitadel-init,
# zitadel-setup), and one cleanup Job as `post-delete`. ArgoCD maps the first
# eight to PreSync with `before-hook-creation`: it deletes and re-creates them
# on EVERY sync, not only on a chart change.
# - zitadel-init (`init zitadel`) — schema step
# - zitadel-setup (`setup`) — first instance + iam-admin machine key
# On an initialized instance the setup step can fail, which aborts the sync
# before the Deployment is applied. If it succeeds instead, its
# machinekeyWriter container re-applies the iam-admin key Secret that
# Terraform reads, which silently rotates a credential in use.
# After the first sync reports healthy:
# 1. set configmapConfig.FirstInstance.Skip: true here — permanently: the
# setup steps are schema-only once the instance exists, and Skip alone
# stops the machinekeyWriter rotation
# 2. set initJob.enabled: false below
# 3. commit, sync once, then enable automated+selfHeal on the Application
# For a later schema upgrade, re-enable initJob.enabled only — never set
# FirstInstance.Skip back to false.
# ArgoCD never prunes a hook Job, so a Job that failed before step 2 stays and
# keeps ZitadelSetupJobFailed firing until a human deletes it.
# DSN mode (chart renders this through the zitadel.dbEnv helper). CNPG's `uri`
# key is a postgresql:// URL, which ZITADEL accepts as a DSN.
env:
- name: ZITADEL_DATABASE_POSTGRES_DSN
valueFrom:
secretKeyRef:
name: zitadel-db-app
key: uri
# Bundled Bitnami Postgres is disabled: the CNPG cluster above is the system of
# record. Enabling it would create a second, unmanaged database.
postgresql:
enabled: false
# CNPG already created the database, the `app` user and its grants, so the init
# job runs only the ZITADEL-internal step. Running the full default command
# would try to create a database and a superuser it does not own.
#
# enabled: false after the first successful boot (2026-10-26). The schema is in
# place; the init hook has nothing left to do and re-running it on every sync
# only adds a PreSync failure surface. Re-enable it for a deliberate schema
# upgrade, then set it back to false.
initJob:
enabled: false
command: "zitadel"
activeDeadlineSeconds: 600
# The setup job runs the full first-instance creation: schema migrations, the
# system org, the iam-admin machine key, and 13 projection replays. 20 minutes
# leaves room for a cold database without masking a genuine hang.
#
# DONE, 2026-10-26 — first boot completed. The annotations are moved from
# pre-install/pre-upgrade to post-delete because a PreSync setup run is not
# harmless after boot:
# - it re-runs every repeatable migration on every sync;
# - interrupting one (a sync aborted, or a human deleting the Job) leaves a
# `system.migration.started` event with no matching `done`, and ZITADEL then
# waits forever on `migration already started`. That is exactly how this
# instance deadlocked on projections.projects4 and later on
# repeatable_delete_stale_org_fields.
# A post-delete hook is never executed by ArgoCD sync, so setup runs only on
# first install. Re-enable it deliberately (and only with initJob too) for a
# schema upgrade, then revert.
setupJob:
activeDeadlineSeconds: 1200
annotations:
helm.sh/hook: post-delete
helm.sh/hook-delete-policy: hook-succeeded
helm.sh/hook-weight: "2"
# Two API replicas: the read path survives one node drain, and both share the
# same masterkey and database. The login UI stays at one replica — a session
# cookie is not shared, so a second pod buys nothing.
replicaCount: 2
revisionHistoryLimit: 3
# Best practice: a PDB guarantees at least one API pod stays available during
# voluntary disruptions (node drain, rolling upgrade). Without it a drain can
# take both replicas down at once. minAvailable 1 is correct for 2 replicas;
# minAvailable 2 would block every drain forever.
pdb:
enabled: true
minAvailable: 1
# Best practice: spread replicas across nodes so a single node failure cannot
# take both API pods. Anti-affinity alone only prefers separation; topology
# spread enforces the failure-domain budget.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: zitadel
ingress:
enabled: true
className: nginx
controller: nginx
login:
enabled: true
# ksops owns this Secret (k8s/argocd/secrets/zitadel-login-service-key.enc.yaml,
# rendered by the sops-secrets Application). Pointing the chart at it stops the
# chart from auto-generating an RSA keypair every render, which changed
# checksum/secret-login-service-key and kept both Deployments permanently
# OutOfSync. The keypair must stay RSA: the login container signs JWTs (RS256).
loginServiceKeySecretName: zitadel-login-service-key
replicaCount: 1
revisionHistoryLimit: 3
ingress:
enabled: true
className: nginx
# Best practice: the login UI is the public attack surface. Harden the pod
# even at one replica. The chart has no login PDB key; a single replica has
# nothing to protect with minAvailable.
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
# Chart-owned ServiceMonitor. The cluster's Prometheus runs with
# serviceMonitorSelectorNilUsesHelmValues=false, so the release label is not
# required for discovery. Endpoint is /debug/metrics on port http2-server.
metrics:
enabled: true
serviceMonitor:
enabled: true
scrapeInterval: 30s
scrapeTimeout: 10s
# The 3-CP / 2-worker cluster mixes control-plane taints with worker capacity.
# Without the toleration a rolling upgrade can leave both replicas Pending.
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app.kubernetes.io/name: zitadel