Hosting municipalities (operator runbook)¶
Run one HelixIAM installation for several Dutch municipalities (gemeenten), one realm each: onboarding, day-2 operations, upgrades and offboarding.
What it is¶
This runbook is for the operator who hosts HelixIAM for municipalities. It uses only standard Kubernetes and Helm, so it applies to any conformant cluster, including VNG Haven clusters. Every municipality gets its own realm: its own users, applications, signing keys, sign-in flows, theme, DigiD and eHerkenning connections, and administrators.
The Helm chart ships the files this runbook uses. After helm registry login ghcr.io with the registry credentials
from your licence, unpack it with
helm pull oci://ghcr.io/melmarzgioui/helixiam/charts/helixiam --version 1.1.0-rc.14 --untar:
| Piece | Where |
|---|---|
| Hosting profile (values file) | helixiam/examples/values-multi-tenant-municipalities.yaml in the chart |
| Chart documentation: HA, monitoring and alerts, backups, Haven | helixiam/README.md in the chart |
Generic pg_dump backup CronJob |
helixiam/examples/postgres-backup-cronjob.yaml in the chart |
| Readiness pack in Dutch: BIO2 mapping, DPIA input, DigiD/eHerkenning onboarding, processor-agreement input | available to licensed customers on request |
Install the platform once¶
-
Create the Secret. The chart never inlines secrets. The Secret holds the database credentials, the
db-encryptionkey (16, 24 or 32 characters; it encrypts the realm signing keys and every other secret in the database and must never change), the bootstrap admin password and the secret of the provisioning service account:kubectl create namespace identity kubectl -n identity create secret generic helixiam-secrets \ --from-literal=db-username=helixiam \ --from-literal=db-password="$DB_PASSWORD" \ --from-literal=db-encryption="$(openssl rand -hex 16)" \ --from-literal=admin-password="$(openssl rand -base64 24)" \ --from-literal=bootstrap-client-secret="$(openssl rand -hex 32)"Copy the
db-encryptionvalue into your secrets vault now, separately from any database backup. A database backup is useless without it. -
Adapt the profile. Copy
helixiam/examples/values-multi-tenant-municipalities.yamlfrom the unpacked chart and change every value markedCHANGE: the host names (config.idpBaseUrl,config.spBaseUrl,ingress.hosts,ingress.tls, the console's beheer host inconsole.ingressandconfig.adminAllowedHosts), the ingress controller's pod CIDR inconfig.trustedProxies,database.host, the namespace selectors of the NetworkPolicy, and the labels your Prometheus selects on (serviceMonitor.labels,prometheusRule.labels). SetimagePullSecretsto adocker-registrySecret in the namespace with the registry credentials from your licence. The profile connects to PostgreSQL withdatabase.sslMode: verify-full: create the Secret with the CA that signed the database server certificate first (kubectl -n identity create secret generic postgres-ca --from-file=ca.crt=./postgres-ca.pem, or setdatabase.caSecret.nameto yours). Decide the audit-log retention with the municipalities (retention.auditLogMaxAge, empty keeps it). -
Install:
What the profile sets, and why:
| Concern | Values |
|---|---|
| High availability | replicaCount: 3 (two at least), strategy RollingUpdate with maxUnavailable: 0, topologySpread.enabled (zones and nodes), podDisruptionBudget.minAvailable: 2 |
| Shared state | config.sessionStore: queue, config.tokenStore: queue, config.clusterStateStore: jdbc: sessions, tokens, flow state and rate limits in PostgreSQL, counted once for the cluster. The chart passes replicaCount to the server as HELIX_CLUSTER_EXPECTED_REPLICAS. With Redis instead, set redis.host and the stores to redis |
| Proxies and host names | config.trustedProxies (HELIX_TRUSTED_PROXIES), config.allowedHosts (HELIX_ALLOWED_HOSTS): see Security hardening |
| Pod security | non-root, readOnlyRootFilesystem, all capabilities dropped, RuntimeDefault seccomp, no service-account token, resource requests and limits |
| Network | networkPolicy.allowSameNamespaceIngress: false, ingress only from the ingress controller's and the monitoring namespace, egress restricted to DNS, PostgreSQL, 443 and SMTP |
| Monitoring | config.prometheusAnonymous: true with serviceMonitor.enabled and prometheusRule.enabled |
| Database TLS | database.sslMode: verify-full with database.caSecret (mounted read-only, passed as DB_SSLMODE / DB_SSL_ROOT_CERT): the connection is encrypted and the server certificate and host name are verified. Without it the JDBC driver default (prefer) does not verify the server |
| Data retention | Expired sessions, tokens, one-time codes and links are deleted on a schedule (retention.enabled, batches, safe with several replicas); the audit log only with retention.auditLogMaxAge, a policy decision |
| Admin console | console.enabled with 2 replicas, its own Deployment on an internal beheer host (console.ingress, removed from the server Ingress), an ingress-only console.networkPolicy. The server still scales to 3+ on its own. See Architecture |
| Administration only on the beheer host | config.adminAllowedHosts: [beheer host], config.adminRestrictMasterRealm: true: /admin/**, the API docs and the master realm answer 404 on the public sign-in host; the municipalities' realms are unaffected. See Install |
| Provisioning | secrets.bootstrapClientId: provisioner: a client_credentials client with the master admin role, so automation never signs in as the bootstrap admin and config.mfaEnabled stays on |
Keep /actuator/prometheus off the public ingress
The ServiceMonitor needs anonymous scraping. The NetworkPolicy admits only the monitoring namespace besides the
ingress controller, but the chart's Ingress routes every path of its hosts: deny /actuator/ in your ingress
controller or gateway.
A token for the admin API¶
The examples below call the admin API with a bearer token of the provisioning service account, so they need no CSRF
header (see Authenticating to the API). The profile serves the admin API and
the master realm only on the beheer host (config.adminAllowedHosts), so HELIX_URL is the beheer host here, not
the public sign-in host (which answers 404 for both). Run them from a network that reaches the internal beheer
ingress, or call an in-cluster name you also listed in config.adminAllowedHosts:
export HELIX_URL=https://beheer.login.example.nl
PROVISIONER_SECRET=$(kubectl -n identity get secret helixiam-secrets \
-o jsonpath='{.data.bootstrap-client-secret}' | base64 -d)
TOKEN=$(curl -s -u "provisioner:$PROVISIONER_SECRET" -d grant_type=client_credentials \
"$HELIX_URL/realms/master/oauth2/token" | jq -r .access_token)
AUTH="Authorization: Bearer $TOKEN"
The token is valid for the master realm's access-token lifetime (one hour by default); fetch a new one when it expires.
Onboard a municipality¶
The HelixIAM release tests run steps 1 to 3, 5 and the export of the offboarding section against a two-replica install, with every request of a sign-in alternating between the pods.
1. Create the realm¶
Pick the realm id first: it is part of every URL, every token's iss and the SAML entity ids
(https://login.example.nl/realms/<realm>/…), and DigiD and eHerkenning are registered against those URLs. Use a
stable, URL-safe name such as gemeente-voorbeeld.
Saving the realm's settings creates it:
REALM=gemeente-voorbeeld
curl -s -X PUT -H "$AUTH" -H "Content-Type: application/json" \
-d '{"displayName":"Gemeente Voorbeeld","accessTokenTtlSeconds":300,"refreshTokenTtlSeconds":86400,
"enabled":true,"passwordMinLength":12,"ssoSessionIdleTimeoutSeconds":1800,
"ssoSessionMaxLifetimeSeconds":36000}' \
"$HELIX_URL/admin/realms/$REALM/settings"
Keep one realm document per municipality in git (the format of GET /admin/realms/{realm}/export, see
Import / export); the file name is the realm id. Mount them with the chart's
realmConfig values; every replica applies them at start-up:
kubectl -n identity create configmap helixiam-realms \
--from-file=gemeente-voorbeeld.json --dry-run=client -o yaml | kubectl apply -f -
helm upgrade helixiam oci://ghcr.io/melmarzgioui/helixiam/charts/helixiam --version <chart version> \
-n identity --reset-then-reuse-values \
--set realmConfig.enabled=true --set realmConfig.configMap.name=helixiam-realms
With realmConfig.onConflict=skip (the default) a restart never changes what already exists; overwrite
reconciles the realm to the files. Secrets in the files are ${HELIX_<REALM>_…} placeholders: provide them with
config.extraEnv from a Secret, and set realmConfig.requireEnv=true so a file whose secret is missing is skipped
instead of half-applied. A minimal file:
{"formatVersion": 2, "realm": {"displayName": "Gemeente Voorbeeld", "accessTokenTtlSeconds": 300,
"refreshTokenTtlSeconds": 86400, "enabled": true, "passwordMinLength": 12}}
Up to and including 1.0.0-rc.12, a skip-mode import does not create a realm that does not exist yet (fixed in
1.0.0-rc.13). On those older releases create the realm with the admin API first, or use
realmConfig.onConflict=overwrite.
Set the realm's sign-in policy (MFA, password policy, lockout) in the same settings, see Realm settings and Password policy & lockout.
2. Administrators¶
Creating a realm also creates its bootstrap admin, admin-<realm>, with the bootstrap admin password of the
installation (the same for every realm). Replace that password and disable the account before anyone else can use it,
then give the municipality named administrators:
BOOT_ID=$(curl -s -H "$AUTH" "$HELIX_URL/admin/realms/$REALM/users" \
| jq -r --arg u "admin-$REALM" '.[] | select(.username == $u) | .userId')
curl -s -X PUT -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"newPassword\":\"$(openssl rand -base64 32)\"}" \
"$HELIX_URL/admin/realms/$REALM/users/$BOOT_ID/password"
curl -s -X PUT -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"username\":\"admin-$REALM\",\"enabled\":false}" \
"$HELIX_URL/admin/realms/$REALM/users/$BOOT_ID"
# A named administrator with a one-time password they must change at first sign-in (TOTP enrolment follows
# with config.mfaEnabled=true)
ADMIN_ID=$(curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d '{"username":"j.jansen","email":"[email protected]","enabled":true}' \
"$HELIX_URL/admin/realms/$REALM/users" | jq -r .userId)
curl -s -X PUT -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"newPassword\":\"$ONE_TIME_PASSWORD\"}" "$HELIX_URL/admin/realms/$REALM/users/$ADMIN_ID/password"
curl -s -X PUT -H "$AUTH" -H "Content-Type: application/json" -d '{"requiredActions":"UPDATE_PASSWORD"}' \
"$HELIX_URL/admin/realms/$REALM/users/$ADMIN_ID/required-actions"
ROLE_ID=$(curl -s -H "$AUTH" "$HELIX_URL/admin/realms/$REALM/roles" | jq -r '.[] | select(.name == "admin") | .roleId')
curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" -d "{\"roleId\":\"$ROLE_ID\"}" \
"$HELIX_URL/admin/realms/$REALM/users/$ADMIN_ID/roles"
Hand the one-time password over out of band.
The bootstrap admin is recreated only when it is missing, so keep it disabled rather than deleting it. The realm role
admin holds realm-admin; give help-desk and audit staff narrower roles (Admin roles). A
municipality's administrators manage only their own realm; keep the master realm to the hosting team.
3. Branding¶
A realm theme (colours, logo, texts, fonts) is set with PUT /admin/realms/{realm}/theme; Theming
describes the model. Shared, file-based themes can be mounted with the chart's themes values and
selected per realm with PUT /admin/realms/{realm}/theme/base.
curl -s -X PUT -H "$AUTH" -H "Content-Type: application/json" \
-d '{"colors":{"primary":{"light":"#1d4ed8"}},"assets":{"logoUrl":"https://www.voorbeeld.nl/logo.svg"}}' \
"$HELIX_URL/admin/realms/$REALM/theme"
Also set the realm's email provider and templates, so codes and notices come from the municipality (Notifications).
4. DigiD and eHerkenning¶
Each municipality brings its own DigiD connection (Logius) and eHerkenning contract (a broker), and its own PKIoverheid certificates. Create the connections in the municipality's realm as described in DigiD and eHerkenning:
- Use separate connections for the scheme's test and production environments, for example aliases
digid-preprodanddigid, each with its own metadata and certificates. - The Assertion Consumer Service URL to register is
https://login.example.nl/realms/<realm>/broker/<alias>/callback. - Set
minimumLoato the level each service needs; it is requested and enforced. - The artifact binding resolves over mutual TLS on port 443 (allowed by the profile's egress policy), with the SP signing certificate as the client certificate. The Logius server certificate must chain to the image's default Java trust store; there is no per-connection trust store.
- Decide which applications may receive the BSN. A DigiD user's username is the BSN unless the connection has a
username template (Mappers), and
preferred_usernamecarries the username.
The Dutch onboarding guide in the readiness pack (available to licensed customers) maps every Logius and eHerkenning requirement to these settings.
5. The municipality's own signing certificate¶
Each realm signs tokens and SAML assertions with its own key. To use a certificate from the municipality's CA, let Helix generate the key and a CSR (the private key never leaves the server), have it signed, attach the chain, and activate the key once relying parties have picked it up (Realm keys):
curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d '{"algorithm":"RS256","subject":"CN=login.example.nl, O=Gemeente Voorbeeld, C=NL","dnsNames":["login.example.nl"]}' \
"$HELIX_URL/admin/realms/$REALM/keys/csr" | tee csr-response.json | jq -r .csrPem > realm.csr
KEY_ID=$(jq -r .key.keyId csr-response.json)
# After the CA returns the certificate (leaf first, then the issuing CA certificates):
jq -n --rawfile chain signed-chain.pem '{certificateChainPem: $chain}' |
curl -s -X PUT -H "$AUTH" -H "Content-Type: application/json" --data-binary @- \
"$HELIX_URL/admin/realms/$REALM/keys/$KEY_ID/certificate"
# The key is PENDING and already in the JWKS; activate it after the staging time (5 minutes by default)
curl -s -X POST -H "$AUTH" "$HELIX_URL/admin/realms/$REALM/keys/$KEY_ID/activate"
SAML service providers that pin the signing certificate must get the new certificate (from the realm's SAML IdP metadata) before you activate.
6. Applications¶
Register each application of the municipality as an Application with an OIDC client and/or a SAML relying party, and release only the claims it needs (Client scopes & claims).
Onboarding checklist¶
- [ ] Realm created, settings and MFA policy set
- [ ]
admin-<realm>has a random password and is disabled; named administrators hold the least role they need - [ ] Theme and email provider set
- [ ] DigiD / eHerkenning test connections working, then production connections after the scheme's acceptance
- [ ] Realm signing key with the municipality's certificate active (if required)
- [ ] Applications registered with minimal claims
- [ ] Realm document committed to git (
GET /admin/realms/{realm}/export)
Day-2 operations¶
Backups and restore drills¶
The database holds all durable state (Backup & disaster recovery). Use your platform's managed
PostgreSQL backups with point-in-time recovery, or run the generic CronJob from the chart (unpacked with helm pull … --untar, above):
kubectl -n identity apply -f helixiam/examples/postgres-backup-cronjob.yaml
kubectl -n identity create job helixiam-pg-backup-manual --from=cronjob/helixiam-pg-backup
Copy the dumps off the cluster. Back up the db-encryption value separately and securely: the realm signing keys,
identity-provider keys and other secrets in the dump are encrypted with it.
Restore drill (quarterly, and after every major upgrade), into a separate namespace so production is untouched:
- Create an empty database and restore the latest dump into it:
pg_restore --no-owner --dbname helixiam_restore helixiam-<timestamp>.dump. - Create the Secret in the drill namespace with the same
db-encryptionvalue from your vault. - Install the chart there with
database.name=helixiam_restoreand drill host names. - Check readiness (
/actuator/health/readiness), a sign-in in one municipality's realm, and that its/realms/<realm>/oauth2/jwkslists the same key ids as production. - Record the time it took (your RTO) and the age of the dump (your RPO), then delete the drill namespace.
After restoring production itself, restart every replica: a replica reloads identity providers only when their version goes up, and a restore can lower it.
Key and certificate rotation¶
| What | How |
|---|---|
| Realm signing key / certificate | Before expiry, attach a renewed certificate to the active key (PUT /admin/realms/{realm}/keys/{keyId}/certificate), or stage a new key (CSR or import) and activate it after relying parties picked it up. See Realm keys |
| DigiD / eHerkenning SP certificates (PKIoverheid) | Update spSigningCertificate / spSigningPrivateKey / spDecryptionPrivateKey of the connection, coordinated with Logius or the broker; the keys are write-only |
| Provisioning service account secret | POST /admin/realms/master/clients/{id}/secret, then update the Secret |
| Email/SMS provider credentials | Update the Secret (global email credentials are read at every send) or the realm's provider |
DB_ENCRYPTION |
Never. It cannot be rotated |
Certificate expiry raises REALM_KEY_CERTIFICATE_EXPIRING / REALM_KEY_CERTIFICATE_EXPIRED audit events 30 days ahead,
a daily WARN, and shows in the realmSigningKeys details of /actuator/health/readiness. An expired active
certificate keeps signing, so alert on these events.
Monitoring¶
- Probes:
/actuator/health/livenessand/actuator/health/readiness(anonymous); see Observability & health. - Metrics:
/actuator/prometheus, scraped by the chart's ServiceMonitor. - Alerts: the chart's PrometheusRule covers scrape failures, unavailable replicas (needs kube-state-metrics), 5xx rate,
failed-sign-in ratio per realm (
helix_login_total), email provider authentication failures, the email retry backlog and unreadable encrypted columns. The chart README lists them. - Log-based alerts (the pods log to stdout; audit events are JSON lines of the
helix.auditlogger): realm certificate expiry (above); the WARNRate limit '<name>': the shared <store> store failed(a replica counts rate limits on its own while PostgreSQL or Redis is unreachable);PRIVILEGED_LEGACY_LINKandPRIVILEGED_AMBIGUOUS_LINKafter the 1.0.0-rc.12 upgrade;DEFAULT_ROLE_HAS_ADMIN_PERMISSIONS; and the start-up WARNProduction readiness — N insecure setting(s) in effect.
Incident response pointers¶
- Who did what: the realm's audit log (
GET /admin/realms/{realm}/events, filterable; see Events & audit), and thehelix.auditlines in your log pipeline. - Cut off access: revoke sessions (
DELETE /admin/realms/{realm}/sessions/{id}, see Sessions), disable the user, rotate a leaked client secret, or disable the affected identity provider. - Suspected key compromise: stage and activate a new realm key, then retire the old one
(
DELETE /admin/realms/{realm}/keys/{keyId}, after it is no longer ACTIVE); relying parties that cache the JWKS keep trusting the old key until their cache expires. - Scheme obligations: a DigiD incident is also reported to Logius under the connection agreement; personal data breaches to the Dutch DPA (Autoriteit Persoonsgegevens) by the municipality as controller.
- Upgrades with security fixes: read the security advisories in each release's upgrade notes (1.0.0-rc.12 fixed two High privilege escalations).
Upgrades¶
- Read the release's upgrade notes (Upgrades & migrations and the per-release pages). Some upgrades
cannot mix versions and need
strategy.type=Recreatefor that one upgrade. - Back up the database (and check you have the
db-encryptionvalue). helm upgrade helixiam oci://ghcr.io/melmarzgioui/helixiam/charts/helixiam --version <new chart version> -n identity --reset-then-reuse-values. With the profile (maxSurge: 1,maxUnavailable: 0) one new pod starts at a time and old pods keep serving.- Flyway: the first new pod applies pending migrations at start-up (
HELIX_MIGRATIONS_ENABLED=true); Flyway's lock in the database keeps the other pods from migrating at the same time. Migrations are additive, so old pods keep working against the new schema during the roll.flyway_schema_historyrecords every applied version. - Check readiness, one sign-in per realm with an eID connection, and the start-up reports in the log.
For 1.0.0-rc.11 → 1.0.0-rc.12 (the guide):
- Flyway V86 rewrites
realm_keyand places federated links in realms; read thePRIVILEGED_LEGACY_LINKreport afterwards and revoke trust in suspect links. - Activate an ES256 realm key only after every replica runs 1.0.0-rc.12 or later: a 1.0.0-rc.11 replica cannot load an EC key. Importing and staging keys during the roll is fine (1.0.0-rc.11 replicas ignore PENDING keys).
- Wallet providers (
trusted_authorities, from 1.0.0-rc.11): an older replica refuses a DCQL query that carriestrusted_authorities, so a wallet sign-in started on an upgraded pod and answered on an old one fails. Upgrade all replicas in one step, drain wallet transactions first (transactionTtlSeconds, 300 s by default), or set HAIP-profile providers'dcql.trustedAuthoritiesto Off for the roll. - Admin automation that used
manage-realmfor key writes and other sensitive settings now needsrealm-admin.
For 1.1.0-rc.1 → 1.1.0-rc.2 (the guide):
- The profile now runs the admin console as its own Deployment on the beheer host and sets
config.adminAllowedHosts: remove the beheer host from the server'singress.hosts/ingress.tls, and move every script, Terraform configuration and CI job that calls/adminor gets master-realm tokens to the beheer host. - Custom scripts that read the CSRF cookie must read
__Host-XSRF-TOKEN.
Offboard a municipality¶
Helix has no realm-delete operation in the console or the admin API. Offboarding is therefore an export followed by removing the realm's data object by object:
- Agree the scope with the municipality: what to hand over, what to delete, which retention periods apply to the audit log and backups.
-
Export the configuration and hand over personal data:
The export carries the realm's configuration and its users' profiles and role assignments, never passwords, MFA secrets, signing keys, sessions or the audit log (Import / export). Per user,
GET /admin/realms/{realm}/users/{userId}/gdpr/exportproduces the data-subject export (GDPR & privacy). Export the audit log from your log pipeline or withGET /admin/realms/{realm}/events. 3. Stop sign-ins: set the realm'senabledtofalse(PUT /admin/realms/{realm}/settings, or Realm settings in the console). A disabled realm refuses every sign-in, token issuance and refresh, introspects tokens as inactive and serves only discovery, JWKS and metadata; the admin API stays reachable (Realms). Then delete the realm's applications (cascades to their OIDC clients and SAML relying parties) and its identity providers (DELETE /admin/realms/{realm}/identity-providers/{alias}), and revoke the remaining sessions. 4. Erase users:DELETE /admin/realms/{realm}/users/{userId}/gdprfor every user. This also removes their DigiD, eHerkenning and wallet links and their (encrypted) BSN. 5. Retire keys that are not ACTIVE (DELETE /admin/realms/{realm}/keys/{keyId}); the ACTIVE RS256 key cannot be retired and stays, encrypted, with the realm's settings row. 6. What remains in the database: the realm's settings row, its active key, roles, flows and other configuration that has no delete endpoint, and its audit-log rows (removed only byretention.auditLogMaxAge, when set). Removing those is database work outside the product's supported operations. If an operator deletes the realm'srealm_configrow, the Flyway V85 trigger deletes that realm's wallet pepper, verifier access keys, in-flight wallet transactions and trusted list sources in the same transaction, so a realm created later under the same id starts without them (EUDI Wallet: when a realm is deleted); it removes nothing else. 7. Backups: the municipality's data stays in older backups until they expire under your backup retention; record that date in the offboarding report. 8. Schemes: end the DigiD connection with Logius and the eHerkenning contract with the broker.