Identity (Keycloak + OpenLDAP)¶
Stack:
stack/secure/(Keycloak),stack/ldap/(OpenLDAP) · Host:phil-app· Updated: 2026-02-27
Keycloak provides OIDC SSO for all services. OpenLDAP is the user directory backend for Keycloak federation.
Overview¶
Keycloak (sso.philipp.info/realms/family): OIDC provider for Nextcloud, Paperless, Friendica, Matrix/MAS, mailcow web UI, and others. The Family realm covers the household users.
OpenLDAP (dc=dieholzers,dc=local): 8 users, migrated from bitnamilegacy/openldap:2 to nfrastack/openldap:2.6 on 2026-02-27 (see ADR-006). LDAP is used only as a Keycloak User Federation backend — no service uses LDAP directly for auth.
Architecture¶
Keycloak (stack/secure/)¶
- Container:
secure-keycloak-1(image:quay.io/keycloak/keycloak) - DB: MariaDB on phil-db, database
keycloak, resolvesphil-dbvia CoreDNS (dns: 172.21.0.53) - TLS: Internal Step-CA cert at
./config/docker/certs/server.crt(renewed bycert-renewer@keycloak.timer) - Networks:
ldap-network(LDAP access),traefik-ingress(public HTTPS),ca-network(cert renewal) - Port: 8443 (internal), routed by Traefik to
sso.philipp.info
OpenLDAP (stack/ldap/)¶
- Image:
nfrastack/openldap:2.6(NOTtiredofit/openldap:latest— broken/frozen) - Port: 389 (standard; was 1389 on bitnami)
- Data volume:
/datainside container - TLS: Step-CA certs at
./config/certs/(cert-renewer, post-cert.sh) - Networks:
ldap-networkonly - Data path:
/data(volumeldap-vol-1) - Secrets:
ADMIN_PASS_FILE=/run/secrets/ldap_admin_password,CONFIG_PASS_FILE=/run/secrets/ldap_config_password
Step-CA (Internal PKI)¶
Step-CA (secure-ca-1) issues short-lived certs (24h) for Keycloak and LDAP, renewed automatically by cert-renewer@.timer systemd units.
| Cert | Path on host | Renewer override | Post-renewal |
|---|---|---|---|
| keycloak | /opt/docker/secure/config/docker/certs/server.crt |
cert-renewer@keycloak.service.d/override.conf |
chown 166536:165536 + docker compose restart keycloak |
| ldap | /opt/docker/ldap/config/certs/ldap.crt |
cert-renewer@ldap.service.d/override.conf |
chown 165925:165925 + docker compose restart app |
nfrastack LDAP user UID: UID 389 on Alpine → host UID 165925 with userns-remap (165536+389).
Configuration¶
Keycloak LDAP User Federation¶
Connection URL: ldap://ldap:389
Bind DN: cn=admin,dc=dieholzers,dc=local
User search base: ou=users,dc=dieholzers,dc=local
OpenLDAP key environment variables¶
DOMAIN: dieholzers.local
ORGANIZATION: "Die Holzers"
ENABLE_TLS: "TRUE"
TLS_CREATE_SELFSIGNED: "FALSE" # REQUIRED — use external step-ca certs
TLS_CERT_FILE: ldap.crt
TLS_KEY_FILE: ldap.key
TLS_CA_CERT_PATH: /certs/ # REQUIRED — set explicitly, default unreliable
TLS_CA_CERT_FILE: ca.crt
# do NOT set init: true — breaks s6-overlay (must be PID 1)
Operations¶
Health Check¶
# Keycloak
sudo docker logs secure-keycloak-1 --tail 20
# LDAP: verify users
sudo docker exec ldap-app-1 ldapsearch \
-x -H ldap://localhost:389 \
-D "cn=admin,dc=dieholzers,dc=local" \
-y /run/secrets/ldap_admin_password \
-b "dc=dieholzers,dc=local" "(objectClass=inetOrgPerson)" dn
# Step-CA cert expiry
sudo openssl x509 -in /opt/docker/secure/config/docker/certs/server.crt -noout -dates
sudo openssl x509 -in /opt/docker/ldap/config/certs/ldap.crt -noout -dates
Failure Cascades¶
Step-CA cert expired
→ Keycloak has no valid TLS cert
→ Keycloak OIDC discovery returns 404
→ Matrix (Synapse/MAS) fails to load OIDC provider → restart loop
→ Paperless OIDC login broken
→ All OIDC-dependent services fail
Rule: When Keycloak is down, check Step-CA certs first.
Issuing a New Certificate (when expired)¶
step ca renew fails if the cert is already expired. Issue a NEW cert manually:
# Issue via Step-CA container
sudo docker exec secure-ca-1 step ca certificate <name> /tmp/<name>.crt /tmp/<name>.key \
--ca-url https://localhost:9000 \
--provisioner-password-file /home/step/secrets/password
# Copy to host
sudo docker cp secure-ca-1:/tmp/<name>.crt <host-cert-path>
sudo docker cp secure-ca-1:/tmp/<name>.key <host-key-path>
# Fix ownership + restart
# Keycloak: chown 166536:165536 + docker compose restart keycloak
# LDAP: chown 165925:165925 + docker compose restart app
Keycloak DB reconnect¶
Keycloak does NOT auto-reconnect after a MariaDB restart. After phil-db MariaDB comes back:
cd /opt/docker/secure && sudo docker compose restart keycloak
Backup¶
Keycloak and LDAP data are backed up by Borgmatic as part of the Docker volume backup jobs. The relevant volumes are included in the per-stack borgmatic configs.
LDAP data (ldap-vol-1) is relatively small (~MBs). The authoritative copy is also in stack/ldap/config/ldifs/dieholzers.ldif (committed to git).
For manual LDAP export:
sudo docker exec ldap-app-1 slapcat -n 1 -l /tmp/ldap-export.ldif
sudo docker cp ldap-app-1:/tmp/ldap-export.ldif ./ldap-backup-$(date +%Y%m%d).ldif
# Strip operational attrs before reimporting:
python3 scripts/clean-kopano-ldif.py ./ldap-backup-*.ldif > clean.ldif
phpLDAPadmin¶
Available at ldap.philipp.info:8993 (Traefik customsecure entrypoint — admin dashboards only).
Pitfalls¶
Keycloak does not auto-reconnect to MariaDB¶
"Connection refused to phil-db" in Keycloak logs can be a transient MariaDB restart, not a networking issue. Restart Keycloak after MariaDB comes back.
init: true breaks s6-overlay (nfrastack/openldap)¶
init: true injects tini as PID 1. nfrastack uses s6-overlay which must be PID 1 itself.
Fix: Do NOT set init: true on the app service.
tiredofit/openldap:latest is broken¶
The project renamed from tiredofit/docker-openldap to nfrastack/container-openldap. The tiredofit/openldap:latest tag is frozen at an old version that crashes on startup (Aborted on modern Alpine/kernels).
Fix: Use nfrastack/openldap:2.6.
256 MiB OOM-kills during first-run schema loading¶
First start loads all schema files and requires more memory than normal operation.
Fix: memory: 512m during bootstrap. Can be tuned down after first start.
Silent TLS failure with TLS_CREATE_SELFSIGNED=TRUE (default)¶
When using external Step-CA certs, the init script tries to create a self-signed CA key in /certs/ (which may not be writable by the ldap user). The silent wrapper and set -e cause an immediate silent exit — no TRACE output visible.
Fix: TLS_CREATE_SELFSIGNED: "FALSE". Diagnosis tip: set ENABLE_TLS=FALSE first to confirm base functionality.
TLS_CA_CERT_PATH must be set explicitly¶
Even though /certs/ is the documented default, the CA cert is not reliably picked up without explicitly setting TLS_CA_CERT_PATH: /certs/.
Wrong TLS env var names (bitnami vs nfrastack)¶
| bitnami style | nfrastack correct name |
|---|---|
TLS_CRT_FILENAME |
TLS_CERT_FILE |
TLS_KEY_FILENAME |
TLS_KEY_FILE |
TLS_CA_CRT_FILENAME |
TLS_CA_CERT_FILE |
Using bitnami-style names silently falls through to defaults.
slapcat exports operational attributes (err=19 on import)¶
slapcat -n 1 includes structuralObjectClass, entryUUID, entryCSN, creatorsName, etc. that ldapadd rejects with err=19: no user modification allowed.
Fix: Use scripts/clean-kopano-ldif.py to strip these. Always use ldapadd -c (continue-on-error) for import.
TRACE logging invisible when TLS init fails¶
CONTAINER_LOG_LEVEL=TRACE only helps after the logging system initializes. When TLS init fails early, set -e + silent wrapper suppress all output.
Workaround: Disable TLS first (ENABLE_TLS=FALSE), confirm base startup, then diagnose TLS separately.
cert-renewer base unit has WRONG default paths¶
The base unit at /etc/systemd/system/cert-renewer@.service defines CERT_LOCATION=/etc/step/certs/%i.crt — this path does NOT exist. The actual paths are set in per-service override.conf files. Always check the override, not the base unit.
Max cert duration is 24h¶
Do not pass --not-after 8760h or similar — the CA rejects anything over 24h1m.
Expired certs cannot be renewed¶
step ca renew fails if the cert is already expired. Issue a NEW cert manually (see Operations above), then the renewer takes over.
Decisions¶
- ADR-006 — why nfrastack/openldap was chosen, migration procedure