Centralized Authentication with lldap: A Practical Guide

What problem does this solve?
If you’re running a homelab or a small infrastructure with multiple self-hosted services, you’ve probably hit this wall: every service has its own user database. Nextcloud has one, Gitea has another, Portainer has its own, and so on. Add a new team member? You’re creating accounts in five different places. Someone leaves? Good luck remembering where they had access.
LDAP solves this by giving you a single directory where all your users and groups live. Each service connects to this directory instead of maintaining its own user list. You create a user once, assign them to groups, and every connected service knows who they are and what they can access.
lldap is a lightweight LDAP server built specifically for this use case. Unlike full-blown solutions like OpenLDAP or FreeIPA, lldap is simple to set up, has a clean web UI for managing users and groups, and does exactly what you need without the complexity you don’t.
LDAP naming: making sense of the syntax
LDAP uses a hierarchical naming system that looks intimidating at first glance but follows a simple logic. Let’s break it down.
Domain Components (dc)
Your LDAP directory needs a root — a starting point for the tree. This is derived from your domain name by splitting it into Domain Components.
| Your domain | LDAP Base DN |
|---|---|
example.com | dc=example,dc=com |
yak.consulting | dc=yak,dc=consulting |
home.lab | dc=home,dc=lab |
myserver.local | dc=myserver,dc=local |
This root is called your Base DN (Distinguished Name). It’s the foundation everything else sits on top of.
The directory tree
LDAP organizes data in a tree structure. Under your Base DN, you’ll have Organizational Units (ou) that act like folders, and inside those you’ll have actual entries — users and groups.
Here’s what a typical lldap directory looks like:
dc=yak,dc=consulting ← Base DN (root)
│
├── ou=people ← Users live here
│ ├── uid=slav ← A user entry
│ ├── uid=anna ← Another user
│ └── uid=service_reader ← A service account
│
└── ou=groups ← Groups live here
├── cn=lldap_admin ← Admin group (built-in)
├── cn=nextcloud_users ← Custom group
├── cn=gitea_users ← Custom group
└── cn=portainer_admins ← Custom group
Key abbreviations
These show up everywhere in LDAP configuration. Here’s what they mean:
| Abbreviation | Stands for | Used for | Example |
|---|---|---|---|
| dc | Domain Component | Parts of your domain | dc=yak |
| ou | Organizational Unit | Folders/containers | ou=people |
| uid | User ID | Individual users | uid=slav |
| cn | Common Name | Groups and services | cn=admins |
| dn | Distinguished Name | Full path to any entry | uid=slav,ou=people,dc=yak,dc=consulting |
Reading a Distinguished Name
A DN is like a full file path, but written from most specific to most general (left to right = leaf to root):
uid=slav, ou=people, dc=yak, dc=consulting
│ │ └── domain: yak.consulting
│ └── in the "people" folder
└── the user "slav"
Think of it as a reversed file path: /yak.consulting/people/slav becomes uid=slav,ou=people,dc=yak,dc=consulting.
Setting up lldap
Docker Compose
The simplest way to run lldap. Here’s a production-ready compose file:
services:
lldap:
image: lldap/lldap:stable
container_name: lldap
restart: unless-stopped
ports:
- "3890:3890" # LDAP
- "17170:17170" # Web UI
environment:
- TZ=Europe/Warsaw
- LLDAP_JWT_SECRET=CHANGE_ME_generate_a_random_string
- LLDAP_KEY_SEED=CHANGE_ME_another_random_string
- LLDAP_LDAP_BASE_DN=dc=yak,dc=consulting
- LLDAP_LDAP_USER_PASS=your_admin_password
volumes:
- lldap_data:/data
volumes:
lldap_data:
After starting, open http://your-server:17170 and log in with admin / the password you set.
First steps in the web UI
- Create a read-only service account — this is the account your services will use to query LDAP. Name it something like
ro_bindorservice_reader. Don’t use the admin account for this. - Create your groups — one per service you plan to connect (e.g.,
nextcloud_users,gitea_users,portainer_admins). - Create your users — add real people, then assign them to the appropriate groups.
Connecting services to lldap
Every service that supports LDAP authentication will ask you for roughly the same set of parameters. Here’s the universal template:
| Parameter | Value | Notes |
|---|---|---|
| LDAP URL | ldap://lldap:3890 | Use container name if on same Docker network |
| Base DN | dc=yak,dc=consulting | Your root |
| Bind DN | uid=ro_bind,ou=people,dc=yak,dc=consulting | Service account for queries |
| Bind Password | (password of ro_bind) | |
| User Search Base | ou=people,dc=yak,dc=consulting | Where to look for users |
| Group Search Base | ou=groups,dc=yak,dc=consulting | Where to look for groups |
The two things that change between services are the user filter and the group filter. These control who can log in and what group membership is checked.
How authentication works
When a user tries to log in to a connected service, here’s the sequence:
- The service connects to lldap using the Bind DN (the read-only service account).
- It searches for the user using the user filter — e.g., “find me a user where
uidmatches what was typed in the login form.” - If found, the service attempts to bind (authenticate) as that user with the password they provided.
- If the bind succeeds, the service optionally checks group membership to decide if the user is allowed in.
- Access granted or denied.
The service account never sees user passwords. It only searches the directory. The actual password check happens when lldap tries to authenticate the user directly.
User filters explained
A user filter tells the service how to find a user in LDAP. The most common ones:
Basic — match by uid:
(uid={input})
The {input} (or %s or {username} depending on the service) gets replaced with whatever the user typed in the login form.
Match by uid or email:
(|(uid={input})(mail={input}))
The | means OR — so users can log in with either their username or email.
Restrict to a specific group:
(&(uid={input})(memberOf=cn=nextcloud_users,ou=groups,dc=yak,dc=consulting))
The & means AND — the user must match the uid AND be a member of the specified group. This is how you control per-service access.
Group filters explained
Some services use a separate group filter instead of (or in addition to) memberOf in the user filter:
(&(objectClass=groupOfUniqueNames)(uniqueMember={dn}))
This finds all groups where the authenticated user is listed as a member. The service then checks if any of those groups match an allowed group.
Practical examples
Nextcloud
Nextcloud has built-in LDAP support via the “LDAP user and group backend” app (enable it in the Apps section).
Server tab:
- Host:
ldap://lldap(or IP) - Port:
3890 - Bind DN:
uid=ro_bind,ou=people,dc=yak,dc=consulting - Bind Password: (your service account password)
- Base DN:
dc=yak,dc=consulting
Users tab:
- Edit LDAP Query:
(&(objectClass=person)(memberOf=cn=nextcloud_users,ou=groups,dc=yak,dc=consulting))
Login Attributes tab:
- Edit LDAP Query:
(&(objectClass=person)(|(uid=%uid)(mail=%uid)))
Groups tab:
- Edit LDAP Query:
(objectClass=groupOfUniqueNames)
Gitea / Forgejo
Go to Site Administration → Authentication Sources → Add Authentication Source:
- Authentication Type:
LDAP (via BindDN) - Host:
lldap(or IP) - Port:
3890 - Bind DN:
uid=ro_bind,ou=people,dc=yak,dc=consulting - Bind Password: (password)
- User Search Base:
ou=people,dc=yak,dc=consulting - User Filter:
(&(objectClass=person)(|(uid=%s)(mail=%s))(memberOf=cn=gitea_users,ou=groups,dc=yak,dc=consulting)) - Admin Filter:
(memberOf=cn=gitea_admins,ou=groups,dc=yak,dc=consulting) - Username Attribute:
uid - First Name Attribute:
givenName - Surname Attribute:
sn - Email Attribute:
mail
The Admin Filter is a nice touch — it lets you automatically grant Gitea admin rights to members of a specific LDAP group.
Portainer
Settings → Authentication → LDAP:
- LDAP URL:
ldap://lldap:3890 - Reader DN:
uid=ro_bind,ou=people,dc=yak,dc=consulting - Password: (password)
- User Base DN:
ou=people,dc=yak,dc=consulting - Username Attribute:
uid - User Filter:
(memberOf=cn=portainer_users,ou=groups,dc=yak,dc=consulting) - Group Base DN:
ou=groups,dc=yak,dc=consulting - Group Membership Attribute:
member - Group Filter:
(objectClass=groupOfUniqueNames)
Authelia (as an LDAP-backed identity provider)
If you’re using Authelia as your SSO/authentication portal, it can use lldap as its user backend. In configuration.yml:
authentication_backend:
ldap:
address: ldap://lldap:3890
base_dn: dc=yak,dc=consulting
users_filter: "(&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))"
groups_filter: "(member={dn})"
user: uid=ro_bind,ou=people,dc=yak,dc=consulting
password: "your_service_account_password"
attributes:
username: uid
display_name: displayName
mail: mail
group_name: cn
With Authelia in the mix, you can add two-factor authentication and single sign-on on top of your LDAP directory — services that don’t natively support LDAP can still benefit from centralized auth through Authelia’s forward auth.
Access control strategy
Here’s a practical approach to organizing your groups:
Group naming convention
Use a consistent pattern that makes the purpose immediately clear:
{service}_users → basic access to the service
{service}_admins → admin-level access to the service
For example:
nextcloud_users,nextcloud_adminsgitea_users,gitea_adminsportainer_users,portainer_adminsmonitoring_viewers(for Grafana read-only access)
Example user-to-group mapping
| User | Groups | Access |
|---|---|---|
| slav | lldap_admin, nextcloud_admins, gitea_admins, portainer_admins, monitoring_viewers | Full admin everywhere |
| anna | nextcloud_users, gitea_users | Standard access to Nextcloud and Gitea only |
| ro_bind | (none — just a service account) | Can only read the directory, can’t log into any service |
| client_x | nextcloud_users | Only Nextcloud access |
Adding a new person? Create one user in lldap, add them to the right groups, and they can immediately log in to all the services they need. Remove them from a group or deactivate their account and access is revoked everywhere.
Troubleshooting tips
“User not found” errors: Double-check the User Search Base. It should be ou=people,dc=..., not just the Base DN.
“Bind failed” for the service account: Verify the full Bind DN includes ou=people — a common mistake is writing uid=ro_bind,dc=yak,dc=consulting and forgetting the OU.
Users can log in but shouldn’t be able to: Your user filter likely isn’t checking group membership. Add the memberOf=cn=... condition.
Group membership not working: lldap uses memberOf as a virtual attribute. Some services expect isMemberOf — check the service’s documentation. Also verify the full group DN in your filter matches exactly, including case.
Testing from the command line: You can use ldapsearch to verify your setup works:
# Search for a specific user
ldapsearch -H ldap://localhost:3890 \
-D "uid=ro_bind,ou=people,dc=yak,dc=consulting" \
-w "password" \
-b "ou=people,dc=yak,dc=consulting" \
"(uid=slav)"
# Check group membership
ldapsearch -H ldap://localhost:3890 \
-D "uid=ro_bind,ou=people,dc=yak,dc=consulting" \
-w "password" \
-b "ou=groups,dc=yak,dc=consulting" \
"(member=uid=slav,ou=people,dc=yak,dc=consulting)"
Security considerations
A few things worth keeping in mind:
Always use a dedicated read-only bind account. Never use your personal admin account as the Bind DN for services. If a service gets compromised, you don’t want the attacker to have write access to your entire directory.
Consider LDAPS or StartTLS. Plain LDAP sends credentials unencrypted. If lldap is on the same Docker network as your services, the risk is minimal. But if traffic crosses networks, enable TLS. lldap supports this natively — set LLDAP_LDAP_TLS_CERT_FILE and LLDAP_LDAP_TLS_KEY_FILE in your environment.
Pair with Authelia or another SSO provider. LDAP handles identity and group membership, but it doesn’t give you two-factor authentication or single sign-on by itself. Adding Authelia (or Keycloak, or Authentik) on top of lldap gives you the full picture: centralized users, 2FA, and SSO across all your services.
Audit your groups periodically. It’s easy to add people to groups and forget to clean up. A quick review every few months keeps your access control tight.
