Centralized Authentication with lldap: A Practical Guide

Centralized Authentication with lldap: A Practical Guide
Page content

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 domainLDAP Base DN
example.comdc=example,dc=com
yak.consultingdc=yak,dc=consulting
home.labdc=home,dc=lab
myserver.localdc=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:

AbbreviationStands forUsed forExample
dcDomain ComponentParts of your domaindc=yak
ouOrganizational UnitFolders/containersou=people
uidUser IDIndividual usersuid=slav
cnCommon NameGroups and servicescn=admins
dnDistinguished NameFull path to any entryuid=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

  1. Create a read-only service account — this is the account your services will use to query LDAP. Name it something like ro_bind or service_reader. Don’t use the admin account for this.
  2. Create your groups — one per service you plan to connect (e.g., nextcloud_users, gitea_users, portainer_admins).
  3. 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:

ParameterValueNotes
LDAP URLldap://lldap:3890Use container name if on same Docker network
Base DNdc=yak,dc=consultingYour root
Bind DNuid=ro_bind,ou=people,dc=yak,dc=consultingService account for queries
Bind Password(password of ro_bind)
User Search Baseou=people,dc=yak,dc=consultingWhere to look for users
Group Search Baseou=groups,dc=yak,dc=consultingWhere 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:

  1. The service connects to lldap using the Bind DN (the read-only service account).
  2. It searches for the user using the user filter — e.g., “find me a user where uid matches what was typed in the login form.”
  3. If found, the service attempts to bind (authenticate) as that user with the password they provided.
  4. If the bind succeeds, the service optionally checks group membership to decide if the user is allowed in.
  5. 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_admins
  • gitea_users, gitea_admins
  • portainer_users, portainer_admins
  • monitoring_viewers (for Grafana read-only access)

Example user-to-group mapping

UserGroupsAccess
slavlldap_admin, nextcloud_admins, gitea_admins, portainer_admins, monitoring_viewersFull admin everywhere
annanextcloud_users, gitea_usersStandard 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_xnextcloud_usersOnly 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.