Website behind Active Directory/LDAP with Nginx
06 May 2025
LDAP is a pretty known and widely used protocol in the world of IT, although it is not as comfortable to use as OAuth or OpenID Connect. Nevertheless, you can still often encounter enterprises that need to use it, therefore it is worth to gain some experience with it. In this article, I will show you how to set up a website on Nginx that is protected by Active Directory or LDAP authentication. If you have your own spare domain, you are even luckier because you can also use SSL from Let's Encrypt to make the connection secure (see last section)!
The code for the complete project is available on GitHub: https://github.com/ppabis/nginx-ldap-simplead
Our first directory
In order to use LDAP with Nginx, we obviously need a directory where our users will reside. For this purpose, I will use AWS Simple AD, as it is easy to set up and costs very little. I will use Terraform Infrastructure as Code to set up all the required resources.
First, create a new project for your IaC and create a new file provider.tf
that will define versions of providers we want to use and configure them. One
thing to note is that not all regions of AWS support managed Simple AD service
(such as eu-central-1
), so in my case I will use eu-west-1
which is the only
european region that supports it. Refer to this list for more information:
Region availability for AWS Directory Service.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">=5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
}
provider "aws" {
region = "eu-west-1"
}
Because a directory lives in a VPC, we also need one. I will create a new one
using a module for simplicity. It will set up all the IGWs and route tables for
us. You can technically use the default VPC, but it is preferred to have better
control over the resources. In file vpc.tf
write:
module "vpc" {
source = "aws-ia/vpc/aws"
version = ">= 4.2.0"
name = "my-ldap-vpc"
cidr_block = "10.10.0.0/16"
az_count = 2
subnets = {
public = { netmask = 24 }
private = { netmask = 24 }
}
}
The code above will create two public and two private subnets in to different
AZs. Now we can apply the changes to create the VPC (change tofu
to
terraform
if you are using Terraform):
$ tofu init
$ tofu apply
In this new VPC we can create our LDAP directory. The password you can either
define as a variable or use random_password
resource from hashicorp/random
provider. I will use the latter and use output marked as sensitive
to retrieve
it. Also choose some DNS name for your directory. In the example I will use
auth.company.internal
. In the new file directory.tf
you can create the
resources:
resource "random_password" "directory_password" {
length = 20
special = true
override_special = "-_.!"
min_special = 2
min_upper = 2
min_lower = 2
min_numeric = 2
}
resource "aws_directory_service_directory" "simple_ad" {
name = "auth.company.internal" # richtige DNS-Name
password = random_password.directory_password.result
size = "Small"
type = "SimpleAD"
vpc_settings {
vpc_id = module.vpc.vpc_attributes.id
subnet_ids = slice([for _, subnet in module.vpc.private_subnet_attributes_by_az : subnet.id], 0, 2)
}
tags = { Name = "simple-ad" }
}
output "ldap_password" {
value = random_password.directory_password.result
sensitive = true
}
After applying this infrastructure, you will retrieve the password using
tofu output
or terraform output
. This is the password for the administrator
account of the directory so keep it very safe. We need it to manage the
users and groups in the directory.
$ tofu output ldap_password
Managing the directory
At this step we will place ourselves in the shoes of a traditional IT
administrator who manages the users - we will use Windows. For this purpose in
Terraform we will define IAM Role, Security Groups and EC2 Instance. To decrypt
the EC2 Windows password, we will use hashicorp/tls
provider that will
generate a key pair for us. EC2 software provided by Amazon will automatically
join our instance to the directory.
terraform {
required_providers {
... # aws and random
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
}
Now, our IAM role will need some more permissions than the usually used ones.
Not only we need AmazonSSMManagedInstanceCore
policy but also
AmazonSSMDirectoryServiceAccess
. Only permissions defined in these policies
allow for EC2 instance to join the directory. In the file iam.tf
write:
# IAM Role for the EC2 instance
resource "aws_iam_role" "windows_domain_role" {
name = "WindowsDomainRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [ {
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
} ]
})
}
# Instance Profile
resource "aws_iam_instance_profile" "windows_domain_profile" {
name = "WindowsDomainProfile"
role = aws_iam_role.windows_domain_role.name
}
# Permissions
resource "aws_iam_role_policy_attachment" "ssm_managed_instance" {
role = aws_iam_role.windows_domain_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_role_policy_attachment" "directory_service_access" {
role = aws_iam_role.windows_domain_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMDirectoryServiceAccess"
}
Next, what we need is a security group that will allow our local machine to
reach out to the EC2 instance. For that we need to enable port 3389
for remote
desktop protocol. To keep it secure, specify your own IP CIDR block. You can
check the IP be going to https://api.ipify.org
. Convert it to block by adding
/32
at the end. In the example I will place a larger CIDR block to handle
dynamic IP changes.
resource "aws_security_group" "rdp" {
name = "rdp-access"
description = "Access to EC2 with RDP"
vpc_id = module.vpc.vpc_attributes.id
ingress {
description = "RDP from specified CIDR"
from_port = 3389
to_port = 3389
protocol = "tcp"
cidr_blocks = ["89.19.0.0/16"] # Make it your IP CIDR block
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Last but not least, we generate a key pair that will be used to decrypt the password for the admin account on Windows instance. This is not necessarily the most important part, more needed just for debugging. In general, we will use the LDAP administrator account straight away to authenticate with the machine.
resource "tls_private_key" "windows_key" {
algorithm = "RSA"
rsa_bits = 2048
}
resource "aws_key_pair" "windows_key" {
key_name = "windows-key"
public_key = tls_private_key.windows_key.public_key_openssh
}
output "windows_private_key" {
value = tls_private_key.windows_key.private_key_pem
sensitive = true
}
Joining the instance to the directory is also not that straightforward. You
first have to define an SSM document that will be used with SSM State Manager to
create the association with the instance. The SSM Agent will then set up the
instance to join the directory. One document is needed for a directory and can
be reused for more instances. In file ssm.tf
create it.
resource "aws_ssm_document" "domain_join" {
name = "awsconfig_Domain_${aws_directory_service_directory.simple_ad.id}_${aws_directory_service_directory.simple_ad.name}"
document_type = "Command"
content = jsonencode({
schemaVersion = "1.0"
description = "Automatic Domain Join Configuration"
runtimeConfig = {
"aws:domainJoin" = {
properties = {
directoryId = aws_directory_service_directory.simple_ad.id
directoryName = aws_directory_service_directory.simple_ad.name
dnsIpAddresses = aws_directory_service_directory.simple_ad.dns_ip_addresses
}
}
}
})
}
Phew, that was a lot of lines! Now we are ready to start the management EC2
instance. In new file windows_ec2.tf
create the instance. Also define the
outputs that will be used to connect to it and retrieve the password. I will use
the latest Windows Server 2025 AMI. I also added depends_on
to the instance to
be sure that the SSM document is already available and the association can be
created quickly. The password for the instance must be decrypted using the
private key we generated before.
data "aws_ami" "windows_ami" {
most_recent = true
owners = ["amazon"]
name_regex = "^Windows_Server-2025-English-Full-Base-*"
}
resource "aws_instance" "windows_ec2" {
tags = { Name = "windows-administrator" }
ami = data.aws_ami.windows_ami.id
instance_type = "t3.small"
iam_instance_profile = aws_iam_instance_profile.windows_domain_join.name
key_name = aws_key_pair.windows_key.key_name
get_password_data = true
subnet_id = [for _, subnet in module.vpc.public_subnet_attributes_by_az : subnet.id][0]
vpc_security_group_ids = [aws_security_group.rdp.id]
associate_public_ip_address = true
depends_on = [aws_ssm_document.domain_join]
}
resource "aws_ssm_association" "domain_join" {
name = aws_ssm_document.domain_join.name
targets {
key = "InstanceIds"
values = [aws_instance.windows_ec2.id]
}
}
output "windows_ec2_dns_name" {
value = aws_instance.windows_ec2.public_dns
}
output "windows_password" {
value = rsadecrypt(aws_instance.windows_ec2.password_data, tls_private_key.windows_key.private_key_pem)
sensitive = true
}
Connecting with Remote Desktop
It can take time before the instance is ready to accept connections, even five minutes for the domain join to be completed. If your current operating system is Windows, you can simply search for "Remote Desktop" in the start menu. For Linux I recommend Remmina and for Mac you can use Microsoft Remote Desktop (now known as "Windows App"). You can get it here.
First add a new PC. Select the option to add a new account. For the username use
administrator@auth.company.internal
(or other domain you used) and use the
ldap_password
output (not windows_password!). Copy windows_ec2_dns_name
and paste it to the PC name.
In case you can't connect with these credentials, you can debug with just
administrator
username and the windows_password
output. Read
C:\ProgramData\Amazon\SSM\Logs\
files for more information. ProgramData
directory is hidden so you need to type it into explorer.
After you are logged in, search for "Server Manager" in the start menu. It can take a while to be ready to use. After that, click "Add roles and features", leave everything as default by clicking "Next" until you reach the "Features" page. Look on the list for "AD DS and AD LDS Tools" and check it. Also check "DNS Server Tools". Click "Next" and then "Install". These two are hidden under "Remote Server Administration Tools" > "Role Administration Tools". Now you can go and make yourself a cup of coffee ☕️.
Once you are back, you can search for "Active Directory Users and Computers" in the start menu. Under the domain, right-click on the folder "Users" and select "New" > "User". Create some new password and select checkboxes that the password never expires and doesn't need to be changed. Create at least two users for this tutorial. After that, right-click on each user and select "Properties". Select "Account" tab and check "Unlock account" checkbox.
Now right-click on the folder "Users" and select "New" > "Group". Create a new group with default settings. Right-click on one of the users and select the option to add it to the group. Type name of the group and verify with "Check Names". Do not add the other user to the group. Check the group properties to check if the user is added there.
Preparing the web instance
On our web instance that will be protected by LDAP, we will use Nginx running in
Docker. It could be done using ECS, but I want to make it a bit simpler to
understand. To create the whole stack, we will also use Docker Compose. A
sidecar container will be required to run a special LDAP daemon for Nginx. As an
OS of my choice, I will use Amazon Linux 2023. In a new Terraform file web.tf
create the security group, IAM role, and EC2 instance. To make the security
group cleaner, I will use terraform-aws-modules/security-group
module that
contains many predefined rules for popular services. If you want to use a key
pair depends on you, as I will give the instance IAM permissions to use SSM
Session Manager. If you are more comfortable with SSH, set a key pair and add
security group rule for ssh-tcp
.
data "aws_ssm_parameter" "amazon_linux_2023" {
name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-arm64"
}
module "instance_sg" {
source = "terraform-aws-modules/security-group/aws"
version = "5.3.0"
name = "instance-sg"
vpc_id = module.vpc.vpc_attributes.id
description = "Security group of the web instance"
ingress_rules = ["ssh-tcp", "http-80-tcp", "https-443-tcp"]
ingress_cidr_blocks = ["0.0.0.0/0"]
egress_rules = ["all-all"]
}
resource "aws_iam_role" "instance_role" {
name = "ldap-instance-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [ {
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
} ]
})
}
resource "aws_iam_role_policy_attachment" "ssm_policy" {
role = aws_iam_role.instance_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "instance_profile" {
name = "ldap-instance-profile"
role = aws_iam_role.instance_role.name
}
# Optional if you want to use SSH
resource "aws_key_pair" "instance_key" {
key_name = "ldap-instance-key"
public_key = file("~/.ssh/id_rsa.pub")
}
resource "aws_instance" "ldap_web" {
tags = { Name = "web-instance" }
ami = data.aws_ssm_parameter.amazon_linux_2023.value
instance_type = "t4g.nano"
subnet_id = [for _, subnet in module.vpc.public_subnet_attributes_by_az : subnet.id][0]
associate_public_ip_address = true
vpc_security_group_ids = [module.instance_sg.security_group_id]
iam_instance_profile = aws_iam_instance_profile.instance_profile.name
# Optional if you want to use SSH
key_name = aws_key_pair.instance_key.key_name
lifecycle { ignore_changes = [ami] }
}
Apply the changes using tofu init
and tofu apply
. Connect to the instance
using your preferred method. Install Docker and Docker Compose and allow your
user to use Docker. Log out and log in again to apply the group changes.
$ sudo yum install docker git -y
$ sudo systemctl enable --now docker
$ # Attention: You need IPv4 to download from GitHub!
$ sudo curl -L \
"https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" \
-o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose
$ sudo usermod -aG docker $(whoami) # Log in to the instance again
Sharing AD attributes with the instance
To make things a bit safer, we will use SSM Parameter Store to share all the details about the directory with the instance. This way we don't need to hardcode any values. The IAM role of our instance has permissions already to read the SSM Parameters.
resource "aws_ssm_parameter" "ad_admin_password" {
name = "/nginx-ldap/ad-admin-password"
description = "The directory Administrator password"
type = "SecureString"
value = random_password.directory_password.result
}
resource "aws_ssm_parameter" "ad_server_name" {
name = "/nginx-ldap/ad-server-name"
description = "The directory server name"
type = "String"
value = aws_directory_service_directory.simple_ad.name
}
resource "aws_ssm_parameter" "ad_dns_ip" {
name = "/nginx-ldap/ad-dns-ip"
description = "The directory's first DNS server IP"
type = "String"
value = tolist(aws_directory_service_directory.simple_ad.dns_ip_addresses)[0]
}
It will be possible to retrieve these values using AWS CLI. We will create a script later that will put them into environment variables before starting the Docker Compose stack.
Configuring the LDAP server
The original code had uid
hardcoded as the user attribute, I had to modify it
a bit. I also changed the authentication credentials to use the modern usernames
with @
. You can find the fork here:
ppabis/nginx-ldap-auth-service.
You will need to clone the code to the instance and we will build the image from
it. You can for example prepare the directory /opt/ldap
, go there and run
git clone https://github.com/ppabis/nginx-ldap-auth-service.git
. Also in
/opt/ldap
create a new file docker-compose.yml
with the following content:
networks:
app_network:
driver: bridge
services:
ldap:
build:
context: nginx-ldap-auth-service # or other name in case you cloned it to a different directory
dockerfile: Dockerfile
hostname: ldap
container_name: ldap
ports:
- "8888:8888"
environment:
- LDAP_URI=${LDAP_URI}
- LDAP_BASEDN=${LDAP_BASEDN}
- LDAP_BINDDN=${LDAP_BINDDN}
- LDAP_PASSWORD=${LDAP_PASSWORD}
- LDAP_USERNAME_ATTRIBUTE=sAMAccountName
- SECRET_KEY=notImportant
- LDAP_AUTHORIZATION_FILTER=(&(memberOf=${LDAP_GROUP}) ({username_attribute}={username}))
networks:
- app_network
This will create a new LDAP daemon on port 8888. All the values above will be
populated by the environment variables that we will load shortly. The LDAP_URI
will be the DNS IP of our directory, LDAP_BASEDN
will be the name of our
server in Distinguished Name format (DC=auth,DC=company,DC=internal
),
LDAP_BINDDN
is the username used by the daemon to log in to the directory (we
use administrator account here but it's not recommended). The most important
part is the LDAP_AUTHORIZATION_FILTER
- this is a filter that will be used to
select which users are allowed to log in to the website. Here we just check if
the user is a member of the group we have created before. SECRET_KEY
is
required but not used anywhere in the code. You can set it to any random string.
Now to populate these variables, we need a script that will load them from SSM
Parameter Store. For the password, we need --with-decryption
flag. Create a
new start.sh
script.
#!/bin/bash
# Call SSM to get the parameters
AD_DNS_IP=$(aws ssm get-parameter --name "/nginx-ldap/ad-dns-ip" --query "Parameter.Value" --output text)
AD_NAME=$(aws ssm get-parameter --name "/nginx-ldap/ad-server-name" --query "Parameter.Value" --output text)
export LDAP_PASSWORD=$(aws ssm get-parameter --name "/nginx-ldap/ad-admin-password" --with-decryption --query "Parameter.Value" --output text)
# Format the variables
export LDAP_URI="ldap://${AD_DNS_IP}"
export LDAP_BASEDN="DC=$(echo ${AD_NAME} | sed 's/\./,DC=/g')"
export LDAP_BINDDN="CN=Administrator,CN=Users,$LDAP_BASEDN"
export LDAP_GROUP="CN=webservice,CN=Users,$LDAP_BASEDN"
To validate the script below all the export calls, you can run ldapsearch
command to check if everything is functional. You can install it using
sudo yum install openldap-clients -y
.
$ ldapsearch -x -H $LDAP_URI -D $LDAP_BINDDN -w $LDAP_PASSWORD -b $LDAP_BASEDN
# extended LDIF
#
# LDAPv3
# base <DC=auth,DC=company,DC=internal> with scope subtree
...
$ ldapsearch -x -LLL -H $LDAP_URI -D $LDAP_BINDDN -w $LDAP_PASSWORD -b $LDAP_BASEDN "(objectClass=person)" "sAMAccountName"
dn: CN=Generic,CN=Users,DC=auth,DC=company,DC=internal
sAMAccountName: generic
dn: CN=Karol Krawczyk,CN=Users,DC=auth,DC=company,DC=internal
sAMAccountName: tramwaj18
dn: CN=Administrator,CN=Users,DC=auth,DC=company,DC=internal
sAMAccountName: Administrator
...
$ ldapsearch -LLL -H $LDAP_URI -D $LDAP_BINDDN -w $LDAP_PASSWORD -b $LDAP_BASEDN \
"(&(memberOf=${LDAP_GROUP}) (sAMAccountName=tramwaj18))" \
"sAMAccountName"
dn: CN=Karol Krawczyk,CN=Users,DC=auth,DC=company,DC=internal
sAMAccountName: tramwaj18
# refldap://auth.furfel.internal/CN=Configuration,DC=auth,DC=company,DC=internal
...
$ ldapsearch -LLL -H $LDAP_URI -D $LDAP_BINDDN -w $LDAP_PASSWORD -b $LDAP_BASEDN \
"(&(memberOf=${LDAP_GROUP}) (sAMAccountName=generic))" \
"sAMAccountName"
# refldap://auth.furfel.internal/CN=Configuration,DC=auth,DC=company,DC=internal
...
After you have verified that the filter is correct, and you can only see
sAMAccountName
only for the user that is in the group, you can start the
Compose stack. If the configuration is correct, the container should start and
not throw any errors. Add to start.sh
the command to start the stack and then
look at the logs.
...
export LDAP_GROUP="CN=webservice,CN=Users,$LDAP_BASEDN"
docker-compose up -d
$ chmod +x start.sh
$ ./start.sh
$ docker-compose logs
ldap | 2025-01-26T17:54:48.183974Z [info ] session.store [nginx_ldap_auth] backend=memory
ldap | 2025-01-26T17:54:48.184803Z [info ] session.setup.complete [nginx_ldap_auth] backend=memory cookie_domain=None cookie_name=nginxauth max_age=0 rolling=False
ldap | 2025-01-26T17:54:48.187028Z [info ] Started server process [1] [uvicorn.error]
ldap | 2025-01-26T17:54:48.187150Z [info ] Waiting for application startup. [uvicorn.error]
ldap | 2025-01-26T17:54:48.294225Z [info ] Application startup complete. [uvicorn.error]
ldap | 2025-01-26T17:54:48.301033Z [info ] Uvicorn running on https://ldap:8888 (Press CTRL+C to quit) [uvicorn.error]
Nginx configuration
First we will create a draft configuration for Nginx, without any locations. We
need to define a cache, which will hold the authentication keys. Our cache will
be of size 32MB and will keep 10MB of data in memory. The inactive keys will be
removed after 60 minutes. Then we will define an upstream block that will lead
to the LDAP service. In server_name
put your domain name. If you don't have
one just come up with a random one and add it to your local /etc/hosts
file
along with the public IP of the instance.
events { worker_connections 1024; }
http {
proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=auth_cache:10m max_size=32m inactive=60m use_temp_path=off;
upstream auth_backend {
server ldap:8888;
}
server {
listen 80;
server_name my.domain.org;
# Here come the locations
}
}
In this example configuration we will hide the entire website behind the LDAP.
We need three locations: one for our site, one for the LDAP login form and one
for authentication check. The last one will be internal, so only Nginx can reach
out to it in order to verify the credentials. In this example, take care of the
domain in X-Cookie-Domain
header.
Authentication check
location /check-auth {
internal;
proxy_pass https://auth_backend/check;
proxy_pass_request_headers off;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_ignore_headers "Set-Cookie";
proxy_hide_header "Set-Cookie";
proxy_cache auth_cache;
proxy_cache_valid 200 10m;
proxy_set_header X-Cookie-Name "nginxauth";
proxy_set_header X-Cookie-Domain "my.domain.org";
proxy_set_header Cookie nginxauth=$cookie_nginxauth;
proxy_cache_key "$http_authorization$cookie_nginxauth";
}
Login form
location /auth {
proxy_pass https://auth_backend/auth;
proxy_set_header X-Cookie-Name "nginxauth";
proxy_set_header X-Cookie-Domain "my.domain.org";
proxy_set_header X-Auth-Realm "Log in to website";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
Main website
location / {
auth_request /check-auth;
root /usr/share/nginx/html;
index index.html index.htm;
# In case of 401 error, just redirect to the login page
error_page 401 =200 /auth/login?service=$request_uri;
}
When you have the complete configuration in nginx.conf
file in /opt/ldap
on
the web instance, you can add the Nginx container to the docker-compose.yml
.
We will mount the configuration file to the container. Remember that this
Compose stack has to be run alongside environment variables loaded from SSM. The
new site will be accessible via the instance public IP (if you allow port 80
).
networks:
app_network:
driver: bridge
services:
nginx:
image: nginx:latest
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
networks:
- app_network
depends_on:
- ldap
ports:
- "80:80" # See below if you should remove this
ldap:
...
TLS protected connection
Sending credentials over HTTP is not a good idea. You technically can create an
SSH tunnel or configure a VPN to reach the instance. But why would you do that
if you have an actual domain? We will use Caddy as a reverse proxy to
automatically get Let's Encrypt certificate for our domain. If you don't have
your own domain, just skip this part. In /opt/ldap
create a new Caddyfile
.
my.domain.org {
tls {
issuer acme
}
reverse_proxy nginx:80
}
Now in the Docker Compose stack define another service for Caddy. Also create
two disks that will be used to store some things that Caddy creates (such as the
SSL certificates), so it won't reach to Let's Encrypt every time you restart the
instance. We will open both ports 80
and 443
to the outside world. Plaintext
HTTP is needed for the initial certificate request. The default Caddy behavior
is to redirect HTTP to HTTPS.
networks:
app_network:
driver: bridge
volumes:
caddy_data:
caddy_config:
services:
caddy:
image: caddy:latest
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
networks:
- app_network
depends_on:
- nginx
nginx:
# From nginx, remove the port mapping
...
How does the ready project look like?
Here is a demo of the final status. You can try using wrong credentials to test if the authentication really works. You can try also to log in with the user that is not allowed (not in the group) to access the website. Eventually, try the correct credentials.