Identity and access

Zitadel on Kubernetes, the secure way

The quick start ends with "log in with password Password1!". Everyone changes it, of course. Right after the demo. Which is now in production.

The short answer

Keep the masterkey, database password and first admin password in OpenBao, synced as Kubernetes Secrets, never in values. Set the first human admin's name and password yourself, give the bootstrap machine key days of life, connect to Postgres with sslmode=verify-full, serve TLS to the pod, and fence the pods with a NetworkPolicy.

Updated Houssam Hammoudi, CTOTested with Zitadel v4.19.1, zitadel chart 10.0.6, External Secrets Operator 2.11.0, OpenBao 2.6.3, CloudNativePG 1.30.1, cert-manager v1.21.2, Cilium 1.20.2, Kubernetes 1.34 (kind)

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

The quick start creates a human instance owner with a password everyone who reads the docs knows. It is marked "change required", which only means the first person to log in picks the new one.

The chart's default first instance also creates a machine user with the instance owner role, and writes its key and a PAT into Kubernetes Secrets. Both expire in 2029. Anyone who can read Secrets in the namespace owns the identity provider for years.

The masterkey that encrypts every secret in Zitadel's database is passed with --set or kubectl create secret --from-literal. A typed value lands in shell history and CI logs; even a generated one sits in the process arguments, visible in ps.

The DSN example on the docs' configuration page uses sslmode=disable, and TLS stops at the ingress. Passwords and tokens cross the cluster network in the clear.

What the docs say

That's it. Visit http://localhost/ui/[email protected] and log in with password Password1!.

Source: Zitadel docs, Deploy on Kubernetes

TLS is terminated at the ingress controller. The Zitadel containers do not handle TLS termination

Source: Zitadel docs, Kubernetes configuration

By default, a JWT Machine Key is generated and stored in a Kubernetes secret named after the Username (e.g., 'iam-admin').

Source: zitadel-charts, values.yaml (chart 10.0.6)

The quick start is labeled as a quick start. The configuration page then builds on it without saying that the human admin is still created with the default password when you configure only a machine user. The test below shows it is.

The secure configuration

Put the three secrets in OpenBao first: a 32-byte masterkey, the database password, and the first admin's password. Never type them as arguments.

bash
# On an operator machine, values never on the command line:
tr -dc A-Za-z0-9 </dev/urandom | head -c 32 | bao kv put -mount=secret zitadel/masterkey masterkey=-
bao kv put -mount=secret zitadel/first-admin [email protected]   # 0600 file, then shred it

Sync them into the namespace with External Secrets Operator:

yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: zitadel-masterkey
  namespace: zitadel
spec:
  refreshInterval: 1h
  secretStoreRef: {kind: SecretStore, name: openbao}
  target:
    name: zitadel-masterkey
    creationPolicy: Owner
  data:
    - secretKey: masterkey
      remoteRef: {key: zitadel/masterkey, property: masterkey}
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: zitadel-secret-config
  namespace: zitadel
spec:
  refreshInterval: 1h
  secretStoreRef: {kind: SecretStore, name: openbao}
  target:
    name: zitadel-secret-config
    creationPolicy: Owner
    template:
      data:
        config-yaml: |
          FirstInstance:
            Org:
              Human:
                Password: "{{ .first_admin_password }}"
  data:
    - secretKey: first_admin_password
      remoteRef: {key: zitadel/first-admin, property: password}
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: zitadel-db-dsn
  namespace: zitadel
spec:
  refreshInterval: 1h
  secretStoreRef: {kind: SecretStore, name: openbao}
  target:
    name: zitadel-db-dsn
    creationPolicy: Owner
    template:
      data:
        dsn: "postgresql://zitadel:{{ .password }}@zitadel-db-rw.zitadel-db.svc:5432/zitadel?sslmode=verify-full&sslrootcert=/db-ssl-ca-crt/ca.crt"
  data:
    - secretKey: password
      remoteRef: {key: zitadel/db, property: password}

A certificate for Zitadel itself, so the hop from the ingress to the pod is TLS too. It needs the external domain as well as the service names: the Login UI calls https://zitadel:8080 but sends Host: id.example.com, and Node checks the certificate against that host.

yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: zitadel-tls
  namespace: zitadel
