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
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:
| |
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/lldapstores the generated configuration and secret material./opt/lldapstores the persistent data that is mounted into the container.
Both directories will be owned by the lldap user created earlier:
| |
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_SECRETLLDAP_KEY_SEEDlldap_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:
| |
The secrets are defined as a list of dictionaries for ease of use and management:
| |
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:
| |
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
opensslcommand and writes the output to the secret destination referenced byitem.path. - The
createsargument tells Ansible to skip the command if the file already exists. That is what keeps the task idempotent. no_log: trueprevents 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:
| |
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
lldapimage - Run the container with the required arguments: volume, env file, and port bindings
| |
lldap_bind_addressis 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.
| |
In Ansible, the runtime secrets are read first using slurp, then the template is rendered with those values:
| |
Playbook
All that is left is the playbook that applies the role to the target host:
| |
A second run should report no changes:
| |
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.
