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.
227 lines
9.2 KiB
YAML
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
|