LDAP Directory
    TLS + ACLs

    Deploy OpenLDAP on a VPS

    Run a production OpenLDAP 2.6 directory on a RamNode VPS — TLS, a sane directory tree, real ACLs, memberOf and password policy overlays, backups, and replication.

    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 sizevCPURAMDisk
    Under 1,000 entries11 GB20 GB
    1,000 to 50,000 entries22 GB40 GB
    50,000+ entries or heavy search load2 to 44 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

    shell
    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/hosts

    Create DNS records:

    shell
    ldap.example.com.  A     203.0.113.10
    ldap.example.com.  AAAA  2001:db8::10

    Set 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.

    shell
    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 enable

    RamNode 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

    shell
    DEBIAN_FRONTEND=noninteractive apt install -y slapd ldap-utils
    dpkg-reconfigure -p low slapd

    Answer the prompts:

    • Omit OpenLDAP server configuration: No
    • DNS domain name: example.com (this becomes dc=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:

    shell
    systemctl status slapd
    ldapsearch -x -H ldapi:/// -b dc=example,dc=com -LLL

    Configuration 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:

    shell
    ldapsearch -Y EXTERNAL -H ldapi:/// -b cn=config -LLL dn

    Get 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.

    shell
    apt install -y slapd-contrib

    Load the module and set it as the default scheme. Create argon2.ldif:

    shell
    dn: cn=module{0},cn=config
    changetype: modify
    add: olcModuleLoad
    olcModuleLoad: argon2
    
    dn: olcDatabase={-1}frontend,cn=config
    changetype: modify
    replace: olcPasswordHash
    olcPasswordHash: {ARGON2}
    shell
    ldapmodify -Y EXTERNAL -H ldapi:/// -f argon2.ldif

    Now generate a new hash and set the directory admin password:

    shell
    slappasswd -o module-load=argon2 -h '{ARGON2}'

    Create rootpw.ldif with the output:

    shell
    dn: olcDatabase={1}mdb,cn=config
    changetype: modify
    replace: olcRootPW
    olcRootPW: {ARGON2}$argon2id$v=19$m=65536,t=2,p=1$...
    shell
    ldapmodify -Y EXTERNAL -H ldapi:/// -f rootpw.ldif

    Existing password hashes are not rewritten. Users get the new scheme the next time they change their password.


    5. Enable TLS

    shell
    apt install -y certbot
    certbot certonly --standalone -d ldap.example.com --agree-tos -m admin@example.com --no-eff-email

    slapd 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:

    shell
    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.pem

    Apply the configuration with tls.ldif:

    shell
    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: never
    shell
    ldapmodify -Y EXTERNAL -H ldapi:/// -f tls.ldif

    Debian 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:

    shell
    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.

    shell
    systemctl restart slapd
    ldapwhoami -x -H ldaps://ldap.example.com

    Automate renewal:

    shell
    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.sh

    Require 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:

    shell
    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: services
    shell
    ldapadd -x -D cn=admin,dc=example,dc=com -W -H ldaps://ldap.example.com -f base.ldif

    Add a user with POSIX attributes so the same entry serves both application logins and Unix logins. user.ldif:

    shell
    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/bash
    shell
    ldapadd -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=com

    Add a group. group.ldif:

    shell
    dn: cn=sysadmins,ou=groups,dc=example,dc=com
    objectClass: groupOfNames
    cn: sysadmins
    member: uid=anguyen,ou=people,dc=example,dc=com

    Create a dedicated read-only bind account for applications. Never let an application bind as cn=admin. svc.ldif:

    shell
    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 applications

    7. Add indexes

    An unindexed search scans the whole database. Fix that before you have real data.

    indexes.ldif:

    shell
    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 eq
    shell
    ldapmodify -Y EXTERNAL -H ldapi:/// -f indexes.ldif

    Also raise the LMDB map size from Debian's default before the database grows into it:

    shell
    dn: olcDatabase={1}mdb,cn=config
    changetype: modify
    replace: olcDbMaxSize
    olcDbMaxSize: 2147483648

    That 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:

    shell
    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 * none
    shell
    ldapmodify -Y EXTERNAL -H ldapi:/// -f acl.ldif

    Rules 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:

    shell
    ldapsearch -x -H ldaps://ldap.example.com -b dc=example,dc=com uid=anguyen

    That should return nothing. This should return the entry:

    shell
    ldapsearch -x -H ldaps://ldap.example.com \
      -D cn=svc-readonly,ou=services,dc=example,dc=com -W \
      -b dc=example,dc=com uid=anguyen

    9. Load the overlays you will actually want

    memberOf and referential integrity

    Most applications want to read group membership from the user entry. memberof.ldif:

    shell
    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 owner
    shell
    ldapadd -Y EXTERNAL -H ldapi:/// -f memberof.ldif

    The 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:

    shell
    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: TRUE

    Then create the policy entry itself:

    shell
    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: TRUE

    10. Logging

    shell
    dn: cn=config
    changetype: modify
    replace: olcLogLevel
    olcLogLevel: stats
    -
    replace: olcLogFile
    olcLogFile: /var/log/slapd.log

    stats logs connections, binds, and search bases without the flood that any produces. Rotate it:

    shell
    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
    }
    EOF

    Enable the monitor backend for metrics:

    shell
    ldapsearch -Y EXTERNAL -H ldapi:/// -b cn=monitor -LLL \
      '(|(cn=Total)(cn=Current))' monitorCounter

    11. 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.

    shell
    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-backup
    shell
    cat > /etc/cron.d/slapd-backup <<'EOF'
    15 2 * * * root /usr/local/sbin/slapd-backup
    EOF

    slapcat is safe to run against a live LMDB database. Restoring is not:

    shell
    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 slapd

    Take 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:

    shell
    # /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 = true
    shell
    chmod 600 /etc/sssd/sssd.conf
    systemctl restart sssd
    getent passwd anguyen

    Keep 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:

    shell
    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: 500

    Give each server a unique ID and a syncrepl consumer entry pointing at its peer. On node 1:

    shell
    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: TRUE

    Mirror 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.