Lightweight LDAP
    Docker

    Deploy LLDAP on a VPS

    Self-host LLDAP, a lightweight Rust LDAP server with a clean web UI, on a RamNode VPS with Docker, TLS, and integrations for Nextcloud, Grafana, Authelia, and more.

    Slug: deploy-lldap-lightweight-ldap-server-vps Difficulty: Beginner to Intermediate Estimated time: 30 minutes Tested on: Ubuntu 24.04 LTS, LLDAP v0.6.3


    Introduction

    LLDAP is a lightweight LDAP server written in Rust. It exists because OpenLDAP is miserable to configure for the common case: a small directory of users and groups that a handful of self-hosted services need to authenticate against.

    LLDAP gives you a clean web UI, a GraphQL API, and enough of the LDAP protocol that Nextcloud, Grafana, Jellyfin, Gitea, Authelia, Vaultwarden, Portainer, and the entire *arr stack authenticate against it without complaint. It idles at roughly 20 MB of RAM.

    What LLDAP is not: a full directory server. It does not do replication, arbitrary schema, ACLs, or referrals. If you need those, you need FreeIPA or 389-DS. If you need one login across a dozen self-hosted services, LLDAP is the right tool and you will have it running in half an hour.

    RamNode VPS sizing

    WorkloadvCPURAMDisk
    LLDAP alone1512 MB10 GB
    LLDAP plus a reverse proxy and SSO layer11 GB20 GB

    This is one of the lightest services you can self-host. It comfortably shares a box with other applications.

    Prerequisites

    • A RamNode KVM VPS running Ubuntu 24.04 LTS
    • Root or sudo access
    • A domain pointed at the VPS. This guide uses ldap.example.com for the web UI
    • Ports 80 and 443 open

    Step 1: Install Docker

    LLDAP is distributed as an OCI image and this is the path most people should take. Native packages are covered in the appendix.

    shell
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y ca-certificates curl gnupg
    
    sudo install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
      | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
    sudo chmod a+r /etc/apt/keyrings/docker.gpg
    
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
    https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
      | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    
    sudo apt update
    sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    sudo systemctl enable --now docker

    Step 2: Generate secrets

    LLDAP needs two secrets and an admin password.

    • JWT secret: signs session tokens
    • Key seed: derives the server private key used for password hashing

    Losing or changing the key seed invalidates every stored password. Generate both once and back them up.

    shell
    sudo mkdir -p /opt/lldap/secrets
    cd /opt/lldap
    
    openssl rand -base64 48 | tr -d '\n' | sudo tee secrets/jwt_secret > /dev/null
    openssl rand -base64 32 | tr -d '\n' | sudo tee secrets/key_seed > /dev/null
    openssl rand -base64 24 | tr -d '\n' | sudo tee secrets/admin_password > /dev/null
    
    sudo chmod 600 secrets/*
    sudo cat secrets/admin_password; echo

    Note the admin password. You will need it for the first login.


    Step 3: Write the Compose file

    Create /opt/lldap/docker-compose.yml.

    shell
    services:
      lldap:
        image: lldap/lldap:stable
        container_name: lldap
        restart: unless-stopped
        ports:
          # LDAP, bound to loopback only. Services on this host connect here.
          - "127.0.0.1:3890:3890"
          # Web UI, bound to loopback. nginx terminates TLS in front of it.
          - "127.0.0.1:17170:17170"
        volumes:
          - ./data:/data
          - ./secrets:/secrets:ro
        environment:
          - UID=1000
          - GID=1000
          - TZ=UTC
          - LLDAP_LDAP_BASE_DN=dc=example,dc=com
          - LLDAP_LDAP_USER_DN=admin
          - LLDAP_LDAP_USER_EMAIL=admin@example.com
          - LLDAP_HTTP_URL=https://ldap.example.com
          - LLDAP_JWT_SECRET_FILE=/secrets/jwt_secret
          - LLDAP_KEY_SEED_FILE=/secrets/key_seed
          - LLDAP_LDAP_USER_PASS_FILE=/secrets/admin_password
          - LLDAP_DATABASE_URL=sqlite:///data/users.db?mode=rwc
          - LLDAP_VERBOSE=false

    Key points:

    • The _FILE variants read secrets from files rather than putting them in the environment, where docker inspect would expose them. These take precedence over the non-file variables.
    • LLDAP_LDAP_BASE_DN must match your domain. Change dc=example,dc=com to yours. Changing it after users exist means starting over, so get it right now.
    • LLDAP_HTTP_URL is used to build password reset links. It must be the public HTTPS URL.
    • Use the :stable tag, not :latest. The latest tag tracks unreleased code.
    • Port 3890 is bound to 127.0.0.1. Never expose plaintext LDAP to the internet.

    Create the data directory with matching ownership.

    shell
    sudo mkdir -p /opt/lldap/data
    sudo chown -R 1000:1000 /opt/lldap/data /opt/lldap/secrets

    Start it.

    shell
    cd /opt/lldap
    sudo docker compose up -d
    sudo docker compose logs -f

    Look for the LDAP server binding on 3890 and the HTTP server on 17170. If you see a warning about a default JWT secret or an insecure default admin password, your secret files are not being read. Check the mount path and permissions before continuing.


    Step 4: Reverse proxy with TLS

    Install nginx and Certbot.

    shell
    sudo apt install -y nginx certbot python3-certbot-nginx

    Create /etc/nginx/sites-available/lldap.

    shell
    server {
        listen 80;
        server_name ldap.example.com;
        return 301 https://$host$request_uri;
    }
    
    server {
        listen 443 ssl;
        http2 on;
        server_name ldap.example.com;
    
        ssl_certificate     /etc/letsencrypt/live/ldap.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/ldap.example.com/privkey.pem;
    
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    
        location / {
            proxy_pass http://127.0.0.1:17170;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }

    Enable and issue the certificate.

    shell
    sudo ln -s /etc/nginx/sites-available/lldap /etc/nginx/sites-enabled/
    sudo nginx -t && sudo systemctl reload nginx
    sudo certbot --nginx -d ldap.example.com

    Lock down the firewall.

    shell
    sudo ufw allow OpenSSH
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw --force enable

    Port 3890 stays closed. Services on the same host reach LDAP over loopback. Services on other hosts get a WireGuard tunnel or LDAPS, covered below.


    Step 5: First login and initial setup

    Open https://ldap.example.com and log in as admin with the password from Step 2.

    Create the groups your services will map to. Common ones:

    • lldap_admin already exists and grants full LLDAP administration
    • lldap_password_manager already exists and allows resetting other users' passwords without full admin
    • lldap_strict_readonly already exists for read-only API access

    Add your own, matching whatever your applications expect:

    shell
    nextcloud_users
    grafana_admins
    media_users
    vpn_users

    Then create your first real user. Do not use the admin account for day-to-day logins. Create a personal account, add it to lldap_admin, and keep admin as a break-glass credential.


    Step 6: Create a read-only bind account

    Applications that authenticate against LDAP need a service account to perform searches. Do not give them the admin credentials.

    1. Create a user named bind_user in the web UI with a long random password
    2. Add it to the lldap_strict_readonly group

    Its bind DN is:

    shell
    uid=bind_user,ou=people,dc=example,dc=com

    This is the account you hand to Nextcloud, Grafana, and everything else. It can read the directory and validate passwords, and nothing more.


    Step 7: Verify with LDAP tooling

    Install the client utilities.

    shell
    sudo apt install -y ldap-utils

    Confirm a bind works.

    shell
    ldapwhoami -x -H ldap://127.0.0.1:3890 \
      -D "uid=admin,ou=people,dc=example,dc=com" \
      -W

    List all users.

    shell
    ldapsearch -x -H ldap://127.0.0.1:3890 \
      -D "uid=bind_user,ou=people,dc=example,dc=com" \
      -W \
      -b "dc=example,dc=com" \
      "(objectClass=person)"

    Query a single group's members.

    shell
    ldapsearch -x -H ldap://127.0.0.1:3890 \
      -D "uid=bind_user,ou=people,dc=example,dc=com" \
      -W \
      -b "ou=groups,dc=example,dc=com" \
      "(cn=nextcloud_users)"

    Understanding the DN structure saves a lot of guesswork later:

    ObjectDN pattern
    Usersuid=<username>,ou=people,dc=example,dc=com
    Groupscn=<groupname>,ou=groups,dc=example,dc=com
    User search baseou=people,dc=example,dc=com
    Group search baseou=groups,dc=example,dc=com

    Standard user filter: (&(objectClass=person)(uid=%s)) Standard group filter: (&(objectClass=groupOfUniqueNames)(uniqueMember=%s))


    If any service connects from another host, encrypt the LDAP traffic. Reuse the Let's Encrypt certificate you already have.

    Copy the certificate into the LLDAP data directory and set up a renewal hook.

    shell
    sudo mkdir -p /opt/lldap/certs
    sudo cp /etc/letsencrypt/live/ldap.example.com/fullchain.pem /opt/lldap/certs/cert.pem
    sudo cp /etc/letsencrypt/live/ldap.example.com/privkey.pem /opt/lldap/certs/key.pem
    sudo chown -R 1000:1000 /opt/lldap/certs
    sudo chmod 640 /opt/lldap/certs/key.pem

    Add to the Compose environment block:

    shell
          - LLDAP_LDAPS_OPTIONS__ENABLED=true
          - LLDAP_LDAPS_OPTIONS__PORT=6360
          - LLDAP_LDAPS_OPTIONS__CERT_FILE=/certs/cert.pem
          - LLDAP_LDAPS_OPTIONS__KEY_FILE=/certs/key.pem

    Add the volume and the port:

    shell
        volumes:
          - ./data:/data
          - ./secrets:/secrets:ro
          - ./certs:/certs:ro
        ports:
          - "127.0.0.1:3890:3890"
          - "6360:6360"
          - "127.0.0.1:17170:17170"

    Nested config keys use a double underscore as the separator in environment variables. That is the part people get wrong.

    Open the port and restart.

    shell
    sudo ufw allow 6360/tcp
    cd /opt/lldap && sudo docker compose up -d

    Add a Certbot deploy hook at /etc/letsencrypt/renewal-hooks/deploy/lldap.sh:

    shell
    #!/bin/bash
    cp /etc/letsencrypt/live/ldap.example.com/fullchain.pem /opt/lldap/certs/cert.pem
    cp /etc/letsencrypt/live/ldap.example.com/privkey.pem /opt/lldap/certs/key.pem
    chown 1000:1000 /opt/lldap/certs/*.pem
    chmod 640 /opt/lldap/certs/key.pem
    docker restart lldap
    shell
    sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/lldap.sh

    Test with:

    shell
    ldapwhoami -x -H ldaps://ldap.example.com:6360 \
      -D "uid=bind_user,ou=people,dc=example,dc=com" -W

    Step 9: Optional SMTP for password resets

    Password resets require email. RamNode does not permit running mail services on their VPS instances, so use an external SMTP relay such as your existing provider, and configure LLDAP as a client only.

    shell
          - LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET=true
          - LLDAP_SMTP_OPTIONS__SERVER=smtp.yourprovider.com
          - LLDAP_SMTP_OPTIONS__PORT=587
          - LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION=STARTTLS
          - LLDAP_SMTP_OPTIONS__USER=noreply@example.com
          - LLDAP_SMTP_OPTIONS__PASSWORD_FILE=/secrets/smtp_password
          - LLDAP_SMTP_OPTIONS__FROM=LLDAP <noreply@example.com>

    Write the password to /opt/lldap/secrets/smtp_password with mode 600 and restart.

    If SMTP is not configured, an admin resets passwords through the web UI instead. That is fine for a small directory.


    Integration examples

    Nextcloud

    Settings, LDAP/AD integration:

    • Host: ldap://127.0.0.1 (or ldaps://ldap.example.com)
    • Port: 3890 (or 6360)
    • Bind DN: uid=bind_user,ou=people,dc=example,dc=com
    • Base DN: dc=example,dc=com
    • User filter: (&(objectclass=person)(memberof=cn=nextcloud_users,ou=groups,dc=example,dc=com))
    • Login attribute: uid
    • Group filter: (&(objectclass=groupOfUniqueNames)(cn=nextcloud_users))
    • Email attribute: mail
    • Display name attribute: displayname

    Grafana

    In ldap.toml:

    shell
    [[servers]]
    host = "127.0.0.1"
    port = 3890
    use_ssl = false
    bind_dn = "uid=bind_user,ou=people,dc=example,dc=com"
    bind_password = "REPLACE_ME"
    search_filter = "(uid=%s)"
    search_base_dns = ["ou=people,dc=example,dc=com"]
    
    [servers.attributes]
    username = "uid"
    name = "displayName"
    surname = "sn"
    member_of = "memberOf"
    email = "mail"
    
    [[servers.group_mappings]]
    group_dn = "cn=grafana_admins,ou=groups,dc=example,dc=com"
    org_role = "Admin"
    
    [[servers.group_mappings]]
    group_dn = "*"
    org_role = "Viewer"

    Enable it in grafana.ini with [auth.ldap] enabled = true.

    Authelia

    shell
    authentication_backend:
      ldap:
        implementation: custom
        address: ldap://127.0.0.1:3890
        base_dn: dc=example,dc=com
        username_attribute: uid
        additional_users_dn: ou=people
        users_filter: "(&({username_attribute}={input})(objectClass=person))"
        additional_groups_dn: ou=groups
        groups_filter: "(member={dn})"
        group_name_attribute: cn
        mail_attribute: mail
        display_name_attribute: displayName
        user: uid=bind_user,ou=people,dc=example,dc=com
        password: REPLACE_ME

    Pairing LLDAP with Authelia gives you a full SSO stack: LLDAP holds the identities, Authelia handles sessions, 2FA, and forward auth for everything behind your reverse proxy.

    Jellyfin

    Install the LDAP-Auth plugin, then set:

    • LDAP Server: 127.0.0.1, port 3890
    • Bind DN: uid=bind_user,ou=people,dc=example,dc=com
    • Base DN: ou=people,dc=example,dc=com
    • Search filter: (memberOf=cn=media_users,ou=groups,dc=example,dc=com)
    • Username attribute: uid

    Operations

    Backups

    Back up three things:

    1. /opt/lldap/data/users.db, the SQLite database
    2. /opt/lldap/data/server_key, the derived private key
    3. /opt/lldap/secrets/, the JWT secret and key seed

    Losing the key seed or server key means every stored password becomes unverifiable and every user must reset.

    A working backup script:

    shell
    #!/bin/bash
    set -euo pipefail
    BACKUP_DIR=/backup/lldap
    STAMP=$(date +%Y%m%d-%H%M%S)
    mkdir -p "$BACKUP_DIR"
    
    docker exec lldap sqlite3 /data/users.db ".backup '/data/backup.db'"
    tar czf "$BACKUP_DIR/lldap-$STAMP.tar.gz" \
      -C /opt/lldap data/backup.db data/server_key secrets
    docker exec lldap rm -f /data/backup.db
    
    find "$BACKUP_DIR" -name 'lldap-*.tar.gz' -mtime +30 -delete

    Run it nightly via cron or a systemd timer, and ship the archive off the VPS.

    PostgreSQL instead of SQLite

    SQLite is adequate for a directory of a few thousand users. If you want PostgreSQL, change the database URL:

    shell
          - LLDAP_DATABASE_URL=postgres://lldap:PASSWORD@postgres/lldap

    Do this before creating users. LLDAP does not migrate between backends automatically.

    Upgrading

    shell
    cd /opt/lldap
    # Back up first
    sudo docker compose pull
    sudo docker compose up -d
    sudo docker compose logs -f

    LLDAP applies schema migrations on startup. Read the release notes before a minor version bump, since a few releases have changed LDAP schema behavior in ways that affect service integrations.

    Declarative user management

    For infrastructure-as-code, the repository ships a bootstrap.sh script that enforces a declared set of users, groups, and attributes from JSON files on every run. Point it at a directory of definitions and it reconciles the server to match. There is also a community Terraform provider if that fits your workflow better.


    Appendix: native package installation

    If you would rather skip Docker, LLDAP is packaged for Ubuntu, Debian, Fedora, CentOS, and openSUSE through a community repository maintained on the openSUSE Build Service, and is in the Arch repositories.

    After installation, the configuration file lives at /etc/lldap/lldap_config.toml:

    shell
    ldap_port = 3890
    http_port = 17170
    http_host = "127.0.0.1"
    ldap_host = "127.0.0.1"
    http_url = "https://ldap.example.com"
    
    jwt_secret = "REPLACE_ME"
    key_seed = "REPLACE_ME"
    
    ldap_base_dn = "dc=example,dc=com"
    ldap_user_dn = "admin"
    ldap_user_email = "admin@example.com"
    ldap_user_pass = "REPLACE_ME"
    
    database_url = "sqlite:///var/lib/lldap/users.db?mode=rwc"
    
    [smtp_options]
    enable_password_reset = false
    
    [ldaps_options]
    enabled = false
    port = 6360
    cert_file = "/var/lib/lldap/cert.pem"
    key_file = "/var/lib/lldap/key.pem"
    shell
    sudo chmod 600 /etc/lldap/lldap_config.toml
    sudo systemctl enable --now lldap

    Everything else in this guide applies unchanged.


    Troubleshooting

    Log warns about a default JWT secret or insecure default admin password. The secret files are not being read. Verify the /secrets mount exists inside the container and that the _FILE environment variables point at the right paths.

    ldapwhoami returns invalid credentials for a user you just created. LLDAP lowercases usernames. Bind with the lowercase form. Also confirm you are using uid=, not cn=, in the bind DN.

    A service binds successfully but finds no users. The search base is wrong. Users live under ou=people, not directly under the base DN. Set the user search base to ou=people,dc=example,dc=com.

    memberOf filters return nothing. Some clients need the full group DN in the filter, not just the group name. Use (memberOf=cn=groupname,ou=groups,dc=example,dc=com).

    Password reset emails never arrive. Confirm LLDAP_HTTP_URL is the public HTTPS URL, and that your SMTP relay accepts submission on the port you configured. Wrong port produces a specific error in the LLDAP logs since v0.6.

    Everyone's password stopped working after a migration. The key seed changed. Restore the original key_seed and server_key from backup.

    Anonymous binds are rejected. That is correct behavior. LLDAP refuses anonymous binds by design. Configure a bind account.


    Next steps

    • Add Authelia in front of your reverse proxy for full SSO with 2FA
    • Migrate existing services off local accounts one at a time, keeping local admin access until each is verified
    • Set up the bootstrap script so your directory is reproducible from version control
    • Enable LDAPS and connect services running on other RamNode instances over WireGuard
    • Add LLDAP-backed Linux logins with PAM and nslcd if you manage several boxes