OpenLDAP is the reference implementation of the LDAP protocol and the directory server that most other identity products are measured against. It does one thing: store and serve a hierarchical directory, fast, with fine-grained access control. It does not give you a web UI, a Kerberos KDC, a certificate authority, or single sign-on. If you want a directory that applications can bind against and you want full control over the schema and ACLs, this is the right tool. If you want an out-of-the-box identity platform, look at FreeIPA or Kanidm instead.
This guide covers a production single-server OpenLDAP deployment on a RamNode KVM VPS with TLS, a sane directory tree, real access control, the memberOf and password policy overlays, backups, and an appendix on replication.
Verified against OpenLDAP 2.6.x, which is the current LTS series (2.6.13 released March 2026). Debian 13 ships 2.6.10.
1. Pick the right RamNode instance
slapd with the default LMDB backend is extremely efficient. The database is memory-mapped, so the useful sizing rule is that your working set should fit in RAM.
| Deployment size | vCPU | RAM | Disk |
|---|---|---|---|
| Under 1,000 entries | 1 | 1 GB | 20 GB |
| 1,000 to 50,000 entries | 2 | 2 GB | 40 GB |
| 50,000+ entries or heavy search load | 2 to 4 | 4 GB+ | 60 GB+ |
Order a Standard or Premium KVM plan. Choose a location near the applications that will bind against it, because every authentication in your stack becomes a round trip to this box.
Distribution choice matters here
Use Debian 13 or Ubuntu 24.04. Red Hat removed the openldap-servers package in RHEL 8 and it has not returned, so on AlmaLinux or Rocky you would be installing third-party Symas packages or building from source. Neither is wrong, but Debian's packaging is the well-trodden path and this guide follows it.
2. Prepare the server
apt update && apt full-upgrade -y
apt install -y ufw
hostnamectl set-hostname ldap.example.com
echo "203.0.113.10 ldap.example.com ldap" >> /etc/hostsCreate DNS records:
ldap.example.com. A 203.0.113.10
ldap.example.com. AAAA 2001:db8::10Set the PTR record in the RamNode control panel. TLS certificate validation and log readability both depend on the name being consistent everywhere.
Firewall
Plaintext LDAP on 389 should never be reachable from the internet. Expose LDAPS on 636 to the applications that need it and nothing else.
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp # Let's Encrypt only
ufw allow from 198.51.100.25 to any port 636 proto tcp # app server
ufw enableRamNode instances do not share a private network, so if your application servers live on other RamNode VPSs, put them all on a WireGuard mesh and bind LDAP to the tunnel addresses. That is a better answer than opening 636 to a list of public IPs.
3. Install slapd
DEBIAN_FRONTEND=noninteractive apt install -y slapd ldap-utils
dpkg-reconfigure -p low slapdAnswer the prompts:
- Omit OpenLDAP server configuration: No
- DNS domain name:
example.com(this becomesdc=example,dc=com) - Organization name: your organization
- Administrator password: strong, saved in your password manager
- Database backend: MDB
- Remove the database when slapd is purged: No
- Move old database: Yes
Confirm it is running and answering:
systemctl status slapd
ldapsearch -x -H ldapi:/// -b dc=example,dc=com -LLLConfiguration lives in the cn=config tree, not in a slapd.conf file. You change it with LDAP operations, not a text editor. Root can authenticate to it over the local socket with SASL EXTERNAL:
ldapsearch -Y EXTERNAL -H ldapi:/// -b cn=config -LLL dnGet used to that command. Every configuration change in this guide uses the same authentication method.
4. Set the admin password properly
The installer set a password hash for cn=admin,dc=example,dc=com. Verify the hashing scheme in use and upgrade it if you want Argon2 rather than the default SSHA.
apt install -y slapd-contribLoad the module and set it as the default scheme. Create argon2.ldif:
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: argon2
dn: olcDatabase={-1}frontend,cn=config
changetype: modify
replace: olcPasswordHash
olcPasswordHash: {ARGON2}ldapmodify -Y EXTERNAL -H ldapi:/// -f argon2.ldifNow generate a new hash and set the directory admin password:
slappasswd -o module-load=argon2 -h '{ARGON2}'Create rootpw.ldif with the output:
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcRootPW
olcRootPW: {ARGON2}$argon2id$v=19$m=65536,t=2,p=1$...ldapmodify -Y EXTERNAL -H ldapi:/// -f rootpw.ldifExisting password hashes are not rewritten. Users get the new scheme the next time they change their password.
5. Enable TLS
apt install -y certbot
certbot certonly --standalone -d ldap.example.com --agree-tos -m admin@example.com --no-eff-emailslapd runs as the openldap user and needs to read the key. Copy rather than symlink into Let's Encrypt's directory, and keep permissions tight:
mkdir -p /etc/ldap/tls
cp /etc/letsencrypt/live/ldap.example.com/fullchain.pem /etc/ldap/tls/
cp /etc/letsencrypt/live/ldap.example.com/privkey.pem /etc/ldap/tls/
chown -R openldap:openldap /etc/ldap/tls
chmod 600 /etc/ldap/tls/privkey.pemApply the configuration with tls.ldif:
dn: cn=config
changetype: modify
replace: olcTLSCertificateFile
olcTLSCertificateFile: /etc/ldap/tls/fullchain.pem
-
replace: olcTLSCertificateKeyFile
olcTLSCertificateKeyFile: /etc/ldap/tls/privkey.pem
-
replace: olcTLSCipherSuite
olcTLSCipherSuite: SECURE256:-VERS-ALL:+VERS-TLS1.3:+VERS-TLS1.2
-
replace: olcTLSVerifyClient
olcTLSVerifyClient: neverldapmodify -Y EXTERNAL -H ldapi:/// -f tls.ldifDebian builds slapd against GnuTLS, so the cipher suite string uses GnuTLS priority syntax rather than OpenSSL syntax.
Enable the LDAPS listener in /etc/default/slapd:
SLAPD_SERVICES="ldap://127.0.0.1:389/ ldaps:/// ldapi:///"Binding plaintext LDAP to loopback only means nothing on the wire is ever unencrypted, even by accident.
systemctl restart slapd
ldapwhoami -x -H ldaps://ldap.example.comAutomate renewal:
cat > /etc/letsencrypt/renewal-hooks/deploy/slapd.sh <<'EOF'
#!/bin/bash
set -e
cp /etc/letsencrypt/live/ldap.example.com/fullchain.pem /etc/ldap/tls/
cp /etc/letsencrypt/live/ldap.example.com/privkey.pem /etc/ldap/tls/
chown -R openldap:openldap /etc/ldap/tls
chmod 600 /etc/ldap/tls/privkey.pem
systemctl restart slapd
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/slapd.shRequire encryption for anything that touches credentials by adding olcSecurity: tls=1 to the database entry, or enforce it per-ACL as shown in section 8.
6. Build the directory tree
Create base.ldif:
dn: ou=people,dc=example,dc=com
objectClass: organizationalUnit
ou: people
dn: ou=groups,dc=example,dc=com
objectClass: organizationalUnit
ou: groups
dn: ou=services,dc=example,dc=com
objectClass: organizationalUnit
ou: servicesldapadd -x -D cn=admin,dc=example,dc=com -W -H ldaps://ldap.example.com -f base.ldifAdd a user with POSIX attributes so the same entry serves both application logins and Unix logins. user.ldif:
dn: uid=anguyen,ou=people,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: anguyen
cn: Alice Nguyen
sn: Nguyen
givenName: Alice
mail: alice@example.com
uidNumber: 10001
gidNumber: 10001
homeDirectory: /home/anguyen
loginShell: /bin/bashldapadd -x -D cn=admin,dc=example,dc=com -W -H ldaps://ldap.example.com -f user.ldif
ldappasswd -x -D cn=admin,dc=example,dc=com -W -H ldaps://ldap.example.com \
-S uid=anguyen,ou=people,dc=example,dc=comAdd a group. group.ldif:
dn: cn=sysadmins,ou=groups,dc=example,dc=com
objectClass: groupOfNames
cn: sysadmins
member: uid=anguyen,ou=people,dc=example,dc=comCreate a dedicated read-only bind account for applications. Never let an application bind as cn=admin. svc.ldif:
dn: cn=svc-readonly,ou=services,dc=example,dc=com
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: svc-readonly
userPassword: {ARGON2}$argon2id$...
description: Read-only bind account for applications7. Add indexes
An unindexed search scans the whole database. Fix that before you have real data.
indexes.ldif:
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcDbIndex
olcDbIndex: objectClass eq
olcDbIndex: cn,sn,givenName,mail pres,eq,sub
olcDbIndex: uid,uidNumber,gidNumber eq
olcDbIndex: member,memberUid,memberOf eq
olcDbIndex: entryUUID,entryCSN eqldapmodify -Y EXTERNAL -H ldapi:/// -f indexes.ldifAlso raise the LMDB map size from Debian's default before the database grows into it:
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcDbMaxSize
olcDbMaxSize: 2147483648That is 2 GB of address space, not disk usage. LMDB allocates sparsely.
8. Write real access control
The default Debian ACLs are permissive about anonymous reads. Replace them wholesale. acl.ldif:
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcAccess
olcAccess: {0}to attrs=userPassword,shadowLastChange
by self write
by anonymous auth
by dn="cn=admin,dc=example,dc=com" write
by * none
olcAccess: {1}to dn.base="" by * read
olcAccess: {2}to dn.subtree="ou=people,dc=example,dc=com"
by self write
by dn="cn=admin,dc=example,dc=com" write
by dn="cn=svc-readonly,ou=services,dc=example,dc=com" read
by users read
by * none
olcAccess: {3}to dn.subtree="ou=groups,dc=example,dc=com"
by dn="cn=admin,dc=example,dc=com" write
by dn="cn=svc-readonly,ou=services,dc=example,dc=com" read
by users read
by * none
olcAccess: {4}to *
by dn="cn=admin,dc=example,dc=com" write
by * noneldapmodify -Y EXTERNAL -H ldapi:/// -f acl.ldifRules are evaluated in order and the first match wins, so the specific userPassword rule must come first. by anonymous auth is what allows a bind to succeed without letting anyone read the hash.
Verify anonymous access is closed:
ldapsearch -x -H ldaps://ldap.example.com -b dc=example,dc=com uid=anguyenThat should return nothing. This should return the entry:
ldapsearch -x -H ldaps://ldap.example.com \
-D cn=svc-readonly,ou=services,dc=example,dc=com -W \
-b dc=example,dc=com uid=anguyen9. Load the overlays you will actually want
memberOf and referential integrity
Most applications want to read group membership from the user entry. memberof.ldif:
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: memberof
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: refint
dn: olcOverlay=memberof,olcDatabase={1}mdb,cn=config
objectClass: olcOverlayConfig
objectClass: olcMemberOf
olcOverlay: memberof
olcMemberOfRefInt: TRUE
olcMemberOfGroupOC: groupOfNames
olcMemberOfMemberAD: member
olcMemberOfMemberOfAD: memberOf
dn: olcOverlay=refint,olcDatabase={1}mdb,cn=config
objectClass: olcOverlayConfig
objectClass: olcRefintConfig
olcOverlay: refint
olcRefintAttribute: member memberOf manager ownerldapadd -Y EXTERNAL -H ldapi:/// -f memberof.ldifThe overlay only populates memberOf for changes made after it loads. Existing groups need their members removed and re-added, so load it before you import data.
Password policy
The ppolicy schema is built into slapd 2.5 and later, so no schema import is needed. ppolicy.ldif:
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: ppolicy
dn: olcOverlay=ppolicy,olcDatabase={1}mdb,cn=config
objectClass: olcOverlayConfig
objectClass: olcPPolicyConfig
olcOverlay: ppolicy
olcPPolicyDefault: cn=default,ou=policies,dc=example,dc=com
olcPPolicyHashCleartext: TRUE
olcPPolicyUseLockout: TRUEThen create the policy entry itself:
dn: ou=policies,dc=example,dc=com
objectClass: organizationalUnit
ou: policies
dn: cn=default,ou=policies,dc=example,dc=com
objectClass: pwdPolicy
objectClass: device
cn: default
pwdAttribute: userPassword
pwdMinLength: 12
pwdMaxAge: 31536000
pwdInHistory: 5
pwdMaxFailure: 5
pwdLockout: TRUE
pwdLockoutDuration: 900
pwdMustChange: TRUE10. Logging
dn: cn=config
changetype: modify
replace: olcLogLevel
olcLogLevel: stats
-
replace: olcLogFile
olcLogFile: /var/log/slapd.logstats logs connections, binds, and search bases without the flood that any produces. Rotate it:
cat > /etc/logrotate.d/slapd <<'EOF'
/var/log/slapd.log {
daily
rotate 14
compress
missingok
notifempty
postrotate
systemctl reload slapd > /dev/null 2>&1 || true
endscript
}
EOFEnable the monitor backend for metrics:
ldapsearch -Y EXTERNAL -H ldapi:/// -b cn=monitor -LLL \
'(|(cn=Total)(cn=Current))' monitorCounter11. Backups
Back up both trees. The configuration is as important as the data, and a data-only backup restored onto a fresh install with default ACLs is a security incident waiting to happen.
cat > /usr/local/sbin/slapd-backup <<'EOF'
#!/bin/bash
set -e
DEST=/var/backups/ldap
STAMP=$(date +%F)
mkdir -p "$DEST"
slapcat -n 0 -l "$DEST/config-$STAMP.ldif"
slapcat -n 1 -l "$DEST/data-$STAMP.ldif"
gzip -f "$DEST/config-$STAMP.ldif" "$DEST/data-$STAMP.ldif"
find "$DEST" -name '*.ldif.gz' -mtime +30 -delete
rsync -az "$DEST/" backup@backup.example.com:/srv/backups/ldap/
EOF
chmod 700 /usr/local/sbin/slapd-backupcat > /etc/cron.d/slapd-backup <<'EOF'
15 2 * * * root /usr/local/sbin/slapd-backup
EOFslapcat is safe to run against a live LMDB database. Restoring is not:
systemctl stop slapd
rm -rf /etc/ldap/slapd.d/* /var/lib/ldap/*
slapadd -n 0 -F /etc/ldap/slapd.d -l config.ldif
slapadd -n 1 -F /etc/ldap/slapd.d -l data.ldif
chown -R openldap:openldap /etc/ldap/slapd.d /var/lib/ldap
systemctl start slapdTake a RamNode snapshot before any restore attempt or major version upgrade.
12. Connect a client
Point an application at ldaps://ldap.example.com:636, bind as cn=svc-readonly,ou=services,dc=example,dc=com, and search with base ou=people,dc=example,dc=com and filter (uid=%s).
For Unix logins on another server, use SSSD rather than the legacy libnss-ldap stack:
# /etc/sssd/sssd.conf
[sssd]
domains = example.com
services = nss, pam
[domain/example.com]
id_provider = ldap
auth_provider = ldap
ldap_uri = ldaps://ldap.example.com
ldap_search_base = dc=example,dc=com
ldap_default_bind_dn = cn=svc-readonly,ou=services,dc=example,dc=com
ldap_default_authtok = REDACTED
ldap_tls_reqcert = demand
cache_credentials = truechmod 600 /etc/sssd/sssd.conf
systemctl restart sssd
getent passwd anguyenKeep a second root session open the first time you enable LDAP authentication on a host.
13. Appendix: replication
Two-node mirror mode gives you a directory that survives losing one VPS. Run the replication traffic over WireGuard between the two RamNode instances rather than across the public internet.
On both servers, load the syncprov overlay:
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: syncprov
dn: olcOverlay=syncprov,olcDatabase={1}mdb,cn=config
objectClass: olcOverlayConfig
objectClass: olcSyncProvConfig
olcOverlay: syncprov
olcSpCheckpoint: 100 10
olcSpSessionLog: 500Give each server a unique ID and a syncrepl consumer entry pointing at its peer. On node 1:
dn: cn=config
changetype: modify
replace: olcServerID
olcServerID: 1
dn: olcDatabase={1}mdb,cn=config
changetype: modify
add: olcSyncRepl
olcSyncRepl: rid=002
provider=ldaps://ldap2.example.com
bindmethod=simple
binddn="cn=replicator,ou=services,dc=example,dc=com"
credentials="REDACTED"
searchbase="dc=example,dc=com"
type=refreshAndPersist
retry="10 +"
timeout=1
-
add: olcMirrorMode
olcMirrorMode: TRUEMirror the same configuration on node 2 with olcServerID: 2 and rid=001 pointing back at node 1. Create the cn=replicator account in ou=services on both, grant it read access to everything including operational attributes, and confirm convergence by writing an entry on one node and searching for it on the other.
14. Troubleshooting
ldap_bind: Invalid credentials (49). Wrong DN, wrong password, or the ppolicy overlay has locked the account. Check with ldapsearch -Y EXTERNAL -H ldapi:/// -b uid=anguyen,ou=people,dc=example,dc=com pwdAccountLockedTime.
ldap_sasl_interactive_bind_s: Can't contact LDAP server (-1). With TLS involved this is usually certificate validation on the client, not a network failure. Test with LDAPTLS_REQCERT=never to confirm, then fix the CA trust rather than leaving that setting in place.
Configuration change rejected with "no global superior knowledge". You are trying to add an entry whose parent does not exist. Add the parent first.
Searches are slow as data grows. Check for unindexed searches in the log: grep "not indexed" /var/log/slapd.log.
slapd will not start after a config edit. Debian keeps a backup at /etc/ldap/slapd.d. Validate offline with slaptest -F /etc/ldap/slapd.d -u.
Everything reads as anonymous. Your final olcAccess catch-all rule is missing, or an earlier rule with by * read is matching first. Order is everything.
Where to go next
Once the directory is serving traffic, add replication, then put a management layer on top so you are not writing LDIF for every new hire. Common choices are FusionDirectory, LDAP Account Manager, or a small internal tool against the same read-write bind account. If you find yourself building password self-service, MFA, and OIDC on top of OpenLDAP, that is the signal to evaluate Kanidm or FreeIPA instead, since both ship those pieces already integrated.
