A Docker container providing OpenVPN with LDAP authentication and optional two-factor authentication (TOTP/MFA). Part of an integrated suite of tools for enterprise-grade VPN access with centralised user management.
The goal of this project is to build a Docker container that provides an OpenVPN server which authenticates users against an existing OpenLDAP directory, with optional two-factor authentication using TOTP (via liboath).
OpenVPN is a mature, free, and open-source VPN solution known for its strong security, flexibility, and active development since 2001. It supports a wide range of operating systems through well-maintained client applications.
OpenLDAP is a robust open-source implementation of the Lightweight Directory Access Protocol (LDAP), widely used for centralised authentication, authorisation, and user information management. It provides a flexible, standards-based system for managing directory data across diverse environments.
This project extends that functionality by allowing OpenVPN to verify users’ TOTP keys and related metadata directly from OpenLDAP. Managing these credentials within the same directory simplifies administration, ensures consistent access control, and improves security by keeping all authentication data in a single, authoritative source.
- Centralised authentication: Use your existing LDAP directory for VPN access
- Optional MFA/2FA: Add time-based one-time passwords (TOTP) for enhanced security
- Enterprise ready: Supports fail2ban, custom routing, and advanced networking
- Docker-native: Easy deployment with persistent configuration
This OpenVPN server works best as part of an integrated solution:
| Component | Purpose | Repository |
|---|---|---|
| OpenVPN server (this project) | VPN gateway with LDAP + MFA authentication | openvpn-server-ldap-otp |
| Luminary | A web UI for self-service MFA enrolment | luminary |
Benefits of the complete stack:
- Users can scan QR codes and set up MFA themselves
- Self-service MFA enrolment without admin intervention
- Grace periods allow users time to enrol before VPN access is restricted
- No need to SSH into servers to manage OTP secrets
docker run \
--name openvpn \
--cap-add=NET_ADMIN \
-p 1194:1194/udp \
-v /path/to/data:/etc/openvpn \
-e "OVPN_SERVER_CN=vpn.example.com" \
-e "LDAP_URI=ldap://ldap.example.com" \
-e "LDAP_BASE_DN=dc=example,dc=com" \
-e "LDAP_BIND_USER_DN=cn=pam-totp-ldap-auth,ou=services,dc=example,dc=com" \
-e "LDAP_BIND_USER_PASS=password" \
-d \
wheelybird/openvpn-ldap-otp:v2.1.0NOTE: LDAP_BIND_USER should be the DN for an LDAP account that can access the user's LDAP attributes. If you don't have a specific service account set up for this then you can use the administrator base DN, but this isn't recommended. The example uses the example service account from the LDAP TOTP schema repository.
First, install the LDAP TOTP schema in your LDAP directory, then:
docker run \
--name openvpn \
--cap-add=NET_ADMIN \
-p 1194:1194/udp \
-v /path/to/data:/etc/openvpn \
-e "OVPN_SERVER_CN=vpn.example.com" \
-e "LDAP_URI=ldap://ldap.example.com" \
-e "LDAP_BASE_DN=dc=example,dc=com" \
-e "LDAP_BIND_USER_DN=cn=pam-totp-ldap-auth,ou=services,dc=example,dc=com" \
-e "LDAP_BIND_USER_PASS=your_service_account_password" \
-e "LDAP_FILTER=(memberOf=cn=vpn-users,ou=groups,dc=example,dc=com)" \
-e "MFA_ENABLED=true" \
-e "MFA_BACKEND=ldap" \
-d \
wheelybird/openvpn-ldap-otp:v2.1.0Note: The LDAP_FILTER restricts VPN access to members of the vpn-users group. Omit this variable to allow all users in the base DN.
Deploy Luminary to give users a friendly web interface for MFA enrolment.
docker exec -ti openvpn show-client-config > client.ovpnDistribute this .ovpn file to your users. When connecting with MFA enabled, users enter: password123456 (password + 6-digit TOTP code concatenated).
NOTE: This container uses the (pam_ldap_totp_auth) PAM module for LDAP and MFA authentication. The module can operate in three modes controlled by environment variables. MFA is implemented using TOTP (Time-based One-Time Password).
Store TOTP secrets in LDAP for centralised management.
Prerequisites:
- Install LDAP TOTP Schema in your LDAP directory
- Optionally deploy Luminary for self-service
Configuration:
-e "MFA_ENABLED=true"
-e "MFA_BACKEND=ldap"Note: This implementation uses TOTP (Time-based One-Time Password) as the MFA method.
NOTE: you can configure which LDAP attributes store TOTP data, so if you're unable to install the suggested schema it'll still be possible to store the data in LDAP (but not recommended).
User experience:
- Users enrol via web UI (scan QR code with authenticator app)
- Users enter
password123456when connecting to VPN - Backup codes available for emergency access
Benefits:
- Self-service enrolment via web interface
- Admin oversight of MFA adoption
- Grace periods for new users
- Backup codes stored in LDAP
- Standalone PAM module with direct LDAP integration
- Configurable enforcement modes (strict, graceful, warn-only)
See AUTHENTICATION_MODES.md for detailed information.
Uses google-authenticator with file-based secret storage. The pam_ldap_totp_auth module handles LDAP password authentication (set totp_enabled=false).
Configuration:
-e "MFA_ENABLED=true"
# MFA_BACKEND defaults to 'file' if not setNote: File-based mode uses google-authenticator TOTP implementation.
Setup: docker exec -ti openvpn add-otp-user username
User experience: Users enter password123456 (password + TOTP code).
How it works:
pam_google_authenticatorvalidates the TOTP code from filepam_ldap_totp_auth(with TOTP disabled) validates LDAP password- Both must succeed
Simple LDAP password authentication without two-factor. Uses the pam_ldap_totp_auth module with totp_enabled=false.
Configuration:
-e "LDAP_URI=ldap://ldap.example.com"
-e "LDAP_BASE_DN=dc=example,dc=com"
# Do NOT set MFA_ENABLED=true (or leave it as default 'false')Note: Not recommended for production. Enable MFA for security.
Traditional X.509 certificate-based authentication (no LDAP required). Only one certificate is generated, so for more than one user to connect to the VPN you'd need to share the certificate. This isn't recommended.
Configuration:
-e "USE_CLIENT_CERTIFICATE=true"User experience: User connect with certificate, no password needed.
Use case: Development, testing, or for a single-user VPN.
When MFA_ENABLED=true, MFA is required for all users authenticating to this OpenVPN server.
The MFA_ENFORCEMENT_MODE variable (default: graceful) controls how users without enrolled TOTP secrets are handled:
| Mode | Behaviour | Use case |
|---|---|---|
strict |
Users without TOTP secrets cannot authenticate | Production - require MFA for all users |
graceful |
Users without secrets can authenticate during grace period | Migration - give users time to enrol |
warn_only |
Users without secrets can authenticate (warning logged) | Testing - MFA optional |
Grace period: With MFA_ENFORCEMENT_MODE=graceful (the default), users have MFA_GRACE_PERIOD_DAYS (default: 7) from account creation to enrol in MFA before authentication is denied.
Use the LDAP_FILTER environment variable to restrict VPN access based on group membership or account attributes.
Example - Restrict to specific group:
docker run \
... other options ... \
-e "LDAP_FILTER=(memberOf=cn=vpn-users,ou=groups,dc=example,dc=com)" \
wheelybird/openvpn-ldap-otp:v2.1.0How it works:
- The PAM module combines your filter with the username search using AND logic
- Final LDAP query:
(&(uid=username)(memberOf=cn=vpn-users,ou=groups,dc=example,dc=com)) - Only users matching BOTH criteria can authenticate
More examples:
# Allow multiple groups (OR)
-e "LDAP_FILTER=(|(memberOf=cn=vpn-users,ou=groups,dc=example,dc=com)(memberOf=cn=admins,ou=groups,dc=example,dc=com))"
# Require active account status
-e "LDAP_FILTER=(accountStatus=active)"
# Complex filter (active AND in group)
-e "LDAP_FILTER=(&(accountStatus=active)(memberOf=cn=vpn-users,ou=groups,dc=example,dc=com))"Note: for memberOf filters the LDAP server must have memberOf overlay enabled
| Variable | Description | Example |
|---|---|---|
OVPN_SERVER_CN |
Server hostname (must be resolvable by clients) | vpn.example.com |
| Variable | Required | Description | Example |
|---|---|---|---|
LDAP_URI |
Yes* | LDAP server URI | ldap://ldap.example.com or ldaps://ldap.example.com:636 |
LDAP_BASE_DN |
Yes* | Base DN for user searches | dc=example,dc=com |
LDAP_BIND_USER_DN |
Recommended | DN for bind user (if anonymous bind disabled) | cn=readonly,dc=example,dc=com |
LDAP_BIND_USER_PASS |
Recommended | Password for bind user | supersecret |
LDAP_LOGIN_ATTRIBUTE |
No | Attribute to match username (default: uid) |
sAMAccountName for AD |
LDAP_FILTER |
No | Additional LDAP filter for access control | (memberOf=cn=vpn-users,ou=groups,dc=example,dc=com) |
LDAP_ENCRYPT_CONNECTION |
No | TLS mode: on, starttls, or off |
starttls |
LDAP_TLS_VALIDATE_CERT |
No | Validate TLS certificate (default: true) |
false for self-signed |
LDAP_TLS_CA_CERT |
No | CA certificate contents for TLS | Contents of CA cert file |
*Not required if USE_CLIENT_CERTIFICATE=true
Active Directory users: Set -e "ACTIVE_DIRECTORY_COMPAT_MODE=true" to automatically configure appropriate settings.
Note: MFA is implemented using TOTP (Time-based One-Time Password).
| Variable | Default | Description |
|---|---|---|
MFA_ENABLED |
false |
Enable multi-factor authentication (using TOTP) |
ENABLE_OTP |
false |
Alias for MFA_ENABLED (backwards compatibility) |
MFA_BACKEND |
file |
MFA storage backend: ldap or file |
MFA_TOTP_ATTRIBUTE |
totpSecret |
LDAP attribute storing TOTP secret (LDAP backend only) |
MFA_GRACE_PERIOD_DAYS |
7 |
Grace period for new users (days) |
MFA_ENFORCEMENT_MODE |
graceful |
Enforcement mode (see below) |
Backwards compatibility: Both MFA_ENABLED and ENABLE_OTP work. If both are set, MFA_ENABLED takes precedence.
Enforcement modes:
graceful(default): Users without secrets can authenticate during grace periodstrict: Users without TOTP secrets cannot authenticatewarn_only: Users without secrets can authenticate (warning logged)
See MFA enforcement section for details.
| Variable | Default | Description |
|---|---|---|
OVPN_PORT |
1194 |
OpenVPN listen port (update Docker -p to match) |
OVPN_PROTOCOL |
udp |
Protocol: udp or tcp |
OVPN_NETWORK |
10.50.50.0 255.255.255.0 |
VPN network address and netmask |
OVPN_ROUTES |
All traffic | Routes to push to clients (format: 192.168.1.0 255.255.255.0,10.0.0.0 255.255.0.0) |
OVPN_NAT |
true |
Enable NAT/masquerading for client traffic |
OVPN_DNS_SERVERS |
None | DNS servers to push (comma-separated) |
OVPN_DNS_SEARCH_DOMAIN |
None | DNS search domains (comma-separated) |
| Variable | Default | Description |
|---|---|---|
KEY_LENGTH |
2048 |
Certificate key length (higher = more secure, slower) |
OVPN_TLS_CIPHERS |
Modern ciphers | TLS 1.2 cipher list |
OVPN_TLS_CIPHERSUITES |
Modern suites | TLS 1.3 cipher suites |
OVPN_IDLE_TIMEOUT |
None | Disconnect idle connections (seconds) |
REGENERATE_CERTS |
false |
Force certificate regeneration |
| Variable | Default | Description |
|---|---|---|
FAIL2BAN_ENABLED |
false |
Enable brute-force protection |
FAIL2BAN_MAXRETRIES |
3 |
Failed attempts before ban |
| Variable | Default | Description |
|---|---|---|
OVPN_MANAGEMENT_ENABLE |
false |
Enable TCP management interface on port 5555 |
OVPN_MANAGEMENT_NOAUTH |
false |
Allow access without authentication |
OVPN_MANAGEMENT_PASSWORD |
None | Management interface password |
| Variable | Default | Description |
|---|---|---|
OVPN_DEFAULT_SERVER |
true |
Auto-generate server network config |
OVPN_EXTRA |
None | Additional OpenVPN config directives (raw text) |
OVPN_VERBOSITY |
4 |
Log verbosity (0-11) |
DEBUG |
false |
Enable debug logging |
LOG_TO_STDOUT |
true |
Send OpenVPN logs to stdout |
Note: You can add any extra server configuration for OpenVPN using OVPN_EXTRA. This should be a string with escaped newlines and quotes. For example: OVPN_EXTRA="sndbuf 393216\nrcvbuf 393216\ntxqueuelen 1000"
Mount /etc/openvpn as a volume to persist:
- Generated certificates and keys
- Server configuration
- OTP secrets (if using file-based mode)
- Client configuration
-v /path/on/host:/etc/openvpnImportant: The first run generates certificates (2048-bit key takes 2-10 minutes). Subsequent starts are much faster.
-e "LDAP_ENCRYPT_CONNECTION=starttls"
-e "LDAP_TLS_VALIDATE_CERT=true"
-e "LDAP_TLS_CA_CERT=$(cat /path/to/ca.crt)"-e "MFA_ENABLED=true"
-e "MFA_BACKEND=ldap"Note: MFA_ENABLED replaces the deprecated ENABLE_OTP variable. Both work, but MFA_ENABLED takes precedence if both are set. The MFA implementation uses TOTP (Time-based One-Time Password).
Deploy Luminary for self-service enrolment.
# Use LDAP_FILTER to restrict by group membership
-e "LDAP_FILTER=(memberOf=cn=vpn-users,ou=groups,dc=example,dc=com)"
# Or use base DN to restrict by organisational unit
-e "LDAP_BASE_DN=ou=vpn-users,dc=example,dc=com"See "Restricting access by group" section for more filter examples.
-e "FAIL2BAN_ENABLED=true"
-e "FAIL2BAN_MAXRETRIES=3"-e "OVPN_IDLE_TIMEOUT=3600" # 1 hourdocker exec -ti openvpn show-client-configdocker exec -ti openvpn add-otp-user usernamedocker logs -f openvpn# Ban an IP
docker exec -ti openvpn fail2ban-client set openvpn banip 192.168.1.100
# Unban an IP
docker exec -ti openvpn fail2ban-client set openvpn unbanip 192.168.1.100
# View fail2ban logs
docker exec -ti openvpn tail -50 /var/log/fail2ban.logdocker run ... -e "REGENERATE_CERTS=true" ...Note: After regenerating the certificates you'll need to provide users with the new client configuration.
Cause: Low system entropy (common on VMs).
Solution:
# Install entropy daemon on Docker host
apt-get install haveged
systemctl enable haveged
systemctl start haveged
# Check available entropy (should be >1000)
cat /proc/sys/kernel/random/entropy_availAlternatively, wait longer (can take 30+ minutes on low-entropy systems) or use a lower key length (not recommended for production):
-e "KEY_LENGTH=2048"Check LDAP connectivity:
docker exec -ti openvpn ldapsearch -x -H "${LDAP_URI}" -b "${LDAP_BASE_DN}" -D "${LDAP_BIND_USER_DN}" -w "${LDAP_BIND_USER_PASS}"Common issues:
- Bind user lacks search permissions
- Base DN doesn't include the user
- TLS certificate validation failing (try
LDAP_TLS_VALIDATE_CERT=falsetemporarily) - Wrong base DN or bind credentials
- Wrong
LDAP_LOGIN_ATTRIBUTE(usesAMAccountNamefor Active Directory)
File-based TOTP:
- Check OTP file exists:
docker exec -ti openvpn ls /etc/openvpn/otp/ - Ensure time synchronization with NTP
LDAP-backed TOTP:
- Verify LDAP schema installed: ldap-totp-schema
- Check user has
totpSecretattribute populated - Verify PAM module config:
docker exec -ti openvpn cat /etc/security/pam_ldap_totp_auth.conf - Enable debug:
-e "DEBUG=true"
Time synchronisation critical for TOTP:
# Check time on the host
dateInstall NTP/chrony on the host.
Issue: VPN connects but can't access internal resources.
Solutions:
-
Enable NAT (easiest):
-e "OVPN_NAT=true" -
Add return routes on internal gateways pointing the VPN network (e.g.
10.50.50.0/24) to the OpenVPN server IP -
Check routing:
docker exec -ti openvpn ip route docker exec -ti openvpn iptables -t nat -L -n -v
- LDAP TOTP Schema - Schema installation guide
- Luminary - The Luminary LDAP account manager with self-service password/MFA support
- LDAP TOTP PAM Module - PAM module documentation
- Issues: https://github.com/wheelybird/openvpn-server-ldap-otp/issues
- Pull Requests: Welcome!
See LICENCE file for details.
Built on top of:
- OpenVPN
- OATH toolkit - TOTP validation
- OpenLDAP libraries - LDAP connectivity
- Easy-RSA - Certificate management
Custom components: | Component | Purpose | Repository | | LDAP TOTP schema - An LDAP schema that allows the storage of MFA/TOTP keys and metadata in LDAP
- LDAP TOTP PAM module - A Linux Pluggable-Authentication-Module that authenticates LDAP accounts and can authenticate One-Time-Passwords when LDAP uses the LDAP TOTP schema
Part of the wheelybird LDAP MFA suite.