spec:
  secretName: zitadel-tls
  duration: 2160h
  renewBefore: 720h
  dnsNames:
    - id.example.com                     # the Login UI checks the cert against its Host header
    - zitadel                            # the Login UI calls https://zitadel:8080
    - zitadel.zitadel.svc
    - zitadel.zitadel.svc.cluster.local
  privateKey: {algorithm: ECDSA, size: 256, rotationPolicy: Always}
  issuerRef: {group: cert-manager.io, kind: Issuer, name: zitadel-ca}

The issuer must be allowed to sign these names. A CA with name constraints for your internal domain only (as in an offline internal CA) signs the certificate anyway, because cert-manager does not check constraints, but the Login UI then fails with permitted subtree violation. Give Zitadel a namespace Issuer whose CA permits exactly its names, signed offline:

ini
[v3_ca]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
nameConstraints = critical, permitted;DNS:id.example.com, permitted;DNS:zitadel, permitted;DNS:zitadel.svc, permitted;DNS:zitadel.svc.cluster.local
yaml
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: zitadel-ca
  namespace: zitadel
spec:
  ca:
    secretName: zitadel-ca        # kubernetes.io/tls Secret with the constrained CA and its key

permitted;DNS:zitadel.svc covers zitadel.zitadel.svc, and permitted;DNS:zitadel covers the bare service name.

The Helm values:

yaml
# values.yaml for the zitadel chart 10.0.x (Zitadel v4)
replicaCount: 2
image:
  tag: v4.19.1                                # pin; better, pin by digest in your registry mirror
zitadel:
  masterkeySecretName: zitadel-masterkey      # key "masterkey", 32 bytes, never in values
  configSecretName: zitadel-secret-config     # YAML with passwords, merged at startup
  configSecretKey: config-yaml
  dbSslCaCrtSecret: zitadel-db-ca             # CA that signed the database certificate
  serverSslCrtSecret: zitadel-tls             # cert-manager certificate for Zitadel itself
  configmapConfig:
    ExternalDomain: id.example.com
    ExternalPort: 443
    ExternalSecure: true
    TLS:
      Enabled: true                           # TLS to the pod too, not only at the ingress
    FirstInstance:
      Org:
        Name: Staff                           # the organization for instance administrators
        Human:
          UserName: first-admin
          FirstName: First
          LastName: Admin
          Email:
            Address: [email protected]
            Verified: true
          PasswordChangeRequired: true
          # Password: set in zitadel-secret-config, never here
        Machine:
          Machine:
            Username: bootstrap
            Name: bootstrap automation
          MachineKey:
            ExpirationDate: "2026-10-01T00:00:00Z"   # days, not years; move it to OpenBao
            Type: 1
          Pat: null                           # drop the chart's default PAT (Helm merges maps)
  podSecurityContext:
    runAsNonRoot: true
    runAsUser: 1000
    fsGroup: 1000
    seccompProfile:
      type: RuntimeDefault
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    readOnlyRootFilesystem: true
    privileged: false
    allowPrivilegeEscalation: false
    capabilities:
      drop: [ALL]
env:
  - name: ZITADEL_DATABASE_POSTGRES_DSN      # DSN mode; verify-full, never sslmode=disable
    valueFrom:
      secretKeyRef:
        name: zitadel-db-dsn
        key: dsn
login:
  env:
    - name: NODE_EXTRA_CA_CERTS                # the Login UI must trust the internal CA
      value: /internal-ca/ca.crt
  extraVolumes:
    - name: internal-ca
      secret:
        secretName: zitadel-tls                # cert-manager writes the CA to ca.crt
        items: [{key: ca.crt, path: ca.crt}]
  extraVolumeMounts:
    - {name: internal-ca, mountPath: /internal-ca, readOnly: true}
service:
  scheme: HTTPS                                # probes over HTTPS
  appProtocol: https                           # the chart default says cleartext h2c
  annotations:
    traefik.ingress.kubernetes.io/service.serversscheme: https
pdb:
  enabled: true
  minAvailable: 1

The ingress (or Gateway) must then talk HTTPS to the backend and verify it against the internal CA (see upstream TLS with a pinned CA).

With TLS.Enabled, the chart points the Login UI at https://zitadel:8080 (the release name). The Login UI turns off certificate checks only when the chart generates its own self-signed certificate (the chart's wait-for-zitadel init container never checks), so with your own CA it needs the CA as above.

