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.
On this page
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.
# 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 itSync them into the namespace with External Secrets Operator:
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.
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:
[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.localapiVersion: 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 keypermitted;DNS:zitadel.svc covers zitadel.zitadel.svc, and
permitted;DNS:zitadel covers the bare service name.
The Helm values:
# 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: 1The 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:
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:
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:
{"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:
values.yaml: 0 errors against the chart schema
INVALID replicaCount - 'two' is not of type 'integer'
bad-values.yaml: 1 errors against the chart schemaThe manifests validate against the ESO and cert-manager CRDs:
kubeconform -strict -summary -kubernetes-version 1.34.0 \
-schema-location default -schema-location 'schemas/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' manifests.yamlSummary: 6 resources found in 1 file - Valid: 6, Invalid: 0, Errors: 0, Skipped: 0In the lab cluster, with the page's resources installed:
Secrets come from OpenBao, rendered by ESO.
$ 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.crtWith 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:
$ 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:
{"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:
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 RunningThe NetworkPolicy:
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: openMistakes 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
masterkeySecretNameandconfigSecretName. - Set
FirstInstance.Org.Human.UserNameand 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-fulland 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 freeThe 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