Featured image of post Setting up LLDAP for Central User Management

Setting up LLDAP for Central User Management

Introduction

Central user management becomes increasingly difficult to keep simple as infrastructure grows. While there are plenty of enterprise-grade IAM solutions, they introduce high overhead and operational complexity that is often unnecessary for small to mid-sized environments.

LDAP remains a practical option because it is simple, widely supported, and well suited to self-hosted deployments. In this post, we will use LLDAP to build that foundation as a rootless Podman service managed by Ansible.

This post will go through the steps for configuring the LLDAP host VM. The goal is to keep the deployment reproducible and easy to operate. Ansible prepares and bootstraps the VM before deploying the Podman container.

Architecture

Architecture Diagram

The deployment consists of a single Ansible role that prepares the host, generates the required configuration and secrets, and starts LLDAP as a rootless Podman container.

Running the container rootless reduces the privileges available to the service and limits the impact of a potential container compromise.

The container itself exposes two services:

  • Web UI on port 17170
  • LDAP API endpoint on port 3890

The container also bind-mounts the host data directory for persistent storage. For this particular deployment, there’s little added benefit for creating a dedicated Podman volume compared to mounting on the host.

User Creation

The ansible role creates a dedicated lldap user and group, which will be responsible for running the container. This avoids using the existing default administrative account (e.g. ubuntu, almalinux etc.). This also helps in isolating the service and limits the potential impact in case of a security breach, while also enforcing the principle of least privilege.

User creation is handled by the following tasks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
---
- name: Create LLDAP group
  ansible.builtin.group:
    name: "{{ lldap_group }}"
    system: false

- name: Create LLDAP user
  ansible.builtin.user:
    name: "{{ lldap_user }}"
    group: "{{ lldap_group }}"
    system: false
    create_home: true
    home: "{{ lldap_home_dir }}"
    shell: /bin/bash

- name: Enable lingering for container user
  become: true
  ansible.builtin.command:
    cmd: "loginctl enable-linger {{ lldap_user }}"
    creates: "/var/lib/systemd/linger/{{ lldap_user }}"

The last task, Enable lingering, is critical. It allows the lldap user to keep running user services even when nobody is logged into the system. Without lingering, the user session would end and the container would stop.

Preparation

The role creates two directories:

  • /etc/lldap stores the generated configuration and secret material.
  • /opt/lldap stores the persistent data that is mounted into the container.

Both directories will be owned by the lldap user created earlier:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
---
- name: Create LLDAP configuration directory
  ansible.builtin.file:
    path: /etc/lldap
    state: directory
    owner: "{{ lldap_user }}"
    group: "{{ lldap_group }}"
    mode: "0700"

- name: Create LLDAP data directory
  ansible.builtin.file:
    path: "{{ lldap_data_dir }}"
    state: directory
    owner: "{{ lldap_user }}"
    group: "{{ lldap_group }}"
    mode: "0700"

Generating Secrets

This is the most critical part of the role. Ideally, we would use a central secret management service tool such as HashiCorp Vault or AWS Secrets Manager. That is outside the scope of this post, so the secrets are stored locally on the VM. To reduce the potential security risk, proper permissions and ownership have to be set and apply the principal of least privilege.

For lldap, we need two kinds of runtime secrets plus one bootstrap credential:

  • LLDAP_JWT_SECRET
  • LLDAP_KEY_SEED
  • lldap_ldap_password

todo: mention JWT secret function

LLDAP JWT and Key Secrets

These two secrets are runtime values used by the container itself. They can be generated with openssl:

1
openssl rand -hex 32

The secrets are defined as a list of dictionaries for ease of use and management:

1
2
3
4
5
6
7
lldap_secrets:
  - name: "jwt_secret"
    path: "/etc/lldap/jwt_secret"
    env: "LLDAP_JWT_SECRET"
  - name: "key_seed"
    path: "/etc/lldap/key_seed"
    env: "LLDAP_KEY_SEED"

That way, we can easily reference and identify any of the secret’s attributes.

The generation process must satisfy three requirements:

  • Generate the secret only if it does not exist.
  • If a secret does exist, then do not generate a new one and do not override it, ensuring idempotency.
  • Set the proper ownership and permissions on the secrets

The task below handles the secret generation:

1
2
3
4
5
6
7
8
9
- name: Generate LLDAP runtime secrets if missing
  ansible.builtin.shell: >
    openssl rand -hex 32 > {{ item.path }}
  args:
    creates: "{{ item.path }}"
  loop: "{{ lldap_secrets }}"
  loop_control:
    label: "{{ item.name }}"
  no_log: true

Ownership and permission enforcement is handled separately in the role and omitted here for brevity

