Patrick de Ruiter 989ad3fbfb
All checks were successful
CI Pipeline / build (push) Successful in 40s
CI Pipeline / security-scan (push) Successful in 1m29s
CI Pipeline / autotag (push) Successful in 18s
CI Pipeline / push (push) Successful in 21s
CI Pipeline / update-cd (push) Successful in 17s
CI Pipeline / lint (push) Successful in 19s
CI Pipeline / test (push) Successful in 56s
fix: run push job in same workflow after autotag
Gitea doesn't trigger new workflows when tags are pushed by the
workflow itself. Modified push job to:
- Depend on autotag job
- Use autotag outputs for version when not triggered by tag ref
- Run when autotag succeeds OR when triggered by tag push
2025-12-26 02:00:35 +01:00
2025-12-25 12:36:39 +01:00
2025-12-25 12:36:39 +01:00
2025-12-25 12:36:39 +01:00
2025-12-25 12:36:39 +01:00
2025-12-25 12:36:39 +01:00
2025-12-25 12:36:39 +01:00
2025-12-25 12:36:39 +01:00
2025-12-25 12:36:39 +01:00
2025-12-25 12:36:39 +01:00

OpenLDAP Container Operations Guide

Introduction

This document provides operational guidance for deploying and managing the Enterprise OpenLDAP container. It explains not just the how, but also the what and why behind each configuration decision.

What This Container Provides

This is a purpose-built OpenLDAP container designed to serve as a central identity provider for an enterprise environment. It provides:

  • Centralized user authentication for all services (Keycloak, Nextcloud, Gitea, mail, etc.)
  • POSIX account support for Linux systems via SSSD integration
  • Group-based access control with automatic membership tracking
  • Service account isolation so each application has its own credentials with limited permissions

Why OpenLDAP?

LDAP (Lightweight Directory Access Protocol) remains the standard for centralized identity management because:

  1. Universal support - Nearly every enterprise application supports LDAP authentication
  2. Hierarchical organization - Natural fit for organizational structures (users, groups, departments)
  3. Fine-grained access control - Different applications can have different levels of access
  4. Proven reliability - Decades of production use in demanding environments

Key Design Decisions

RFC2307bis instead of NIS schema: Traditional LDAP setups use the NIS schema where posixGroup is a STRUCTURAL class. This prevents combining POSIX groups with other group types. We use RFC2307bis where posixGroup is AUXILIARY, allowing groups to have both POSIX attributes (gidNumber) and member tracking (member attribute). This is essential for the memberOf overlay to work correctly.

Pre-configured overlays: The container includes four overlays that are critical for enterprise use:

  • memberOf - Automatically maintains reverse group membership on user objects
  • refint - Maintains referential integrity when users are deleted
  • unique - Prevents duplicate UIDs, email addresses, and ID numbers
  • ppolicy - Enforces password complexity and expiration policies

Service accounts: Instead of sharing the admin password with applications, each service gets its own account with read-only access to the directory. This follows the principle of least privilege and provides audit trails.


Architecture Overview

Directory Structure

The container creates a directory tree optimized for enterprise use:

dc=example,dc=com                      ← Your organization's base
│
├── ou=People                          ← All user accounts live here
│   └── uid=jdoe                       ← Individual user
│
├── ou=Groups                          ← Authorization and access groups
│   ├── cn=admins                      ← LDAP administrators
│   └── cn=developers                  ← Example application group
│
├── ou=Services                        ← Application service accounts
│   ├── cn=keycloak                    ← For Keycloak LDAP federation
│   ├── cn=nextcloud                   ← For Nextcloud user backend
│   ├── cn=gitea                       ← For Gitea authentication
│   ├── cn=postfix                     ← For mail routing lookups
│   ├── cn=dovecot                     ← For mail authentication
│   └── cn=sssd                        ← For Linux PAM/NSS
│
├── ou=Domains                         ← Virtual mail domains (for mail server)
│
├── ou=Policies                        ← Password and security policies
│   └── cn=default                     ← Default password policy
│
└── ou=Kerberos                        ← Reserved for Kerberos integration

