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
| Workload | vCPU | RAM | Disk |
|---|---|---|---|
| LLDAP alone | 1 | 512 MB | 10 GB |
| LLDAP plus a reverse proxy and SSO layer | 1 | 1 GB | 20 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.comfor 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.
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 dockerStep 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.
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; echoNote the admin password. You will need it for the first login.
Step 3: Write the Compose file
Create /opt/lldap/docker-compose.yml.
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=falseKey points:
- The
_FILEvariants read secrets from files rather than putting them in the environment, wheredocker inspectwould expose them. These take precedence over the non-file variables. LLDAP_LDAP_BASE_DNmust match your domain. Changedc=example,dc=comto yours. Changing it after users exist means starting over, so get it right now.LLDAP_HTTP_URLis used to build password reset links. It must be the public HTTPS URL.- Use the
:stabletag, 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.
sudo mkdir -p /opt/lldap/data
sudo chown -R 1000:1000 /opt/lldap/data /opt/lldap/secretsStart it.
cd /opt/lldap
sudo docker compose up -d
sudo docker compose logs -fLook 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.
sudo apt install -y nginx certbot python3-certbot-nginxCreate /etc/nginx/sites-available/lldap.
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.
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.comLock down the firewall.
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enablePort 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_adminalready exists and grants full LLDAP administrationlldap_password_manageralready exists and allows resetting other users' passwords without full adminlldap_strict_readonlyalready exists for read-only API access
Add your own, matching whatever your applications expect:
nextcloud_users
grafana_admins
media_users
vpn_usersThen 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.
- Create a user named
bind_userin the web UI with a long random password - Add it to the
lldap_strict_readonlygroup
Its bind DN is:
uid=bind_user,ou=people,dc=example,dc=comThis 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.
sudo apt install -y ldap-utilsConfirm a bind works.
ldapwhoami -x -H ldap://127.0.0.1:3890 \
-D "uid=admin,ou=people,dc=example,dc=com" \
-WList all users.
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.
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:
| Object | DN pattern |
|---|---|
| Users | uid=<username>,ou=people,dc=example,dc=com |
| Groups | cn=<groupname>,ou=groups,dc=example,dc=com |
| User search base | ou=people,dc=example,dc=com |
| Group search base | ou=groups,dc=example,dc=com |
Standard user filter: (&(objectClass=person)(uid=%s))
Standard group filter: (&(objectClass=groupOfUniqueNames)(uniqueMember=%s))
Step 8: Enable LDAPS (optional but recommended)
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.
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.pemAdd to the Compose environment block:
- 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.pemAdd the volume and the port:
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.
sudo ufw allow 6360/tcp
cd /opt/lldap && sudo docker compose up -dAdd a Certbot deploy hook at /etc/letsencrypt/renewal-hooks/deploy/lldap.sh:
#!/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 lldapsudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/lldap.shTest with:
ldapwhoami -x -H ldaps://ldap.example.com:6360 \
-D "uid=bind_user,ou=people,dc=example,dc=com" -WStep 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.
- 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(orldaps://ldap.example.com) - Port:
3890(or6360) - 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:
[[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
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_MEPairing 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, port3890 - 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:
/opt/lldap/data/users.db, the SQLite database/opt/lldap/data/server_key, the derived private key/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:
#!/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 -deleteRun 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:
- LLDAP_DATABASE_URL=postgres://lldap:PASSWORD@postgres/lldapDo this before creating users. LLDAP does not migrate between backends automatically.
Upgrading
cd /opt/lldap
# Back up first
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs -fLLDAP 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:
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"sudo chmod 600 /etc/lldap/lldap_config.toml
sudo systemctl enable --now lldapEverything 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