The task performs several important functions:

  • It loops over the secret dictionary defined earlier.
  • It runs the openssl command and writes the output to the secret destination referenced by item.path.
  • The creates argument tells Ansible to skip the command if the file already exists. That is what keeps the task idempotent.
  • no_log: true prevents the secret values from appearing in logs.

LDAP Admin Password

The LDAP admin password is different from the runtime secrets. It is rendered into the env file as LLDAP_LDAP_USER_PASS and is only meant to bootstrap the initial login.

The password is handled similarly to the previous secrets:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
- name: Generate bootstrap admin password if missing
  ansible.builtin.shell: >
    openssl rand -hex 32 > {{ lldap_bootstrap_password_file }}
  args:
    creates: "{{ lldap_bootstrap_password_file }}"
  no_log: true

- name: Set permissions on bootstrap admin password
  ansible.builtin.file:
    path: "{{ lldap_bootstrap_password_file }}"
    owner: root
    group: root
    mode: "0600"

After the first login, this password should be rotated and treated as a temporary credential rather than a long-lived secret. Keeping it separate from the runtime secret files makes its purpose explicit and avoids mixing it into the same secret loop as the generated keys.

Deploying the Podman Container

We are now at the last stage where we can finally deploy the actual container. All we have to do is:

  • Pull the official lldap image
  • Run the container with the required arguments: volume, env file, and port bindings
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
---
- name: Pull LLDAP image
  become_user: "{{ lldap_user }}"
  containers.podman.podman_image:
    name: "{{ lldap_image }}"

- name: Run LLDAP container
  become_user: "{{ lldap_user }}"
  containers.podman.podman_container:
    name: "{{ lldap_container_name }}"
    image: "{{ lldap_image }}"
    state: started
    restart_policy: always
    detach: true
    ports:
      - "{{ lldap_bind_address }}:{{ lldap_http_port }}:{{ lldap_http_port }}"
      - "{{ lldap_bind_address }}:{{ lldap_ldap_port }}:{{ lldap_ldap_port }}"

    env_file:
      - /etc/lldap/lldap.env

    volumes:
      - "{{ lldap_data_dir }}:/data:Z"

lldap_bind_address is defined in inventory or host vars and should point to the interface that LLDAP should listen on.

Binding to lldap_bind_address keeps the service reachable on the chosen interface without exposing it on every network interface. In practice, that variable should come from inventory or host vars so the bind target stays explicit even on multi-homed hosts.

Env File

The lldap.env file is rendered from a Jinja template. It combines the LDAP configuration, the initial admin password, and the runtime secrets that LLDAP expects at startup.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Runtime secrets
{% for secret in lldap_secret_contents.results %}
{{ secret.item.env }}={{ secret.content | b64decode | trim }}
{% endfor %}

# Bootstrap admin password
LLDAP_LDAP_USER_PASS={{ lldap_bootstrap_password.content | b64decode | trim }}

# DN
LLDAP_LDAP_BASE_DN={{ lldap_ldap_base_dn }}
LLDAP_LDAP_USER_DN={{ lldap_ldap_user_dn }}

In Ansible, the runtime secrets are read first using slurp, then the template is rendered with those values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
- name: Read LLDAP runtime secrets
  ansible.builtin.slurp:
    src: "{{ item.path }}"
  loop: "{{ lldap_secrets }}"
  loop_control:
    label: "{{ item.name }}"
  register: lldap_secret_contents
  no_log: true

- name: Render LLDAP environment file
  ansible.builtin.template:
    src: lldap.env.j2
    dest: /etc/lldap/lldap.env
    owner: root
    group: "{{ lldap_group }}"
    mode: "0600"
  no_log: true

Playbook

All that is left is the playbook that applies the role to the target host:

1
2
3
4
5
6
---
- name: Set up lldap server
  hosts: lldap-01
  become: true
  roles:
    - lldap_server

A second run should report no changes:

1
2
PLAY RECAP ******************************************************************************************************************************************
lldap-01                   : ok=20   changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

This means our role is safe for re-runs without any of the secrets being overwritten.

Conclusion

By combining Ansible with rootless Podman, we’ve built a reproducible and idempotent LLDAP deployment that follows security best practices without introducing unnecessary operational complexity. The result is a lightweight LDAP service suitable for self-hosted environments that can be redeployed safely as the infrastructure evolves.

While this post covers deploying LLDAP, a production-ready setup should also address several additional concerns. Enabling LDAPS with properly managed TLS certificates secures directory traffic, even within internal networks. Just as important is implementing a backup and disaster recovery strategy to ensure the directory—and the authentication it provides—can be restored if something goes wrong.

The full Ansible role implementation is available at GitHub.