A NetworkPolicy: only the ingress controller and the Login UI reach Zitadel; Zitadel reaches DNS, the database and the mail relay. It selects the server pods only (component: start). The chart's init and setup jobs carry the same name label and need the Kubernetes API, because the setup job writes the machine key Secret with kubectl:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: zitadel
  namespace: zitadel
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: zitadel
      app.kubernetes.io/component: start    # server pods, not the setup jobs
  policyTypes: [Ingress, Egress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: zitadel-login
      ports:
        - {protocol: TCP, port: 8080}
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - {protocol: UDP, port: 53}
        - {protocol: TCP, port: 53}
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: zitadel-db
      ports:
        - {protocol: TCP, port: 5432}
    - to:
        - ipBlock:
            cidr: 192.0.2.25/32          # the mail relay
      ports:
        - {protocol: TCP, port: 587}

After the install, the bootstrap job has written the machine key into a Secret named after the machine user. Use it once to hand automation its own narrower credentials, store what you keep in OpenBao, and delete the Secret.

Prove it

From secure-tests/zitadel-kubernetes/. First, what first-instance setup does, run for real in Zitadel v4.19.1 containers. With only a machine user configured, setup also creates a human instance owner, and the quick-start password works:

text
bootstrap	TYPE_MACHINE	IAM_OWNER
[email protected]	TYPE_HUMAN	IAM_OWNER
{"username":"[email protected]","passwordChangeRequired":true}
{"password":"Password1!","result":"accepted"}

With the human admin's name and password set from a secret (in the test, an env file for ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME and ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD), the default no longer works:

text
{"username":"[email protected]","passwordChangeRequired":true}
{"password":"Password1!","result":"Password is invalid (COMMAND-3M0fs)"}
{"username":"[email protected]","passwordChangeRequired":true}
{"password":"<from the secret>","result":"accepted"}

The values, merged over the chart's defaults as Helm does, validate against the chart's own values.schema.json (chart 10.0.6), and a wrong type is caught:

text
values.yaml: 0 errors against the chart schema
INVALID replicaCount - 'two' is not of type 'integer'
bad-values.yaml: 1 errors against the chart schema

The manifests validate against the ESO and cert-manager CRDs:

bash
kubeconform -strict -summary -kubernetes-version 1.34.0 \
  -schema-location default -schema-location 'schemas/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' manifests.yaml
text
Summary: 6 resources found in 1 file - Valid: 6, Invalid: 0, Errors: 0, Skipped: 0

In the lab cluster, with the page's resources installed:

Secrets come from OpenBao, rendered by ESO.

text
$ kubectl -n zitadel get externalsecret
NAME                    STORETYPE     STORE     REFRESH INTERVAL   STATUS         READY
zitadel-db-dsn          SecretStore   openbao   1h                 SecretSynced   True
zitadel-masterkey       SecretStore   openbao   1h                 SecretSynced   True
zitadel-secret-config   SecretStore   openbao   1h                 SecretSynced   True
masterkey bytes: 32
postgresql://zitadel:<password>@zitadel-db-rw.zitadel-db.svc:5432/zitadel?sslmode=verify-full&sslrootcert=/db-ssl-ca-crt/ca.crt

With that DSN, zitadel-init and zitadel-setup completed, and the setup job wrote the machine key Secret bootstrap.

No passwords in ConfigMaps, no PAT, non-root and read-only:

text
$ kubectl -n zitadel get configmap -o yaml | grep -i -E 'password:|masterkey'
$ kubectl -n zitadel get secrets
NAME                            TYPE                 DATA   AGE
bootstrap                       Opaque               1      18m
sh.helm.release.v1.zitadel.v1   helm.sh/release.v1   1      20m
zitadel-ca                      kubernetes.io/tls    2      9m36s
zitadel-db-ca                   Opaque               1      20m
zitadel-db-dsn                  Opaque               1      20m
zitadel-login-service-key       kubernetes.io/tls    2      18m
zitadel-masterkey               Opaque               1      20m
zitadel-secret-config           Opaque               1      20m
zitadel-tls                     kubernetes.io/tls    3      113s
$ kubectl -n zitadel get pod -l app.kubernetes.io/component=start \
    -o jsonpath='{.items[0].spec.containers[0].securityContext.runAsUser} {.items[0].spec.containers[0].securityContext.readOnlyRootFilesystem}'
1000 true

(The lab's SecretStore token Secret is left out of the list.) Grep for password: with the colon: plain password also matches the harmless PasswordChangeRequired: true. There is no PAT Secret; bootstrap goes to OpenBao and is then deleted. The Zitadel image has no shell or id binary, so kubectl exec ... -- id fails; read the security context instead.

The first admin, checked through the API with a token from the bootstrap machine key (JWT profile), trying the well-known default and then the password from OpenBao:

text
{"username": "[email protected]", "passwordChangeRequired": true}
{"password": "Password1!", "result": "Password is invalid (COMMAND-3M0fs)"}
{"password": "<from OpenBao>", "result": "accepted"}

It is the only human user in the instance.

The Login UI trusts Zitadel only with the right certificate. Its readiness errors, in order, as the certificate was fixed:

text
issued by a CA constrained to internal.example.com:  permitted subtree violation
without id.example.com in the certificate:          Hostname/IP does not match certificate's altnames: Host: id.example.com. is not in the cert's altnames: DNS:zitadel, DNS:zitadel.zitadel.svc, DNS:zitadel.zitadel.svc.cluster.local
as on this page:                                    zitadel-login 1/1 Running

The NetworkPolicy:

text
from namespace ingress  -> https://zitadel.zitadel.svc:8080/debug/ready : 200
plain http from ingress                                               : 400
from namespace default  -> https://zitadel.zitadel.svc:8080/debug/ready : timeout (curl exit 28)
from a server pod: internet: blocked (curl exit 28)  Kubernetes API: blocked (curl exit 28)  database 5432: open

Mistakes people make

Keeping the quick-start admin

zitadel-admin with Password1! is in the docs. "Change required" means the first person to log in sets the new password. Name the first admin yourself and set its password from a secret.

Secrets in configmapConfig

The docs' Kubernetes pages show the first admin's password in configmapConfig. That is a ConfigMap: readable by anyone who can read ConfigMaps, and printed by helm get values. Use configSecretName.

An instance-owner key valid until 2029

The chart's default machine key and PAT expire in 2029 and sit in Secrets. Give the key days, set Pat: null (Helm merges maps, so leaving it out keeps the default PAT), move the key to OpenBao, delete the Secret.

sslmode=disable to the database

The DSN example on the docs' configuration page disables TLS; the database page lists disable, require and verify-full. Only verify-full, with the database CA mounted, stops a pod on the path from reading or impersonating the database.

TLS only at the ingress

Between the ingress and the pod, logins and tokens travel as plain HTTP. Enable TLS in Zitadel, or run a mesh with mTLS, and have the ingress verify it.

A certificate for the service name only

The Login UI sends the external domain as Host and Node checks the certificate against it. Without that name, the Login UI never becomes ready.

The masterkey on the command line

kubectl create secret --from-literal=masterkey=... puts a typed value in shell history, and any value in the kubectl process arguments. Lose the masterkey and you lose every encrypted value in the database; leak it and a database dump becomes readable. Generate it into OpenBao and back it up offline.

Checklist

  • Store the masterkey, database password and first admin password in OpenBao.
  • Sync them with ExternalSecrets; set masterkeySecretName and configSecretName.
  • Set FirstInstance.Org.Human.UserName and the password from the secret config.
  • Log in as the first admin, set a passkey, create named staff admins, then remove or lock the first admin.
  • Set the bootstrap machine key expiry to days and Pat: null; move the key to OpenBao; delete its Secret.
  • Use a DSN with sslmode=verify-full and mount the database CA.
  • Enable TLS in Zitadel with a certificate for the external domain and the service names, and verify it at the ingress.
  • Issue it from a CA whose name constraints permit those names, not from a CA constrained to another domain.
  • Give the Login UI the internal CA with NODE_EXTRA_CA_CERTS.
  • Keep the chart's non-root, read-only security contexts and add drop-ALL, no privilege escalation and the RuntimeDefault seccomp profile.
  • Apply a NetworkPolicy to the server pods for ingress, Login UI, DNS, database and mail only.
  • Validate values against the chart schema and manifests with kubeconform in CI.

The identity provider is the front door for every other system. Give it the same care you would give the lock, not the doormat.

H2-CIAE

Learn it on a live range

SSO and lifecycle, in Identity and Access Engineering: a real host in your browser, and every objective checked on the machine.

Start free

The Secure Way

More on identity and access

Self-hosted identity with Zitadel, private access with Headscale, break-glass and offboarding.

All identity and access guides