Skip to content

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, resolves phil-db via CoreDNS (dns: 172.21.0.53)
  • TLS: Internal Step-CA cert at ./config/docker/certs/server.crt (renewed by cert-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 (NOT tiredofit/openldap:latest — broken/frozen)
  • Port: 389 (standard; was 1389 on bitnami)
  • Data volume: /data inside container
  • TLS: Step-CA certs at ./config/certs/ (cert-renewer, post-cert.sh)
  • Networks: ldap-network only
  • Data path: /data (volume ldap-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