A streamlined way to deploy an OpenLDAP server along with phpLDAPadmin and Self Service Password using Docker Compose. Built on the minimal cleanstart/openldap image (OpenLDAP 2.6).
- Minimal image: Uses
cleanstart/openldap— no shell, no bootstrap scripts, full control viaslapadd - Secure by default: Least-privilege ACLs per OU, SSHA-hashed rootDN passwords, ECDSA P-384 TLS certificates, isolated Docker network
- Pre-configured overlays: memberof, referential integrity, password policy, dynamic lists
- Service accounts: Dedicated OU with per-account ACL injection via scripts
- POSIX optional: POSIX support (posixAccount/shadowAccount) available via opt-in flag
- Administration scripts: Manage users, groups, and service accounts from the command line
graph TD
root["dc=example,dc=org"]
root --> users["ou=users<br/><i>inetOrgPerson</i>"]
root --> groups["ou=groups<br/><i>groupOfNames</i>"]
root --> svc["ou=service-accounts<br/><i>phpldapadmin, ssp, custom</i>"]
root --> pol["ou=policies<br/><i>Password policies</i>"]
Main database (dc=example,dc=org):
| Identity | userPassword | service-accounts | users | groups | policies | base DN |
|---|---|---|---|---|---|---|
| self | write | - | write | - | - | - |
| admin (ou=users) | write | write | write | write | read | write |
| adminconfig | - | - | read | - | - | - |
| ssp | write | - | - | - | read | - |
| phpldapadmin | - | - | read | read | read | - |
| anonymous | auth only | - | - | - | read | read |
Infrastructure databases:
| Identity | cn=config | cn=accesslog | cn=Monitor |
|---|---|---|---|
| adminconfig | manage | read | read |
| * | - | - | - |
Users and applications that need read access to ou=users or ou=groups must use a dedicated service account (see Service accounts).
Pick the layout that matches your availability needs. Each mode is self-contained in its own directory.
| Mode | Directory | Topology | Writes | Read scaling | Use when |
|---|---|---|---|---|---|
| Standalone | standalone/ |
1 OpenLDAP container | local only | n/a | dev / single-host prod |
| HA Active-Passive | ha-active-passive/ |
2 masters (MirrorMode) + N consumers + HAProxy first |
active master only | consumer replicas | clean failover, no conflict risk |
| HA Active-Active | ha-active-active/ |
N masters (N-way Multi-Master) + HAProxy roundrobin |
any node | any node | max availability, write throughput |
# Standalone
cd standalone && bash setup.sh
# HA Active-Passive — boot 3-VM test cluster
cd ha-active-passive/tests && vagrant up
# HA Active-Active — boot 3-VM test cluster
cd ha-active-active/tests && vagrant upEach HA mode boots a 3-VM VirtualBox cluster (192.168.56.10-12) running Docker + OpenLDAP 2.6 + HAProxy. See per-mode READMEs for details:
- Docker & Docker Compose
ldap-utils(ldapsearch,ldapadd,ldapmodify,ldapdelete)pwgen(for admin scripts password generation)- VirtualBox + Vagrant (HA modes only — for the test cluster)
| Identity | DN | Password |
|---|---|---|
| Admin user (subject to ACLs) | cn=admin,ou=users,dc=example,dc=org |
adminpassword |
| Config admin (rootDN) | cn=adminconfig,cn=config |
adminpasswordconfig |
| Data rootDN | cn=admin,dc=example,dc=org |
(SSHA-hashed in slapd-config.ldif) |
| Replicator (HA only) | cn=replicator,ou=service-accounts,dc=example,dc=org |
replicatorpassword |
Change all defaults before production use. See Password rotation below.
graph LR
root["."]
root --> standalone["standalone/<br/><i>mono-instance</i>"]
root --> hap["ha-active-passive/<br/><i>MirrorMode + consumer</i>"]
root --> haa["ha-active-active/<br/><i>N-way Multi-Master</i>"]
root --> shared["Shared resources"]
shared --> common["common.sh"]
shared --> s02["create-users.sh"]
shared --> s03["change-user-password.sh"]
shared --> s04["delete-users.sh"]
shared --> s05["create-group.sh"]
shared --> s06["add-service-account.sh"]
shared --> s07["change-service-account-password.sh"]
shared --> s08["delete-service-account.sh"]
shared --> initl["base-ldifs/<br/><i>base directory data (users, groups, ppolicy)</i>"]
standalone --> sa_compose["docker-compose.yml"]
standalone --> sa_setup["setup.sh"]
standalone --> sa_cfg["init-config/slapd-config.ldif"]
standalone --> sa_ssp["ssp.conf.php"]
standalone --> sa_certs["certs.sh + certs/"]
standalone --> sa_backup["backup/"]
hap --> hp_compose["docker-compose.yml"]
hap --> hp_setup["setup-node.sh"]
hap --> hp_cfg["init-config/<br/>slapd-config.ldif.tmpl"]
hap --> hp_ildifs["init-ldifs/replicator.ldif"]
hap --> hp_haproxy["haproxy/haproxy.cfg.tmpl"]
hap --> hp_env[".env.example"]
hap --> hp_certs["certs.sh + certs/"]
hap --> hp_backup["backup/"]
hap --> hp_tests["tests/<br/><i>Vagrantfile + provision.sh<br/>+ test-replication.sh</i>"]
haa --> ha_compose["docker-compose.yml"]
haa --> ha_setup["setup-node.sh"]
haa --> ha_cfg["init-config/<br/>slapd-config.ldif.tmpl"]
haa --> ha_ildifs["init-ldifs/replicator.ldif"]
haa --> ha_haproxy["haproxy/haproxy.cfg.tmpl"]
haa --> ha_env[".env.example"]
haa --> ha_certs["certs.sh + certs/"]
haa --> ha_backup["backup/"]
haa --> ha_tests["tests/<br/><i>Vagrantfile + provision.sh<br/>+ test-replication.sh</i>"]
# Change admin user password (cn=admin,ou=users) — uses the admin scripts
bash change-user-password.sh admin
# Change a rootDN password (cn=adminconfig,cn=config OR cn=admin,dc=example,dc=org)
docker run --rm --entrypoint slappasswd cleanstart/openldap:2.6.13 -s "NEW_PASSWORD"
ldapmodify -x -H ldap://localhost:389 -D "cn=adminconfig,cn=config" -w "adminpasswordconfig" <<EOF
dn: olcDatabase={0}config,cn=config
changetype: modify
replace: olcRootPW
olcRootPW: {SSHA}PASTE_HASH_HERE
EOFAfter changing the config admin password, update
CONFIG_ADMIN_PASSincommon.sh.
Run these from the repo root. They connect to ldap://localhost:389 by default (override LDAP_HOST/LDAP_PORT in common.sh if your deployment is elsewhere — e.g. target a specific HA node).
All scripts source common.sh for shared configuration (set -euo pipefail, LDAP connection, helpers). Passwords are never passed via -w on the command line (uses -y with temp files). Temp files are cleaned up on exit via trap.
All scripts use cn=admin,ou=users,dc=example,dc=org (subject to ACLs, not the rootDN).
Passwords are generated with pwgen -s -y -r '#<>\ "'"'"' 32 (32 chars, symbols, LDIF-safe).
Usernames must follow the firstname.lastname pattern. The script auto-populates cn, sn, givenName, displayName, and mail.
# Standard (inetOrgPerson only)
bash create-users.sh john.doe jane.smith --group=demo
# With POSIX attributes (requires nis schema enabled in slapd-config.ldif)
bash create-users.sh john.doe jane.smith --group=demo --posixbash change-user-password.sh john.doeAutomatically removes the user from all groups before deletion:
bash delete-users.sh john.doe jane.smithAt least one member is required (groupOfNames schema constraint):
bash create-group.sh groupName john.doe jane.smithCreate a service account with specific access rights. The script creates the account in ou=service-accounts and injects the access rule into the existing ACL for the target subtree:
# Read access to ou=users
bash add-service-account.sh gitea --access read --subtree "ou=users,dc=example,dc=org"
# Write access to ou=groups
bash add-service-account.sh myapp --access write --subtree "ou=groups,dc=example,dc=org"Change password:
bash change-service-account-password.sh giteaDelete (also cleans up ACL references):
bash delete-service-account.sh giteaPOSIX attributes (posixAccount, shadowAccount, uidNumber, gidNumber, homeDirectory, loginShell) are disabled by default.
To enable POSIX support, uncomment these lines in your mode's slapd-config (standalone/init-config/slapd-config.ldif or <ha-mode>/init-config/slapd-config.ldif.tmpl) before running the bootstrap (standalone/setup.sh or <ha-mode>/setup-node.sh):
# Schema
#include: file:///etc/openldap/schema/nis.ldif
# Index
#olcDbIndex: uidNumber,gidNumber eq
# ACL (insert as {1}, shift subsequent indexes)
#olcAccess: {1}to attrs=shadowLastChange by self write by * read
Then use --posix when creating users:
bash create-users.sh john.doe --posixcerts.sh and certs/ live inside each deployment mode. Steps below are run from your mode directory (standalone/, ha-active-active/, or ha-active-passive/):
- Generate certificates:
cd <mode> # standalone | ha-active-active | ha-active-passive
bash certs.sh- Uncomment the TLS lines in your mode's slapd-config (
init-config/slapd-config.ldiffor standalone,init-config/slapd-config.ldif.tmplfor HA), inside thecn=configentry:
olcTLSCACertificateFile: /etc/openldap/certs/openldapCA.crt
olcTLSCertificateFile: /etc/openldap/certs/openldap.crt
olcTLSCertificateKeyFile: /etc/openldap/certs/openldap.key
olcTLSVerifyClient: never
- Uncomment the
commandline indocker-compose.ymlto enableldaps://:
command: ["slapd", "-d", "0", "-h", "ldap:// ldaps://", "-F", "/etc/openldap/slapd.d"]- If using phpLDAPadmin over LDAPS (standalone only — HA phpLDAPadmin is optional and points at HAProxy), update the env vars in
docker-compose.yml:
- LDAP_CONNECTION=ldaps
- LDAP_PORT=636- Test (from inside your mode directory):
LDAPTLS_CACERT=./certs/openldapCA.crt ldapsearch -x -H ldaps://localhost:636 -D "cn=admin,ou=users,dc=example,dc=org" -w "adminpassword" -b "dc=example,dc=org"Note: If TLS is enabled after initial setup (without
--reset), you can add the TLS config at runtime vialdapmodifyoncn=configwithout re-bootstrapping.
Store backup files on an encrypted partition — they contain password hashes.
All commands below assume you cd <mode> first (standalone, ha-active-active, or ha-active-passive). Each mode has its own data/ and backup/. For HA, run the backup on each node.
Since cleanstart/openldap has no shell, backups are done via tar in an alpine container:
cd <mode>
# Config backup
docker run --rm -v ./data/slapd.d:/slapd.d:ro -v ./backup:/backup alpine:latest \
sh -c "tar czf /backup/config_$(date +%Y%m%d).tar.gz -C /slapd.d ."
# Data backup
docker run --rm -v ./data/openldap-data:/data:ro -v ./backup:/backup alpine:latest \
sh -c "tar czf /backup/data_$(date +%Y%m%d).tar.gz -C /data ."
# Accesslog backup
docker run --rm -v ./data/accesslog-data:/data:ro -v ./backup:/backup alpine:latest \
sh -c "tar czf /backup/accesslog_$(date +%Y%m%d).tar.gz -C /data ."cd <mode>
docker compose down
# Clean existing data
docker run --rm -v ./data:/data alpine:latest \
sh -c "rm -rf /data/slapd.d/* /data/openldap-data/* /data/accesslog-data/*"
# Restore config
docker run --rm -v ./data/slapd.d:/slapd.d -v ./backup:/backup alpine:latest \
sh -c "tar xzf /backup/config_DATE.tar.gz -C /slapd.d"
# Restore data
docker run --rm -v ./data/openldap-data:/data -v ./backup:/backup alpine:latest \
sh -c "tar xzf /backup/data_DATE.tar.gz -C /data"
# Restore accesslog
docker run --rm -v ./data/accesslog-data:/data -v ./backup:/backup alpine:latest \
sh -c "tar xzf /backup/accesslog_DATE.tar.gz -C /data"
# Fix permissions
docker run --rm \
-v ./data/slapd.d:/slapd.d \
-v ./data/openldap-data:/data \
-v ./data/accesslog-data:/alog \
alpine:latest sh -c "chown -R 101:102 /slapd.d /data /alog"
docker compose up -dReplace <mode> with your actual deployment dir (standalone, ha-active-active, ha-active-passive):
# Daily backup at 10 PM + cleanup after 30 days
0 22 * * * cd /path/to/OpenLDAP-docker-setup/<mode> && docker run --rm -v ./data/slapd.d:/slapd.d:ro -v ./backup:/backup alpine:latest sh -c "tar czf /backup/config_$(date +\%Y\%m\%d).tar.gz -C /slapd.d ."
0 22 * * * cd /path/to/OpenLDAP-docker-setup/<mode> && docker run --rm -v ./data/openldap-data:/data:ro -v ./backup:/backup alpine:latest sh -c "tar czf /backup/data_$(date +\%Y\%m\%d).tar.gz -C /data ."
0 22 * * * cd /path/to/OpenLDAP-docker-setup/<mode> && docker run --rm -v ./data/accesslog-data:/data:ro -v ./backup:/backup alpine:latest sh -c "tar czf /backup/accesslog_$(date +\%Y\%m\%d).tar.gz -C /data ."
0 23 * * * find /path/to/OpenLDAP-docker-setup/<mode>/backup -name "*.tar.gz" -mtime +30 -delete# List all entries
ldapsearch -x -H ldap://localhost:389 -D "cn=admin,ou=users,dc=example,dc=org" -w "adminpassword" -b "dc=example,dc=org"
# List modules
ldapsearch -x -H ldap://localhost:389 -D "cn=adminconfig,cn=config" -w "adminpasswordconfig" \
-b cn=config "(objectClass=olcModuleList)" olcModuleLoad -LLL
# View ACLs
ldapsearch -x -H ldap://localhost:389 -D "cn=adminconfig,cn=config" -w "adminpasswordconfig" \
-b "olcDatabase={1}mdb,cn=config" olcAccess -LLL
# Test service account access
ldapsearch -x -H ldap://localhost:389 -D "cn=gitea,ou=service-accounts,dc=example,dc=org" -w "PASSWORD" \
-b "ou=users,dc=example,dc=org" "(uid=john.doe)" cn mailCreate a dedicated service account instead of using the admin account:
bash add-service-account.sh myapp --access read --subtree "ou=users,dc=example,dc=org"Then configure your application with:
| Setting | Value |
|---|---|
| Server | ldap://IP_or_FQDN:389 |
| Base DN | dc=example,dc=org |
| Bind DN | cn=myapp,ou=service-accounts,dc=example,dc=org |
| Bind Password | (generated by the script) |
| User filter | (uid=%s) |
| User object class | inetOrgPerson |
| ID attribute | uid |
| Display name | displayName |
mail |
|
| First name | givenName |
| Last name | sn |
The back_monitor module is enabled in slapd-config.ldif. It exposes server statistics via cn=Monitor (connections, operations, threads, etc.), accessible with the config admin credentials:
ldapsearch -x -H ldap://localhost:389 -D "cn=adminconfig,cn=config" -w "adminpasswordconfig" \
-b "cn=Monitor" "(objectClass=*)" -LLLTo expose these metrics to Prometheus, use the OpenLDAP Prometheus Exporter. It connects to cn=Monitor and serves metrics on an HTTP endpoint for Prometheus scraping.
The default password policy (cn=defaultppolicy,ou=policies) enforces:
| Rule | Value |
|---|---|
| Minimum length | 16 characters |
| Quality check | Enabled |
| Max age | 365 days |
| Expiry warning | 7 days before |
| History | 5 passwords |
| Lockout after | 3 failed attempts |
| Lockout duration | 30 minutes |
| Must change on first login | Yes |
| Cleartext passwords | Auto-hashed server-side |