Website behind Active Directory/LDAP with Nginx

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 = { Ser

May 6, 2025 - 13:52
 0
Website behind Active Directory/LDAP with Nginx

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.

RDP Settings

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 ☕️.

AD Tools installation

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.

Add user

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  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.

Demo