Why this structure?

  • Separate OUs for each purpose makes ACLs (access control lists) simpler and more secure
  • Service accounts in their own OU allows restricting what parts of the tree they can access
  • Policies OU keeps password policies separate from user data
  • Domains OU is specifically for mail server virtual domain configuration

How Authentication Works

When an application authenticates a user:

  1. Application connects to LDAP using its service account (e.g., cn=keycloak,ou=Services,...)
  2. Application searches for the user in ou=People by uid or email
  3. Application attempts to bind (authenticate) as that user with their password
  4. LDAP verifies the password and returns success/failure
  5. Application can then query the user's groups via the memberOf attribute

The service account never sees user passwords - it only searches for users. The actual password verification happens when the user's DN is used for a bind operation.


Getting Started

Prerequisites

Before deploying, ensure you have:

  • Docker installed and running
  • Decided on your domain name (this determines your base DN)
  • Generated a strong admin password
  • (Optional) TLS certificates if you need encrypted connections

Step 1: Basic Deployment

The simplest deployment requires just three pieces of information:

docker run -d --name openldap \
  -e LDAP_DOMAIN=example.com \
  -e LDAP_ORGANISATION="Example Corporation" \
  -e LDAP_ADMIN_PASSWORD=your-secure-password \
  -p 389:389 \
  enterprise-openldap:latest

What happens on first run:

  1. The container detects this is a fresh start (no existing database)
  2. It generates the base DN from your domain (example.comdc=example,dc=com)
  3. Creates the cn=config database with your settings
  4. Loads all required schemas in the correct order
  5. Configures the overlays (memberOf, refint, unique, ppolicy)
  6. Creates the directory structure (OUs)
  7. Sets up ACLs to protect sensitive data
  8. Optionally creates service accounts
  9. Starts the LDAP server

On subsequent restarts, steps 2-8 are skipped - the existing database is used.

Step 2: Verify the Deployment

Test that the server is responding:

# Anonymous query to verify the server is up
ldapsearch -x -H ldap://localhost:389 -b "" -s base "(objectClass=*)"

Test admin authentication:

ldapsearch -x -H ldap://localhost:389 \
  -D "cn=admin,dc=example,dc=com" \
  -w "your-secure-password" \
  -b "dc=example,dc=com" "(objectClass=organizationalUnit)"

You should see the OUs (People, Groups, Services, etc.) listed.

Service accounts allow applications to connect with limited, auditable credentials:

docker run -d --name openldap \
  -e LDAP_DOMAIN=example.com \
  -e LDAP_ORGANISATION="Example Corporation" \
  -e LDAP_ADMIN_PASSWORD=your-secure-password \
  -e LDAP_CREATE_SERVICE_ACCOUNTS=true \
  -p 389:389 \
  enterprise-openldap:latest

The generated passwords are saved to /var/lib/openldap/service-passwords.txt inside the container. Retrieve them:

docker exec openldap cat /var/lib/openldap/service-passwords.txt

Important: Save these passwords securely, then delete the file:

docker exec openldap rm /var/lib/openldap/service-passwords.txt

Step 4: Add Persistence

Without volumes, your data is lost when the container is removed. For production, always use volumes:

docker run -d --name openldap \
  -v openldap-data:/var/lib/openldap/openldap-data \
  -v openldap-config:/etc/openldap/slapd.d \
  -e LDAP_DOMAIN=example.com \
  -e LDAP_ORGANISATION="Example Corporation" \
  -e LDAP_ADMIN_PASSWORD=your-secure-password \
  -e LDAP_CREATE_SERVICE_ACCOUNTS=true \
  -p 389:389 \
  enterprise-openldap:latest

Volume purposes:

Volume Contains Why it matters
/var/lib/openldap/openldap-data The actual LDAP database (MDB format) All your users, groups, and data
/etc/openldap/slapd.d The cn=config configuration Schema, overlays, ACLs, indexes

Step 5: Enable TLS (Production)

For production environments, you should enable TLS to encrypt connections:

