The chart generated the login RSA keypair on every render, so checksum/secret-login-service-key changed each time and both Zitadel Deployments stayed permanently OutOfSync. Adopt the live keypair into k8s/argocd/secrets/zitadel-login-service-key.enc.yaml (SOPS, age recipient from .sops.yaml), list it in the ksops generator, and point login.loginServiceKeySecretName at it. The chart then stops generating the keypair and the Deployment checksums become deterministic.
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 and breaks the next `terraform apply` in terraform/zitadel.
|
|
# 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
|