docker run -d --name openldap \
  -v openldap-data:/var/lib/openldap/openldap-data \
  -v openldap-config:/etc/openldap/slapd.d \
  -v /path/to/certs:/certs:ro \
  -e LDAP_DOMAIN=example.com \
  -e LDAP_ORGANISATION="Example Corporation" \
  -e LDAP_ADMIN_PASSWORD=your-secure-password \
  -e LDAP_TLS_ENABLED=true \
  -p 389:389 -p 636:636 \
  enterprise-openldap:latest

The /certs directory must contain:

  • ldap.crt - Server certificate
  • ldap.key - Private key (readable only by root/ldap user)
  • ca.crt - CA certificate (for client verification)

Port 389 vs 636:

  • Port 389 (LDAP) - Unencrypted, but supports STARTTLS upgrade
  • Port 636 (LDAPS) - TLS from the start, like HTTPS

Most modern applications support STARTTLS on port 389, which is generally preferred.


Configuration Reference

Required Environment Variables

These must be set for the container to start:

Variable Purpose Example
LDAP_DOMAIN Your organization's domain. Converted to base DN automatically. example.comdc=example,dc=com
LDAP_ORGANISATION Human-readable organization name, stored in the base entry. Example Corporation
LDAP_ADMIN_PASSWORD Password for the directory administrator account. Use cleartext - the container hashes it automatically with slappasswd. Str0ng!Passw0rd

Optional Environment Variables

General Settings

Variable Default Purpose
LDAP_BASE_DN Generated from domain Override if you need a non-standard base DN
LDAP_CONFIG_PASSWORD Random Password for cn=config administrative access. Only needed for advanced configuration changes.
LDAP_LOG_LEVEL 256 Controls logging verbosity. 256 logs connections and operations. See Log Levels.

TLS Settings

Variable Default Purpose
LDAP_TLS_ENABLED true Master switch for TLS. Set false to disable completely.
LDAP_TLS_CERT_FILE /certs/ldap.crt Path to server certificate inside container
LDAP_TLS_KEY_FILE /certs/ldap.key Path to private key inside container
LDAP_TLS_CA_FILE /certs/ca.crt Path to CA certificate for client verification
LDAP_TLS_VERIFY_CLIENT try Client cert policy: never (don't ask), try (optional), demand (required)

Service Account Settings

Variable Default Purpose
LDAP_CREATE_SERVICE_ACCOUNTS false Set true to create pre-configured service accounts
LDAP_SERVICE_KEYCLOAK_PASSWORD Random Explicit password for Keycloak account
LDAP_SERVICE_NEXTCLOUD_PASSWORD Random Explicit password for Nextcloud account
LDAP_SERVICE_GITEA_PASSWORD Random Explicit password for Gitea account
LDAP_SERVICE_POSTFIX_PASSWORD Random Explicit password for Postfix account
LDAP_SERVICE_DOVECOT_PASSWORD Random Explicit password for Dovecot account
LDAP_SERVICE_SSSD_PASSWORD Random Explicit password for SSSD account

When to set explicit passwords: If you're deploying with configuration management (Ansible, Terraform) and need predictable credentials, set them explicitly. For manual deployments, random passwords are more secure.


Day-to-Day Operations

Adding Users

Users belong in ou=People. Here's a complete user entry:

dn: uid=jdoe,ou=People,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: jdoe
cn: John Doe
sn: Doe
givenName: John
mail: john.doe@example.com
uidNumber: 10001
gidNumber: 10000
homeDirectory: /home/jdoe
loginShell: /bin/bash
userPassword: {SSHA}hashed-password

Object classes explained:

  • inetOrgPerson - Standard LDAP person with email, phone, etc.
  • posixAccount - UNIX account attributes (uid/gid numbers, home directory, shell)
  • shadowAccount - Password aging and expiration

Add the user:

ldapadd -x -H ldap://localhost:389 \
  -D "cn=admin,dc=example,dc=com" \
  -w "admin-password" \
  -f user.ldif

Adding Groups

Groups use groupOfMembers (for member tracking) combined with posixGroup (for UNIX gidNumber):

dn: cn=developers,ou=Groups,dc=example,dc=com
objectClass: groupOfMembers
objectClass: posixGroup
cn: developers
gidNumber: 10001
description: Development team
member: uid=jdoe,ou=People,dc=example,dc=com

Why groupOfMembers instead of groupOfNames?

groupOfNames requires at least one member - you can't have an empty group. groupOfMembers allows empty groups, which is often needed when setting up access control before adding members.

Checking Group Membership

Thanks to the memberOf overlay, you can see a user's groups directly:

ldapsearch -x -H ldap://localhost:389 \
  -D "cn=admin,dc=example,dc=com" \
  -w "admin-password" \
  -b "uid=jdoe,ou=People,dc=example,dc=com" \
  memberOf

This returns all groups the user belongs to, without having to search each group.

Modifying Entries

To change an attribute:

dn: uid=jdoe,ou=People,dc=example,dc=com
changetype: modify
replace: mail
mail: j.doe@example.com
ldapmodify -x -H ldap://localhost:389 \
  -D "cn=admin,dc=example,dc=com" \
  -w "admin-password" \
  -f modify.ldif

Deleting Entries

ldapdelete -x -H ldap://localhost:389 \
  -D "cn=admin,dc=example,dc=com" \
  -w "admin-password" \
  "uid=jdoe,ou=People,dc=example,dc=com"

The refint overlay automatically removes the user from any groups they belonged to.


Connecting Applications

Keycloak

Keycloak uses LDAP for user federation. Configure it with:

Setting Value
Connection URL ldap://openldap:389
Bind DN cn=keycloak,ou=Services,dc=example,dc=com
Bind Credential (from service-passwords.txt)
Users DN ou=People,dc=example,dc=com
Username Attribute uid
RDN Attribute uid
UUID Attribute entryUUID

Nextcloud

In Nextcloud's LDAP settings:

Setting Value
Host openldap
Port 389
User DN cn=nextcloud,ou=Services,dc=example,dc=com
Password (from service-passwords.txt)
Base DN dc=example,dc=com
User Filter (&(objectClass=inetOrgPerson)(uid=%uid))
Group Filter (objectClass=groupOfMembers)

SSSD (Linux PAM/NSS)

For Linux systems to authenticate against LDAP:

# /etc/sssd/sssd.conf
[sssd]
services = nss, pam
domains = example.com

[domain/example.com]
id_provider = ldap
auth_provider = ldap
ldap_uri = ldap://openldap.example.com
ldap_search_base = dc=example,dc=com
ldap_default_bind_dn = cn=sssd,ou=Services,dc=example,dc=com
ldap_default_authtok = sssd-password-here
ldap_user_search_base = ou=People,dc=example,dc=com
ldap_group_search_base = ou=Groups,dc=example,dc=com

Backup and Recovery

Why Backups Matter

The LDAP directory contains:

  • All user accounts and credentials
  • Group memberships and access control
  • Service account configurations
  • Password policies

Losing this data means losing access to all integrated systems.

Creating Backups

Export the directory to LDIF format:

# Backup user data (database 1)
docker exec openldap slapcat -n 1 > ldap-data-$(date +%Y%m%d).ldif

# Backup configuration (database 0) - optional but recommended
docker exec openldap slapcat -n 0 > ldap-config-$(date +%Y%m%d).ldif

Backup frequency recommendation:

  • Daily for the data backup
  • After any configuration changes for the config backup

Restoring from Backup

Warning: This destroys all existing data!

# Stop the container
docker stop openldap
docker rm openldap

# Remove existing volumes
docker volume rm openldap-data openldap-config

# Create fresh container with backup
docker run --rm \
  -v openldap-data:/var/lib/openldap/openldap-data \
  -v $(pwd)/ldap-data-20240101.ldif:/backup.ldif:ro \
  enterprise-openldap:latest \
  sh -c "slapadd -n 1 -l /backup.ldif && chown -R ldap:ldap /var/lib/openldap"

# Start normally
docker run -d --name openldap \
  -v openldap-data:/var/lib/openldap/openldap-data \
  -v openldap-config:/etc/openldap/slapd.d \
  ... # your normal settings

Troubleshooting

Understanding Log Levels

The LDAP_LOG_LEVEL controls what gets logged. Common values:

Level What it shows When to use
0 Nothing Never in production
256 Connections, operations, results Normal operation
384 Above + ACL decisions Debugging access issues
512 Stats with entry counts Performance analysis
-1 Everything Last resort debugging

Increase temporarily for debugging:

docker run -e LDAP_LOG_LEVEL=384 ...

Common Issues

Container exits immediately

Symptom: Container starts and stops within seconds.

Cause: Missing required environment variables.

Solution: Check that LDAP_DOMAIN, LDAP_ORGANISATION, and LDAP_ADMIN_PASSWORD are all set.

docker logs openldap  # Will show which variable is missing

Cannot authenticate as admin

Symptom: ldap_bind: Invalid credentials (49)

Causes:

  1. Wrong password
  2. Wrong bind DN (check the domain components match your LDAP_DOMAIN)
  3. Typing dc=example.com instead of dc=example,dc=com

Solution: Verify the exact bind DN:

# Your bind DN is always: cn=admin,<your-base-dn>
# If LDAP_DOMAIN=example.com, then it's: cn=admin,dc=example,dc=com

TLS connection refused

Symptom: Cannot connect to port 636.

Cause: Certificates not found or not readable.

Solution: Check the logs for TLS errors:

docker logs openldap | grep -i tls

Verify certificates are mounted correctly:

docker exec openldap ls -la /certs/

Symptom: Service account binds successfully but searches return empty.

Cause: ACLs restrict what the service account can see.

Solution: Service accounts can only read specific OUs. Verify you're searching the correct base:

# Correct - searching People OU
ldapsearch -D "cn=keycloak,ou=Services,..." -b "ou=People,dc=example,dc=com" ...

# Wrong - searching the entire tree
ldapsearch -D "cn=keycloak,ou=Services,..." -b "dc=example,dc=com" ...

Users don't have memberOf attribute

Symptom: User exists in group but memberOf attribute is empty.

Cause: User was added before the memberOf overlay, or group uses wrong attribute.

Solution: The memberOf overlay only tracks member attribute on groupOfMembers objects. Verify:

  1. Group has objectClass: groupOfMembers
  2. Group uses member attribute (not memberUid)

To fix existing users, remove and re-add them to groups.


Security Recommendations

Password Security

  1. Use strong admin password - At least 16 characters with mixed case, numbers, symbols
  2. Rotate service account passwords periodically
  3. Delete service-passwords.txt after retrieving the passwords
  4. Never commit passwords to version control

Network Security

  1. Use TLS for all production deployments
  2. Restrict port access - Only allow LDAP ports from trusted networks
  3. Use internal Docker networks when possible:
docker network create ldap-net
docker run --network ldap-net --name openldap ...
# Other containers on ldap-net can access via hostname "openldap"

Access Control

  1. Use service accounts instead of sharing admin credentials
  2. Principle of least privilege - Service accounts only get read access
  3. Audit regularly - Check who has access to what

Docker Compose Reference

Complete production-ready example:

version: '3.8'

services:
  openldap:
    image: enterprise-openldap:latest
    container_name: openldap
    hostname: ldap.example.com
    environment:
      LDAP_DOMAIN: example.com
      LDAP_ORGANISATION: "Example Corporation"
      LDAP_ADMIN_PASSWORD: ${LDAP_ADMIN_PASSWORD:?Set LDAP_ADMIN_PASSWORD}
      LDAP_TLS_ENABLED: "true"
      LDAP_CREATE_SERVICE_ACCOUNTS: "true"
      LDAP_LOG_LEVEL: "256"
    volumes:
      - openldap-data:/var/lib/openldap/openldap-data
      - openldap-config:/etc/openldap/slapd.d
      - ./certs:/certs:ro
    ports:
      - "389:389"
      - "636:636"
    networks:
      - backend
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "ldapsearch", "-x", "-H", "ldap://localhost", "-b", "", "-s", "base"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

networks:
  backend:
    driver: bridge

volumes:
  openldap-data:
  openldap-config:

Create a .env file:

LDAP_ADMIN_PASSWORD=your-secure-password-here

Start:

docker compose up -d
Description
Enterprise OpenLDAP container with Alpine Linux, RFC2307bis schema, and pre-configured overlays
Readme 108 KiB
Languages
Shell 96.8%
Dockerfile 3.2%