Containers

Install Authelia with LLDAP on Docker Compose: SSO, MFA and Nginx

Authelia does one job and does it with very little memory: it sits next to Nginx and answers yes or no for every request before that request reaches an app. It has no user database of its own worth running in production, so it needs a directory behind it. LLDAP is that directory. It speaks LDAP, has a small web UI for users and groups, and used about 2 MiB of RAM on our host.

Original content from computingforgeeks.com - post 173175

This Authelia Docker Compose setup uses LLDAP as the user store and runs behind Nginx with Let’s Encrypt. PostgreSQL holds the state of both, Redis keeps sessions alive across restarts, and every secret lives in a file instead of the Compose file. The core path ends with TOTP, passkeys and a protected admin UI. Optional sections add forward auth for any other app, Grafana sign-in over OpenID Connect, and password reset by email. A tested restore, a timed upgrade and the errors we hit close it out. Everything was run on Ubuntu 26.04 and replayed on Ubuntu 24.04 in October 2026 with Authelia 4.39.28 and LLDAP 0.6.3.

How the pieces fit

Nginx owns ports 80 and 443. For every request to a protected site, the auth_request module first asks Authelia at /api/authz/auth-request. Authelia checks the session cookie, applies the access rules, and answers 200 or redirects the browser to its portal. Only then does Nginx pass the request to the app. Authelia checks passwords by binding to LLDAP over LDAP inside the Docker network, so LDAP is never published on the host.

Authelia LLDAP Docker Compose architecture: Nginx on 443 asks Authelia on 127.0.0.1:9091 before passing requests to protected apps, Authelia binds to LLDAP over LDAP and stores state in PostgreSQL and sessions in Redis inside the Docker network
ComponentListens onRole
Nginx0.0.0.0:80, 0.0.0.0:443TLS, auth_request to Authelia, proxy to apps
Authelia127.0.0.1:9091Portal, access rules, TOTP, passkeys, OIDC provider
LLDAP127.0.0.1:17170 (web), 3890 inside Docker (LDAP)Users, groups, passwords
PostgreSQLDocker network onlyTwo databases: authelia and lldap
RedisDocker network onlyAuthelia sessions

If you need an identity provider with its own admin console, flows and SAML, the Keycloak setup in Docker, the authentik Docker Compose guide and the Zitadel guide cover the heavier options in this series. Authelia fits when the job is “put a login with MFA in front of my apps” and the config should live in one YAML file.

The core setup needs one server and two DNS names: the Authelia portal and the LLDAP admin UI, which becomes the first protected app. The optional sections are independent of each other, so follow only the ones you need.

SectionNeeded forExtra things you need
Core setupEveryoneauth.example.com and lldap.example.com
Protect another appAny web app without its own login, or with header loginA DNS name for that app
Grafana over OpenID ConnectApps that log in with OIDC and map roles from groupsA DNS name for Grafana
Email and password resetSelf-service password resetAn SMTP relay account

What you need

LLDAP decides the CPU, not Authelia. LLDAP stores passwords with the OPAQUE protocol and an Argon2 hash. On the test host (Intel i7-6700) a password sign-in cost about 260 ms of CPU in LLDAP and 15 ms in Authelia, and the full round trip had a median of 0.53 seconds. One core therefore handles about four password logins per second, which covers thousands of users who sign in a few times a day. After the full setup, including Grafana over OIDC, the containers used 82 MiB (Authelia), 63 MiB (PostgreSQL), 27 MiB (Redis) and 2 MiB (LLDAP).

The lab VM had 4 vCPU and 4 GB of RAM, which is a comfortable floor for following along, not a production size. For a team or a homelab, 2 vCPU and 2 GB is plenty for this stack. Add cores only if many people sign in at the same moment, such as at the start of a shift.

  • A Linux host with Docker Engine and the Compose plugin. Ubuntu 26.04 and 24.04 were both tested, and the Docker CE install guide for Ubuntu covers the repository setup.
  • Two DNS A records pointing at the host: auth.example.com for the portal and lldap.example.com for the user admin UI. Both must sit under one parent domain (example.com here), because the session cookie is set on that parent.
  • Inbound TCP 80 and 443 for the HTTP-01 challenge and the sites. A host without inbound port 80 can use DNS-01, shown below.
  • A TOTP app on your phone (Aegis, 2FAS, Google or Microsoft Authenticator, or your password manager). A passkey on your laptop or phone is optional.

Set the shell variables

Five values drive the whole core setup. The cookie domain is the parent of both names. The base DN is the LDAP namespace LLDAP creates for your users, and it does not have to match a domain you own, so dc=example,dc=com is a fine default. The email address is the Let’s Encrypt contact, and it must be a real mailbox, because Let’s Encrypt refuses example.com addresses:

export AUTH_DOMAIN="auth.example.com"
export LLDAP_DOMAIN="lldap.example.com"
export COOKIE_DOMAIN="example.com"
export LDAP_BASE_DN="dc=example,dc=com"
export ADMIN_EMAIL="[email protected]"
echo "Portal ${AUTH_DOMAIN}, LLDAP ${LLDAP_DOMAIN}, cookie ${COOKIE_DOMAIN}, base DN ${LDAP_BASE_DN}"

The variables last for the current shell. If you reconnect later, run this block again and cd /opt/authelia before you continue, because every docker compose command after the secrets step runs from that directory.

Get the Let’s Encrypt certificates

Install Nginx, Certbot with its Nginx plugin, and jq for reading the GitHub API:

sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx jq

certonly --nginx answers the HTTP-01 challenge through the running Nginx without touching its config, and the deploy hook reloads Nginx after every renewal. One certificate per name keeps the paths simple. The Nginx and Let’s Encrypt guide for Ubuntu 26.04 explains Certbot in more depth:

for d in "${AUTH_DOMAIN}" "${LLDAP_DOMAIN}"; do
  sudo certbot certonly --nginx -d "$d" --non-interactive --agree-tos -m "${ADMIN_EMAIL}" \
    --deploy-hook "systemctl reload nginx"
done

Private network or no port 80: use DNS-01

A host on a private LAN or behind NAT proves ownership with a DNS TXT record instead. Certbot has a plugin per DNS provider (python3-certbot-dns-route53, -digitalocean, -google, -rfc2136 and others). The lab used Cloudflare, so substitute the plugin for your provider and credentials file. The token needs Zone DNS Edit, and the prompt keeps it out of your shell history:

sudo apt install -y python3-certbot-dns-cloudflare
read -rs -p "Cloudflare API token: " CF_TOKEN; echo
echo "dns_cloudflare_api_token = ${CF_TOKEN}" | sudo tee /etc/letsencrypt/cloudflare.ini >/dev/null
sudo chmod 600 /etc/letsencrypt/cloudflare.ini
unset CF_TOKEN

Request both certificates over DNS:

for d in "${AUTH_DOMAIN}" "${LLDAP_DOMAIN}"; do
  sudo certbot certonly --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
    --dns-cloudflare-propagation-seconds 30 -d "$d" --non-interactive --agree-tos -m "${ADMIN_EMAIL}" \
    --deploy-hook "systemctl reload nginx"
done

Each run ends with the saved paths:

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/auth.example.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/auth.example.com/privkey.pem

Create the stack directory and the secrets

Everything lives in /opt/authelia: the Compose file, the Authelia config, a database init script, the LLDAP bootstrap file and a secrets directory. Create the layout:

sudo mkdir -p /opt/authelia/{config,initdb,secrets,bootstrap/user-configs}
cd /opt/authelia

Eleven random values protect the stack. The first loop makes the passwords (PostgreSQL superuser, one database role per app, Redis, the LLDAP admin account and the account Authelia binds with). The second makes the signing and encryption keys. The last line builds the LLDAP database URL from its password:

for s in postgres_password authelia_db_password lldap_db_password redis_password lldap_admin_password ldap_bind_password; do
  openssl rand -hex 24 | sudo tee "secrets/$s" >/dev/null
done
for s in authelia_jwt_secret authelia_session_secret authelia_storage_key lldap_jwt_secret lldap_key_seed; do
  openssl rand -hex 32 | sudo tee "secrets/$s" >/dev/null
done
echo "postgres://lldap:$(sudo cat secrets/lldap_db_password)@postgres/lldap" | sudo tee secrets/lldap_database_url >/dev/null
sudo chmod 644 secrets/*
sudo chmod 700 secrets
sudo ls secrets

The files are readable inside the containers, which run as different users, but the directory is closed to everyone on the host except root:

authelia_db_password	 ldap_bind_password    lldap_jwt_secret
authelia_jwt_secret	 lldap_admin_password  lldap_key_seed
authelia_session_secret  lldap_database_url    postgres_password
authelia_storage_key	 lldap_db_password     redis_password

Two of these files cannot be replaced later. authelia_storage_key encrypts every TOTP secret and passkey in the database, and lldap_key_seed derives the key LLDAP uses for every stored password. Lose either one and every user has to enroll again. Copy the whole secrets directory to your password manager or vault now.

Next, pin the releases. Both projects publish on GitHub, so ask for the latest tag instead of copying a version that ages. The Authelia image tags drop the leading v and the LLDAP tags keep it:

AUTHELIA_TAG=$(curl -fsSL https://api.github.com/repos/authelia/authelia/releases/latest | jq -r .tag_name | sed 's/^v//')
LLDAP_TAG=$(curl -fsSL https://api.github.com/repos/lldap/lldap/releases/latest | jq -r .tag_name)
printf '%s\n' "AUTHELIA_TAG=${AUTHELIA_TAG}" "LLDAP_TAG=${LLDAP_TAG}" "LDAP_BASE_DN=${LDAP_BASE_DN}" "LLDAP_DOMAIN=${LLDAP_DOMAIN}" | sudo tee .env >/dev/null
echo "Authelia ${AUTHELIA_TAG}, LLDAP ${LLDAP_TAG}"

The .env file now feeds the Compose file with both releases and your two LLDAP values:

Authelia 4.39.28, LLDAP v0.6.3

Do not use the latest tag of LLDAP. On Docker Hub it is a nightly build of the main branch, while stable and the version tags are releases.

Write the Authelia Docker Compose file

Open the Compose file:

sudo vim /opt/authelia/compose.yaml

Paste the stack. Every secret arrives as a mounted file through a _FILE variable, so docker inspect never shows a password. Only Authelia and the LLDAP web UI publish ports, and both bind to 127.0.0.1. The LLDAP health check runs every 2 seconds while the container starts. With the default 30 second interval, Authelia waited 32 seconds for LLDAP during an upgrade; with this one it was back in 4:

name: authelia

services:
  postgres:
    image: postgres:18-alpine
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    volumes:
      - ./data/postgres:/var/lib/postgresql
      - ./initdb:/docker-entrypoint-initdb.d:ro
      - ./secrets/postgres_password:/run/secrets/postgres_password:ro
      - ./secrets/authelia_db_password:/run/secrets/authelia_db_password:ro
      - ./secrets/lldap_db_password:/run/secrets/lldap_db_password:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:8-alpine
    restart: unless-stopped
    command: ["sh", "-c", "exec redis-server --requirepass \"$$(cat /run/secrets/redis_password)\" --save 60 1 --appendonly no"]
    volumes:
      - ./data/redis:/data
      - ./secrets/redis_password:/run/secrets/redis_password:ro
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a \"$$(cat /run/secrets/redis_password)\" --no-auth-warning ping | grep -q PONG"]
      interval: 10s
      timeout: 5s
      retries: 5

  lldap:
    image: lldap/lldap:${LLDAP_TAG}
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    environment:
      TZ: Etc/UTC
      LLDAP_LDAP_BASE_DN: ${LDAP_BASE_DN}
      LLDAP_HTTP_URL: https://${LLDAP_DOMAIN}
      LLDAP_JWT_SECRET_FILE: /run/secrets/lldap_jwt_secret
      LLDAP_KEY_SEED_FILE: /run/secrets/lldap_key_seed
      LLDAP_LDAP_USER_PASS_FILE: /run/secrets/lldap_admin_password
      LLDAP_DATABASE_URL_FILE: /run/secrets/lldap_database_url
    volumes:
      - ./data/lldap:/data
      - ./secrets/lldap_jwt_secret:/run/secrets/lldap_jwt_secret:ro
      - ./secrets/lldap_key_seed:/run/secrets/lldap_key_seed:ro
      - ./secrets/lldap_admin_password:/run/secrets/lldap_admin_password:ro
      - ./secrets/lldap_database_url:/run/secrets/lldap_database_url:ro
      - ./secrets/ldap_bind_password:/run/secrets/ldap_bind_password:ro
      - ./bootstrap:/bootstrap:ro
    ports:
      - "127.0.0.1:17170:17170"
    healthcheck:
      test: ["CMD", "/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]
      interval: 30s
      timeout: 5s
      start_period: 30s
      start_interval: 2s

  authelia:
    image: authelia/authelia:${AUTHELIA_TAG}
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      lldap:
        condition: service_healthy
    environment:
      TZ: Etc/UTC
      X_AUTHELIA_CONFIG_FILTERS: template
      AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE: /run/secrets/authelia_jwt_secret
      AUTHELIA_SESSION_SECRET_FILE: /run/secrets/authelia_session_secret
      AUTHELIA_SESSION_REDIS_PASSWORD_FILE: /run/secrets/redis_password
      AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE: /run/secrets/authelia_storage_key
      AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE: /run/secrets/authelia_db_password
      AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE: /run/secrets/ldap_bind_password
    volumes:
      - ./config:/config
      - ./secrets/authelia_jwt_secret:/run/secrets/authelia_jwt_secret:ro
      - ./secrets/authelia_session_secret:/run/secrets/authelia_session_secret:ro
      - ./secrets/authelia_storage_key:/run/secrets/authelia_storage_key:ro
      - ./secrets/authelia_db_password:/run/secrets/authelia_db_password:ro
      - ./secrets/redis_password:/run/secrets/redis_password:ro
      - ./secrets/ldap_bind_password:/run/secrets/ldap_bind_password:ro
    ports:
      - "127.0.0.1:9091:9091"

One PostgreSQL instance serves both apps, each with its own role and database, so neither app holds the superuser password. The official image runs scripts from /docker-entrypoint-initdb.d on the very first start. Create that script:

sudo vim /opt/authelia/initdb/01-roles.sh

It reads the two role passwords from the mounted secret files:

#!/bin/sh
set -eu
printf '%s\n' \
  "CREATE ROLE authelia LOGIN PASSWORD :'authelia_pw';" \
  "CREATE DATABASE authelia OWNER authelia;" \
  "CREATE ROLE lldap LOGIN PASSWORD :'lldap_pw';" \
  "CREATE DATABASE lldap OWNER lldap;" |
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres \
  -v authelia_pw="$(cat /run/secrets/authelia_db_password)" \
  -v lldap_pw="$(cat /run/secrets/lldap_db_password)"

Configure Authelia

Open the configuration file:

sudo vim /opt/authelia/config/configuration.yml

This is the whole core configuration. A few choices in it matter more than the rest. default_policy: 'deny' means a site with no matching rule is refused even after a good login. The only rule lets members of the built-in LLDAP group lldap_admin reach the LLDAP UI with two factors. Password reset stays off until email exists (the optional email section turns it on), so the portal never shows a reset link that cannot send anything. Password change in the portal settings is on, but it only works for normal users: the bind account can change their passwords, not those of lldap_admin members. Admins change their own password in the LLDAP UI. The filesystem notifier writes one-time codes to a file you read over SSH, which is how the core setup works without SMTP. Regulation bans a user and the source IP for 15 minutes after 5 failed passwords in 5 minutes:

theme: 'auto'

server:
  address: 'tcp://:9091/'

log:
  level: 'info'

totp:
  issuer: 'AUTH_DOMAIN_HERE'

webauthn:
  display_name: 'Authelia'
  enable_passkey_login: true

identity_validation:
  reset_password: {}

authentication_backend:
  refresh_interval: '1m'
  password_reset:
    disable: true
  password_change:
    disable: false
  ldap:
    implementation: 'lldap'
    address: 'ldap://lldap:3890'
    base_dn: 'LDAP_BASE_DN_HERE'
    user: 'uid=authelia,ou=people,LDAP_BASE_DN_HERE'

access_control:
  default_policy: 'deny'
  rules:
    - domain: 'LLDAP_DOMAIN_HERE'
      subject: 'group:lldap_admin'
      policy: 'two_factor'

session:
  cookies:
    - domain: 'COOKIE_DOMAIN_HERE'
      authelia_url: 'https://AUTH_DOMAIN_HERE'
      inactivity: '30 minutes'
      expiration: '8 hours'
      remember_me: '2 weeks'
  redis:
    host: 'redis'
    port: 6379

regulation:
  modes:
    - 'user'
    - 'ip'
  max_retries: 5
  find_time: '5 minutes'
  ban_time: '15 minutes'

storage:
  postgres:
    address: 'tcp://postgres:5432'
    database: 'authelia'
    username: 'authelia'

notifier:
  filesystem:
    filename: '/config/notification.txt'

The upper-case values are placeholders. Replace them with your variables:

sudo sed -i -e "s/AUTH_DOMAIN_HERE/${AUTH_DOMAIN}/g" -e "s/LLDAP_DOMAIN_HERE/${LLDAP_DOMAIN}/g" \
  -e "s/COOKIE_DOMAIN_HERE/${COOKIE_DOMAIN}/g" -e "s/LDAP_BASE_DN_HERE/${LDAP_BASE_DN}/g" \
  /opt/authelia/config/configuration.yml
sudo grep -nE "_HERE|${COOKIE_DOMAIN}|${LDAP_BASE_DN}" /opt/authelia/config/configuration.yml

No line with _HERE may remain. The lines left show your own names:

10:  issuer: 'auth.example.com'
28:    base_dn: 'dc=example,dc=com'
29:    user: 'uid=authelia,ou=people,dc=example,dc=com'
34:    - domain: 'lldap.example.com'
40:    - domain: 'example.com'
41:      authelia_url: 'https://auth.example.com'

Check the file with the validator built into Authelia before anything starts. It loads the secrets through the same _FILE variables the service uses:

sudo docker compose run --rm --no-deps authelia authelia config validate --config /config/configuration.yml

A clean file prints one line:

Configuration parsed and loaded successfully without errors.

Start LLDAP and create the bind account

Authelia refuses to start until it can bind to LLDAP, so the directory comes first. Pull the images and start the three backing services:

sudo docker compose pull
sudo docker compose up -d --wait postgres redis lldap
sudo docker compose ps --format "table {{.Service}}\t{{.Status}}"

With --wait, Compose returns only when all three report healthy, which took about 13 seconds:

SERVICE    STATUS
lldap      Up 2 seconds (healthy)
postgres   Up 13 seconds (healthy)
redis      Up 13 seconds (healthy)

The PostgreSQL log of a first start looks alarming. Check it:

sudo docker compose logs postgres | grep -E "CREATE|ERROR"

The four CREATE lines come from the init script. All five ERROR lines that follow are LLDAP probing the empty database before it creates its schema, and they appear on every fresh install:

postgres-1  | CREATE ROLE
postgres-1  | CREATE DATABASE
postgres-1  | CREATE ROLE
postgres-1  | CREATE DATABASE
postgres-1  | 2026-10-06 19:28:27.947 UTC [82] ERROR:  relation "metadata" does not exist at character 23
postgres-1  | 2026-10-06 19:28:27.950 UTC [83] ERROR:  syntax error at or near "PRAGMA" at character 1
postgres-1  | 2026-10-06 19:28:27.962 UTC [82] ERROR:  column "creation_date" of relation "groups" already exists
postgres-1  | 2026-10-06 19:28:27.963 UTC [83] ERROR:  column "uuid" of relation "groups" already exists
postgres-1  | 2026-10-06 19:28:27.963 UTC [82] ERROR:  column "uuid" of relation "users" already exists

LLDAP creates its admin user on the first start with the password from secrets/lldap_admin_password. Do not sign Authelia in as that account. The account Authelia binds with goes into the lldap_password_manager group, which can change the passwords of normal users (needed for password reset) but not of admins. LLDAP ships a bootstrap script for this. Create its user file:

sudo vim /opt/authelia/bootstrap/user-configs/authelia.json

The password comes from the same secret file Authelia reads, so the two can never drift apart:

{
  "id": "authelia",
  "email": "[email protected]",
  "displayName": "Authelia bind account",
  "password_file": "/run/secrets/ldap_bind_password",
  "groups": ["lldap_password_manager"]
}

Run the bootstrap script inside the LLDAP container:

sudo docker compose exec -e LLDAP_ADMIN_PASSWORD_FILE=/run/secrets/lldap_admin_password lldap /app/bootstrap.sh

The account is created, gets its password and joins the group:

--- authelia ---
User "authelia" successfully created
Successfully changed authelia's password
--- Processing custom attributes for user authelia ---
No custom attributes found for user "authelia"
User "authelia" successfully added to the group "lldap_password_manager" (2)
--- authelia ---

The script is safe to run again. It resets the password from the file and leaves anything else in LLDAP alone, which makes it the fix if the bind password and the account ever disagree.

Start Authelia

Start the last service and read its log:

sudo docker compose up -d --wait authelia
sudo docker compose logs authelia | grep -E "migration|Startup"

Authelia creates its schema in PostgreSQL, binds to LLDAP as part of its startup checks, and reports ready:

authelia-1  | time="2026-10-06T19:28:32Z" level=info msg="Storage schema migration from 0 to 29 is being attempted"
authelia-1  | time="2026-10-06T19:28:32Z" level=info msg="Storage schema migration from 0 to 29 is complete"
authelia-1  | time="2026-10-06T19:28:32Z" level=info msg="Startup complete"

If it restarts in a loop instead, the log names the failing check. The most common one is covered in the errors section at the end.

Put Nginx in front

The Authelia documentation ships three Nginx snippets, and the vhosts below include them. Keep them in one directory:

sudo mkdir -p /etc/nginx/snippets/authelia
sudo vim /etc/nginx/snippets/authelia/proxy.conf

The first sets the forwarded headers and proxy timeouts. It is the official file unchanged:

## Headers
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-URI $request_uri;
proxy_set_header X-Forwarded-Ssl on;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;

## Basic Proxy Configuration
client_body_buffer_size 128k;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; ## Timeout if the real server is dead.
proxy_redirect  http://  $scheme://;
proxy_http_version 1.1;
proxy_cache_bypass $cookie_session;
proxy_no_cache $cookie_session;
proxy_buffers 64 256k;

## Trusted Proxies Configuration
## Please read the following documentation before configuring this:
##     https://www.authelia.com/integration/proxies/nginx/#trusted-proxies-and-integration-security
# set_real_ip_from 10.0.0.0/8;
# set_real_ip_from 172.16.0.0/12;
# set_real_ip_from 192.168.0.0/16;
# set_real_ip_from fc00::/7;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

## Advanced Proxy Configuration
send_timeout 5m;
proxy_read_timeout 360;
proxy_send_timeout 360;
proxy_connect_timeout 360;

The second defines the internal location that Nginx calls for each check:

sudo vim /etc/nginx/snippets/authelia/authelia-location.conf

The only change from the official file is the upstream on the first line. Authelia runs in Docker, but Nginx runs on the host, so it reaches Authelia on 127.0.0.1:9091 instead of the container name:

set $upstream_authelia http://127.0.0.1:9091/api/authz/auth-request;

## Virtual endpoint created by nginx to forward auth requests.
location /internal/authelia/authz {
    ## Essential Proxy Configuration
    internal;
    proxy_pass $upstream_authelia;

    ## Headers
    ## The headers starting with X-* are required.
    proxy_set_header X-Original-Method $request_method;
    proxy_set_header X-Original-URL $scheme://$host$request_uri;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header Content-Length "";
    proxy_set_header Connection "";

    ## Basic Proxy Configuration
    proxy_pass_request_body off;
    proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; # Timeout if the real server is dead
    proxy_redirect http:// $scheme://;
    proxy_http_version 1.1;
    proxy_cache_bypass $cookie_session;
    proxy_no_cache $cookie_session;
    proxy_buffers 4 32k;
    client_body_buffer_size 128k;

    ## Advanced Proxy Configuration
    send_timeout 5m;
    proxy_read_timeout 240;
    proxy_send_timeout 240;
    proxy_connect_timeout 240;
}

The third goes into every protected location. It sends the subrequest, copies the user, groups, name and email from the Authelia answer into headers for the app, and turns a 401 into a redirect to the portal:

sudo vim /etc/nginx/snippets/authelia/authelia-authrequest.conf

Paste it unchanged:

## Send a subrequest to Authelia to verify if the user is authenticated and has permission to access the resource.
auth_request /internal/authelia/authz;

## Save the upstream metadata response headers from Authelia to variables.
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email;

## Inject the metadata response headers from the variables into the request made to the backend.
proxy_set_header Remote-User $user;
proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Email $email;
proxy_set_header Remote-Name $name;

## Configure the redirection when the authz failure occurs. Lines starting with 'Modern Method' and 'Legacy Method'
## should be commented / uncommented as pairs. The modern method uses the session cookies configuration's authelia_url
## value to determine the redirection URL here. It's much simpler and compatible with the multi-cookie domain easily.

## Modern Method: Set the $redirection_url to the Location header of the response to the Authz endpoint.
auth_request_set $redirection_url $upstream_http_location;

## Modern Method: When there is a 401 response code from the authz endpoint redirect to the $redirection_url.
error_page 401 =302 $redirection_url;

## Legacy Method: Set $target_url to the original requested URL.
## This requires http_set_misc module, replace 'set_escape_uri' with 'set' if you don't have this module.
# set_escape_uri $target_url $scheme://$host$request_uri;

## Legacy Method: When there is a 401 response code from the authz endpoint redirect to the portal with the 'rd'
## URL parameter set to $target_url. This requires users update 'auth.example.com/' with their external authelia URL.
# error_page 401 =302 https://auth.example.com/?rd=$target_url;

The portal itself is a plain reverse proxy. Create its vhost:

sudo vim /etc/nginx/sites-available/auth.conf

It redirects HTTP to HTTPS and proxies everything to Authelia:

server {
    listen 80;
    listen [::]:80;
    server_name AUTH_DOMAIN_HERE;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name AUTH_DOMAIN_HERE;

    ssl_certificate /etc/letsencrypt/live/AUTH_DOMAIN_HERE/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/AUTH_DOMAIN_HERE/privkey.pem;

    set $upstream http://127.0.0.1:9091;

    location / {
        include /etc/nginx/snippets/authelia/proxy.conf;
        proxy_pass $upstream;
    }
}

The LLDAP vhost is the pattern for any protected app: authelia-location.conf at server level, proxy.conf and authelia-authrequest.conf inside each location that needs a login. Create it:

sudo vim /etc/nginx/sites-available/lldap.conf

The two includes are the only difference from the portal vhost:

server {
    listen 80;
    listen [::]:80;
    server_name LLDAP_DOMAIN_HERE;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name LLDAP_DOMAIN_HERE;

    ssl_certificate /etc/letsencrypt/live/LLDAP_DOMAIN_HERE/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/LLDAP_DOMAIN_HERE/privkey.pem;

    include /etc/nginx/snippets/authelia/authelia-location.conf;

    set $upstream http://127.0.0.1:17170;

    location / {
        include /etc/nginx/snippets/authelia/proxy.conf;
        include /etc/nginx/snippets/authelia/authelia-authrequest.conf;
        proxy_pass $upstream;
    }
}

Fill in the names, enable both sites, drop the default site and reload:

sudo sed -i "s/AUTH_DOMAIN_HERE/${AUTH_DOMAIN}/g" /etc/nginx/sites-available/auth.conf
sudo sed -i "s/LLDAP_DOMAIN_HERE/${LLDAP_DOMAIN}/g" /etc/nginx/sites-available/lldap.conf
sudo ln -sf /etc/nginx/sites-available/auth.conf /etc/nginx/sites-available/lldap.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default

The http2 on; directive needs Nginx 1.25.1 or newer. Ubuntu 26.04 ships 1.28, but Ubuntu 24.04 ships 1.24, which stops with unknown directive "http2". This command rewrites the files to the older listen ... http2 form only when the installed Nginx is 1.24 or older, and does nothing on 26.04:

nginx -v 2>&1 | grep -qE 'nginx/1\.(1[0-9]|2[0-4])\.' && sudo sed -i -e 's/listen 443 ssl;/listen 443 ssl http2;/' \
  -e 's/listen \[::\]:443 ssl;/listen [::]:443 ssl http2;/' -e '/^    http2 on;$/d' \
  /etc/nginx/sites-available/auth.conf /etc/nginx/sites-available/lldap.conf
sudo nginx -t && sudo systemctl reload nginx

Test both names. The portal answers its health check, and LLDAP sends an anonymous visitor to the portal instead of showing its own page:

curl -s "https://${AUTH_DOMAIN}/api/health"; echo
curl -sI "https://${LLDAP_DOMAIN}/" | grep -iE '^(HTTP|location)'

The 302 carries the original URL in rd, so the portal can send the user back after login:

{"status":"OK"}
HTTP/2 302
location: https://auth.example.com/?rd=https%3A%2F%2Flldap.example.com%2F&rm=HEAD

Open the firewall

Only SSH, HTTP and HTTPS need to be reachable. Allow SSH before enabling UFW, or you lock yourself out of a remote host:

sudo ufw allow OpenSSH
sudo ufw allow "Nginx Full"
sudo ufw --force enable
sudo ufw status

UFW does not filter ports that Docker publishes, because Docker writes its own iptables rules. That is why the Compose file binds Authelia and LLDAP to 127.0.0.1: from another machine on the network, 9091, 17170 and the LDAP port 3890 were all closed, and only 443 answered.

Sign in and register the first TOTP device

Open https://lldap.example.com in a browser. Authelia shows its sign-in page. The first account that exists is the LLDAP admin, whose password is in the secrets directory:

sudo cat /opt/authelia/secrets/lldap_admin_password

Sign in as admin with that password. The page also offers a passkey login, which comes later in this guide:

Authelia sign-in portal with username and password fields, Remember me and a Sign in with a passkey button, backed by LLDAP

The LLDAP rule asks for two factors, and the admin has none yet, so Authelia offers to register a device:

Authelia page saying the resource requires two-factor authentication with a Register device link

Select Register device, then ADD next to One-Time Password. Authelia first confirms that the request comes from the account owner and sends a one-time code. With the filesystem notifier, the “email” is a file on the server:

Authelia identity verification dialog asking for the one-time code that was sent by the notifier

Read the code over SSH:

sudo grep -E '^[A-Z0-9]{8}$' /opt/authelia/config/notification.txt | tail -n 1

The file holds the full message, and the code is the line on its own:

PRQYUNRB

Enter the code and select VERIFY. Selecting CANCEL invalidates it, and the dialog only allows five codes per IP address in ten minutes, so do not open and close it repeatedly. Next scan the QR code with your authenticator app:

Authelia Register One-Time Password dialog on the register step with a blurred QR code for the authenticator app

Confirm with the six-digit code from the app. Authelia then completes the redirect and LLDAP shows its own login page, because LLDAP has its own session as well. Sign in again as admin with the same password:

LLDAP login page reached through Authelia after the second factor

If the admin loses the authenticator

The admin account now needs the phone to reach LLDAP. If the phone is lost, delete the TOTP registration from the server. Authelia then offers to register a new device at the next login:

cd /opt/authelia
sudo docker compose exec authelia authelia storage user totp delete admin --config /config/configuration.yml

The command needs no running browser session and confirms the deletion:

Successfully deleted TOTP configuration for user 'admin'

Passkeys work the same way with authelia storage user webauthn list and delete.

Create your own account in LLDAP

Keep admin as the break-glass account and give yourself a personal one. In LLDAP, select Create a user. Fill in the user name, display name, email and a password. The email is not optional in practice: Authelia sends security alerts and password reset links to it, and for an account without one it logs no email address was found for user:

LLDAP create user form with user name jane, display name Jane Doe, email jane@example.com and password fields

Open the new user and add it to lldap_admin under Group memberships. That group is what the Authelia rule checks:

LLDAP user page for jane with lldap_admin in the group memberships and the Add to group button

While you are in LLDAP, open Users > admin and give the break-glass account an email address too. The user list then looks like this, with the authelia bind account that the bootstrap script made:

LLDAP users list showing admin, authelia, bob and jane with their email addresses and display names

Sign out of both (the Authelia portal has a LOGOUT link) and sign in to https://lldap.example.com as your own user. Register a TOTP device the same way as before. Every other user of the system follows the same steps, and with the filesystem notifier an administrator has to read the one-time code from the server for them. That is fine for a homelab and a handful of admins. For more users, set up the optional email section.

Add a passkey

With TOTP in place, open the portal at https://auth.example.com and go to Settings > Two-Factor Authentication. Under WebAuthn Credentials select ADD, confirm with a one-time code, give the credential a name such as “Jane laptop”, and accept the browser prompt from your platform authenticator or security key:

Authelia Register WebAuthn Credential dialog with the description Jane laptop

Both factors now show on the settings page:

Authelia two-factor settings page with a One-Time Password and a WebAuthn credential named Jane laptop registered

The Sign in with a passkey button on the login page now works for that user without typing a user name. Authelia counts a passkey as one factor, so for a two_factor site like LLDAP it then asks for the password as well:

Authelia asking Jane Doe for her password after a passkey sign-in because the site requires two factors

The documented switch that lets a user-verifying passkey satisfy both factors (experimental_enable_passkey_uv_two_factors) is marked experimental and may be replaced, so this guide leaves it off. A passkey still beats TOTP as the second factor, because it cannot be phished.

The core setup is done. Authelia protects the LLDAP UI with two factors, users and groups live in LLDAP, sessions survive restarts, and nothing but Nginx and SSH listens on the network. The next three sections are optional and independent. Backup, upgrade and the error list after them apply to everyone.

Optional: protect another app with forward auth

Any web app behind Nginx can sit behind the same login, including apps that have no authentication of their own. This section uses traefik/whoami, a tiny container that prints the request headers it receives, so you can see what Authelia passes to an app. You need a DNS record for the app name. Set it first:

export APP_DOMAIN="whoami.example.com"

Get a certificate for it the same way as for the core names. On a host without port 80, use the DNS-01 command from the certificate step with -d "${APP_DOMAIN}" instead of the loop:

sudo certbot certonly --nginx -d "${APP_DOMAIN}" --non-interactive --agree-tos -m "${ADMIN_EMAIL}" \
  --deploy-hook "systemctl reload nginx"

Create a Compose file for the app:

sudo mkdir -p /opt/whoami
sudo vim /opt/whoami/compose.yaml

The app listens on localhost only, like everything else:

services:
  whoami:
    image: traefik/whoami:latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:8081:80"

Start it:

cd /opt/whoami && sudo docker compose up -d

The vhost is the LLDAP pattern with a different upstream:

sudo vim /etc/nginx/sites-available/app.conf

Only the upstream port and the name change:

server {
    listen 80;
    listen [::]:80;
    server_name APP_DOMAIN_HERE;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name APP_DOMAIN_HERE;

    ssl_certificate /etc/letsencrypt/live/APP_DOMAIN_HERE/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/APP_DOMAIN_HERE/privkey.pem;

    include /etc/nginx/snippets/authelia/authelia-location.conf;

    set $upstream http://127.0.0.1:8081;

    location / {
        include /etc/nginx/snippets/authelia/proxy.conf;
        include /etc/nginx/snippets/authelia/authelia-authrequest.conf;
        proxy_pass $upstream;
    }
}

Enable it. The nginx -v line is the same Nginx 1.24 conversion as in the core setup, and it does nothing on Ubuntu 26.04:

sudo sed -i "s/APP_DOMAIN_HERE/${APP_DOMAIN}/g" /etc/nginx/sites-available/app.conf
sudo ln -sf /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
nginx -v 2>&1 | grep -qE 'nginx/1\.(1[0-9]|2[0-4])\.' && sudo sed -i -e 's/listen 443 ssl;/listen 443 ssl http2;/' \
  -e 's/listen \[::\]:443 ssl;/listen [::]:443 ssl http2;/' -e '/^    http2 on;$/d' /etc/nginx/sites-available/app.conf
sudo nginx -t && sudo systemctl reload nginx

At this point a user can sign in, and Nginx still answers 403 Forbidden. No rule matches the new name, so default_policy: 'deny' applies. Access is granted by group. In LLDAP, open Groups > Create a group, name it app_users and add the people who may use the app:

LLDAP group app_users with Bob Smith as a member and the Add to group control

Then add a rule for the app. Open the Authelia config:

sudo vim /opt/authelia/config/configuration.yml

Add these lines under rules:, after the LLDAP rule. one_factor means a password is enough for this app. Use two_factor for anything sensitive:

    - domain: 'APP_DOMAIN_HERE'
      subject: 'group:app_users'
      policy: 'one_factor'

Replace the placeholder, validate the file and restart Authelia. A bad indent under rules: would put Authelia into a restart loop, which the validator catches first. A restart is required, because docker compose up -d does not notice an edited config file. Sessions are in Redis, so nobody is logged out:

sudo sed -i "s/APP_DOMAIN_HERE/${APP_DOMAIN}/g" /opt/authelia/config/configuration.yml
cd /opt/authelia
sudo docker compose run --rm --no-deps authelia authelia config validate --config /config/configuration.yml
sudo docker compose restart authelia

Sign in at https://whoami.example.com as a member of app_users. The page lists the headers Nginx added from the Authelia answer:

Host: whoami.example.com
Remote-Email: [email protected]
Remote-Groups: app_users
Remote-Name: Bob Smith
Remote-User: bob

Apps that support header authentication can log users in from Remote-User. Only trust those headers when the app is reachable through Nginx alone, which the 127.0.0.1 port binding guarantees here.

We also checked what Redis buys. With Redis, the browser session of Bob stayed valid across an Authelia restart. With the redis block removed from the config, the same request after a restart returned a 302 back to the portal, so everyone had to sign in again.

Optional: Grafana sign-in over OpenID Connect

Authelia is also an OpenID Connect provider. Apps that support OIDC get a real login button, with roles mapped from LLDAP groups. Grafana is the example here, running on the same host. The Grafana install guide for Ubuntu 26.04 covers a package install if you prefer that over a container. Set the name first:

export GRAFANA_DOMAIN="grafana.example.com"

Request its certificate:

sudo certbot certonly --nginx -d "${GRAFANA_DOMAIN}" --non-interactive --agree-tos -m "${ADMIN_EMAIL}" \
  --deploy-hook "systemctl reload nginx"

The provider needs an RSA key for signing tokens and an HMAC secret. Both go into config/secrets, which Authelia reads through its template filter (already enabled in the Compose file):

cd /opt/authelia
sudo install -d -m 700 config/secrets
sudo docker compose run --rm --no-deps -v /opt/authelia/config/secrets:/keys authelia \
  authelia crypto pair rsa generate --directory /keys
sudo mv config/secrets/private.pem config/secrets/oidc_private.pem
sudo rm config/secrets/public.pem
openssl rand -hex 32 | sudo tee config/secrets/oidc_hmac_secret >/dev/null

Grafana authenticates to Authelia with a client secret. Authelia stores only a hash of it. Generate both:

sudo docker compose run --rm --no-deps authelia authelia crypto hash generate pbkdf2 \
  --variant sha512 --random --random.length 72 --random.charset rfc3986

The random password goes to Grafana and the digest goes to Authelia. Copy both somewhere safe for the next two steps:

Random Password: (72 characters, shown once)
Digest: $pbkdf2-sha512$310000$...

Open the Authelia config:

sudo vim /opt/authelia/config/configuration.yml

Append this block at the end of the file. The claims_policies part is not in the Grafana example from the Authelia docs, and without it every user lands in Grafana as Viewer (the errors section shows why):


identity_providers:
  oidc:
    hmac_secret: {{ secret "/config/secrets/oidc_hmac_secret" | msquote }}
    jwks:
      - key_id: 'main'
        algorithm: 'RS256'
        use: 'sig'
        key: {{ secret "/config/secrets/oidc_private.pem" | mindent 10 "|" | msquote }}
    claims_policies:
      grafana:
        id_token: ['groups', 'email', 'email_verified', 'preferred_username', 'name']
    clients:
      - client_id: 'grafana'
        client_name: 'Grafana'
        client_secret: 'GRAFANA_SECRET_DIGEST_HERE'
        public: false
        claims_policy: 'grafana'
        authorization_policy: 'two_factor'
        require_pkce: true
        pkce_challenge_method: 'S256'
        redirect_uris:
          - 'https://GRAFANA_DOMAIN_HERE/login/generic_oauth'
        scopes:
          - 'openid'
          - 'profile'
          - 'groups'
          - 'email'
        response_types:
          - 'code'
        grant_types:
          - 'authorization_code'
        access_token_signed_response_alg: 'none'
        userinfo_signed_response_alg: 'none'
        token_endpoint_auth_method: 'client_secret_basic'

Put the digest and the Grafana name into the block. The digest contains $ and / characters, so this replacement uses a | delimiter:

read -r -p "Digest: " GRAFANA_DIGEST
sudo sed -i -e "s|GRAFANA_DOMAIN_HERE|${GRAFANA_DOMAIN}|" -e "s|GRAFANA_SECRET_DIGEST_HERE|${GRAFANA_DIGEST}|" config/configuration.yml
sudo docker compose run --rm --no-deps authelia authelia config validate --config /config/configuration.yml
sudo docker compose restart authelia
until curl -sf -o /dev/null "https://${AUTH_DOMAIN}/api/health"; do sleep 1; done
curl -s "https://${AUTH_DOMAIN}/.well-known/openid-configuration" | jq -r .issuer

Nginx answers 502 for about a second while Authelia restarts, hence the wait loop. The discovery document then names your portal as the issuer:

https://auth.example.com

Grafana runs from its own directory. Its .env holds the names and the plain client secret:

sudo mkdir -p /opt/grafana && cd /opt/grafana
read -rs -p "Random Password: " GRAFANA_OIDC_SECRET; echo
printf '%s\n' "GRAFANA_DOMAIN=${GRAFANA_DOMAIN}" "AUTH_DOMAIN=${AUTH_DOMAIN}" "GRAFANA_OIDC_SECRET=${GRAFANA_OIDC_SECRET}" | sudo tee .env >/dev/null
sudo chmod 600 .env
unset GRAFANA_OIDC_SECRET
sudo vim /opt/grafana/compose.yaml

The role expression gives members of grafana_admins the Admin role, grafana_editors the Editor role and everyone else Viewer:

services:
  grafana:
    image: grafana/grafana:latest
    restart: unless-stopped
    environment:
      GF_SERVER_ROOT_URL: https://${GRAFANA_DOMAIN}
      GF_AUTH_GENERIC_OAUTH_ENABLED: "true"
      GF_AUTH_GENERIC_OAUTH_NAME: Authelia
      GF_AUTH_GENERIC_OAUTH_ICON: signin
      GF_AUTH_GENERIC_OAUTH_CLIENT_ID: grafana
      GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ${GRAFANA_OIDC_SECRET}
      GF_AUTH_GENERIC_OAUTH_SCOPES: openid profile email groups
      GF_AUTH_GENERIC_OAUTH_EMPTY_SCOPES: "false"
      GF_AUTH_GENERIC_OAUTH_AUTH_URL: https://${AUTH_DOMAIN}/api/oidc/authorization
      GF_AUTH_GENERIC_OAUTH_TOKEN_URL: https://${AUTH_DOMAIN}/api/oidc/token
      GF_AUTH_GENERIC_OAUTH_API_URL: https://${AUTH_DOMAIN}/api/oidc/userinfo
      GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH: preferred_username
      GF_AUTH_GENERIC_OAUTH_GROUPS_ATTRIBUTE_PATH: groups
      GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH: name
      GF_AUTH_GENERIC_OAUTH_USE_PKCE: "true"
      GF_AUTH_GENERIC_OAUTH_AUTH_STYLE: InHeader
      GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH: contains(groups[*], 'grafana_admins') && 'Admin' || contains(groups[*], 'grafana_editors') && 'Editor' || 'Viewer'
      GF_AUTH_GENERIC_OAUTH_ALLOW_ASSIGN_GRAFANA_ADMIN: "false"
    volumes:
      - grafana-data:/var/lib/grafana
    ports:
      - "127.0.0.1:3000:3000"

volumes:
  grafana-data:

Start Grafana and create its vhost:

sudo docker compose up -d
sudo vim /etc/nginx/sites-available/grafana.conf

Grafana does its own login, so this vhost has no auth_request. The second location carries the live-update websocket:

server {
    listen 80;
    listen [::]:80;
    server_name GRAFANA_DOMAIN_HERE;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name GRAFANA_DOMAIN_HERE;

    ssl_certificate /etc/letsencrypt/live/GRAFANA_DOMAIN_HERE/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/GRAFANA_DOMAIN_HERE/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location /api/live/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

Enable it, with the same Nginx 1.24 conversion:

sudo sed -i "s/GRAFANA_DOMAIN_HERE/${GRAFANA_DOMAIN}/g" /etc/nginx/sites-available/grafana.conf
sudo ln -sf /etc/nginx/sites-available/grafana.conf /etc/nginx/sites-enabled/
nginx -v 2>&1 | grep -qE 'nginx/1\.(1[0-9]|2[0-4])\.' && sudo sed -i -e 's/listen 443 ssl;/listen 443 ssl http2;/' \
  -e 's/listen \[::\]:443 ssl;/listen [::]:443 ssl http2;/' -e '/^    http2 on;$/d' /etc/nginx/sites-available/grafana.conf
sudo nginx -t && sudo systemctl reload nginx

In LLDAP, create a group named grafana_admins and add the people who should administer Grafana:

LLDAP group grafana_admins with Jane Doe as a member

Open https://grafana.example.com. The login page has a new button:

Grafana login page with a Sign in with Authelia button

The client uses authorization_policy: 'two_factor', so Authelia asks for the password and the second factor, then for consent on the first login:

Authelia consent page for the Grafana client requesting openid, profile, email and groups scopes

Jane, a member of grafana_admins, lands as Admin. Bob, who is not, landed as Viewer:

Grafana profile page for Jane Doe showing the Admin role in Main Org, assigned from the Authelia groups claim

Optional: email and password reset

With SMTP, Authelia mails one-time codes and alerts straight to users, and the portal can offer self-service password reset. LLDAP stores the new password, which works because the bind account is in lldap_password_manager. You need an SMTP relay account that accepts submission with STARTTLS on port 587 (your mail provider, Amazon SES, Postmark, Mailgun or similar). Set its values:

export SMTP_HOST="smtp.example.com"
export SMTP_USER="[email protected]"
export SMTP_FROM="[email protected]"

Store the relay password as a secret file:

cd /opt/authelia
sudo install -d -m 700 config/secrets
read -rs -p "SMTP password: " SMTP_PASSWORD; echo
printf '%s' "${SMTP_PASSWORD}" | sudo tee config/secrets/smtp_password >/dev/null
sudo chmod 600 config/secrets/smtp_password
unset SMTP_PASSWORD

Open the Authelia config:

sudo vim /opt/authelia/config/configuration.yml

Replace the whole notifier: block (the three lines with filesystem) with this one. Authelia accepts one notifier at a time:

notifier:
  smtp:
    address: 'submission://SMTP_HOST_HERE:587'
    username: 'SMTP_USER_HERE'
    password: {{ secret "/config/secrets/smtp_password" | msquote }}
    sender: 'Authelia <SMTP_FROM_HERE>'

Fill in the values, turn password reset on and restart:

sudo sed -i -e "s/SMTP_HOST_HERE/${SMTP_HOST}/" -e "s/SMTP_USER_HERE/${SMTP_USER}/" -e "s/SMTP_FROM_HERE/${SMTP_FROM}/" config/configuration.yml
sudo sed -i '/password_reset:/{n;s/disable: true/disable: false/}' config/configuration.yml
sudo docker compose restart authelia
timeout 60 sh -c "until curl -sf -o /dev/null https://${AUTH_DOMAIN}/api/health; do sleep 1; done" \
  && echo "Authelia is up" || sudo docker compose logs authelia --tail 20 | grep -E "level=(error|fatal)"

Authelia logs in to the relay during its startup checks and exits if the relay refuses, so Authelia is up means the SMTP login worked. With a wrong password in the lab, the loop timed out after 60 seconds and printed the reason instead:

level=error msg="Error occurred running a startup check" error="failed to dial connection: SMTP AUTH failed: 535 \"5.7.8 Authentication credentials invalid\"" provider=notification

Fix the secret file, then run the restart and the check again. The portal now shows Reset password?. A user enters their user name:

Authelia reset password page with the username field filled in

The mail arrives with the subject “[Authelia] Reset your password” and a link that opens the new password form:

Authelia form to enter and repeat a new password after following the reset link

In the lab, Bob reset his password this way and signed in to the whoami app with the new one straight away. Users without an email in LLDAP cannot reset, so fill in the Mail field for everyone. Members of lldap_admin get the mail too, but the last step fails, as the errors section shows.

Back up and restore

Two databases, the config and the secrets make up the whole state. Redis holds only sessions, and losing them means people sign in again. The script dumps both databases with pg_dump and archives the files:

sudo vim /usr/local/bin/authelia-backup.sh

Add the script:

#!/usr/bin/env bash
set -euo pipefail

STACK=/opt/authelia
DEST=/var/backups/authelia
KEEP_DAYS=14
STAMP=$(date +%Y%m%d-%H%M%S)

install -d -m 700 "$DEST"
cd "$STACK"

for db in authelia lldap; do
  docker compose exec -T postgres pg_dump -U postgres -d "$db" -Fc > "$DEST/$db-db-$STAMP.dump.tmp"
  mv "$DEST/$db-db-$STAMP.dump.tmp" "$DEST/$db-db-$STAMP.dump"
done
tar -czf "$DEST/files-$STAMP.tar.gz" .env compose.yaml config bootstrap initdb secrets
chmod 600 "$DEST"/*-"$STAMP".*

find "$DEST" -type f -mtime +"$KEEP_DAYS" -delete
echo "backup ok: $(du -ch "$DEST"/*-"$STAMP".* | tail -1 | cut -f1) in $DEST"

A oneshot service runs it:

sudo vim /etc/systemd/system/authelia-backup.service

Add the unit:

[Unit]
Description=Authelia and LLDAP database and file backup
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/authelia-backup.sh

The timer runs it nightly and catches up after downtime:

sudo vim /etc/systemd/system/authelia-backup.timer

Add the timer:

[Unit]
Description=Nightly Authelia and LLDAP backup

[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target

Make the script executable, enable the timer and take the first backup now:

sudo chmod 750 /usr/local/bin/authelia-backup.sh
sudo systemctl daemon-reload
sudo systemctl enable --now authelia-backup.timer
sudo systemctl start authelia-backup.service
sudo ls -lh /var/backups/authelia

Three files per run, all readable by root only:

-rw------- 1 root root  97K Oct  6 19:24 authelia-db-20261006-192442.dump
-rw------- 1 root root 5.4K Oct  6 19:24 files-20261006-192442.tar.gz
-rw------- 1 root root  24K Oct  6 19:24 lldap-db-20261006-192442.dump

The archive contains the secrets, so copy the backups off the host to storage that is at least as protected as the server. A backup on the same disk does not survive the disk.

Restore drill

A backup is only as good as a restore. On the new host, install Docker, Nginx and the certificates as in the core setup, copy the three backup files into /var/backups/authelia, and restore the Nginx snippets and vhosts from the old host or your config management. Then unpack the files archive into /opt/authelia and start PostgreSQL alone. The archive holds no data directory, and /opt/authelia/data must not exist before this step, because the init script only runs on an empty data directory. On the old host after a failed attempt, delete it first with sudo docker compose down and sudo rm -rf /opt/authelia/data. Its init script creates the empty roles and databases on this first start, and the image restarts the server once when that is done, so the loop waits for the second “ready to accept connections” before it loads anything. Set STAMP to the backup you are restoring:

STAMP=20261006-192442
B=/var/backups/authelia
sudo mkdir -p /opt/authelia && cd /opt/authelia
sudo tar -xzf "$B/files-$STAMP.tar.gz"
sudo docker compose up -d postgres
timeout 120 sh -c "until [ \$(sudo docker compose logs postgres | grep -c 'ready to accept connections') -ge 2 ]; do sleep 1; done" || echo "PostgreSQL did not finish its first start"
for db in authelia lldap; do
  sudo cat "$B/$db-db-$STAMP.dump" | sudo docker compose exec -T postgres pg_restore -U postgres -d "$db" --clean --if-exists \
    && echo "restored $db" || echo "restore of $db FAILED"
done
sudo docker compose up -d --wait

Both dumps load cleanly:

restored authelia
restored lldap

We ran this block word for word on the 24.04 VM after deleting the whole stack, data directories included. Nineteen seconds later every container was healthy, both TOTP registrations exported, all four LLDAP users were back, and Bob signed in to the whoami app with the password he had set through the reset flow before the backup. That last check proves the key seed and the password hashes came back together. Passkeys live in the same authelia database as TOTP, so they come back the same way.

Upgrade Authelia and LLDAP

Upgrades are a tag change. Take a backup first, then detect the new releases, write them into .env and recreate the containers:

sudo systemctl start authelia-backup.service
cd /opt/authelia
AUTHELIA_TAG=$(curl -fsSL https://api.github.com/repos/authelia/authelia/releases/latest | jq -r .tag_name | sed 's/^v//')
LLDAP_TAG=$(curl -fsSL https://api.github.com/repos/lldap/lldap/releases/latest | jq -r .tag_name)
sudo sed -i -e "s/^AUTHELIA_TAG=.*/AUTHELIA_TAG=${AUTHELIA_TAG}/" -e "s/^LLDAP_TAG=.*/LLDAP_TAG=${LLDAP_TAG}/" .env
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs authelia --since 2m | grep -E "starting|migration"

We tested the jump from Authelia 4.39.15 and LLDAP 0.6.2 to 4.39.28 and 0.6.3 with a seeded TOTP user. Both migrated their schemas on start, and the TOTP secret was still readable afterwards:

authelia-1  | time="2026-10-06T18:08:48Z" level=info msg="Authelia v4.39.28 is starting"
authelia-1  | time="2026-10-06T18:08:48Z" level=info msg="Storage schema migration from 23 to 29 is being attempted"
authelia-1  | time="2026-10-06T18:08:48Z" level=info msg="Storage schema migration from 23 to 29 is complete"

The portal was down for 4 seconds and LLDAP for under 2. With the default LLDAP health check the same upgrade kept Authelia down for 32 seconds, because Compose waits for LLDAP to report healthy before it starts Authelia. Rolling back is not a tag change: Authelia 4.39.21 changed how it encrypts stored secrets, so going below it needs authelia storage migrate down first, and the simplest rollback is restoring the backup you took before the upgrade.

Error messages and how we fixed them

“error occurred performing bind: LDAP Result Code 49 “Invalid Credentials””

Authelia exits right after start and restarts in a loop:

level=error msg="Error occurred running a startup check" error="error occurred performing bind: LDAP Result Code 49 \"Invalid Credentials\": " provider=user
level=fatal msg="One or more providers had fatal failures performing startup checks, for more details check the error level logs" providers="[user]"

The authelia account does not exist in LLDAP yet, or its password differs from secrets/ldap_bind_password. We hit it by starting the whole stack before running the bootstrap script. Run the bootstrap command again, then sudo docker compose restart authelia. Without the restart, the Docker restart back-off can leave Authelia down for another 30 seconds or more.

“Failed to generate the One-Time Code. Please try again later.”

The registration dialog shows this instead of asking for a code, and the Authelia log says Rate Limit Exceeded for /api/user/session/elevation:

Authelia settings page with the error Failed to generate the One-Time Code. Please try again later.

Authelia allows 3 one-time codes per 5 minutes and 5 per 10 minutes from one IP address. Opening and cancelling the dialog a few times uses them up. Wait the time the log prints (delay= in seconds), then request one code and use it.

“Authentication failed. Check your credentials.” with the right password

The user is banned. After 5 failed passwords in 5 minutes, regulation bans both the user name and the source IP for 15 minutes, and during the ban even the correct password gets the same generic message. The log is clear about it:

level=error msg="Unsuccessful 1FA authentication attempt by user 'bob' and they are banned until 6:57:38PM on October 6 2026 (+00:00)"

List and lift bans from the server:

cd /opt/authelia
sudo docker compose exec authelia authelia storage bans user list --config /config/configuration.yml
sudo docker compose exec authelia authelia storage bans user revoke bob --config /config/configuration.yml
sudo docker compose exec authelia authelia storage bans ip list --config /config/configuration.yml

The IP ban hits everyone behind the same address, such as an office NAT. Revoke it by the ID that the list prints:

sudo docker compose exec authelia authelia storage bans ip revoke --id 1 --config /config/configuration.yml

“Insufficient Access Rights: User `authelia` cannot modify the password of user `jane`”

The reset mail arrives and the new password form opens, but saving it fails, and the Authelia log says:

level=error msg="Error occurred updating the user password" error="unable to update password. Cause: LDAP Result Code 50 \"Insufficient Access Rights\": User `authelia` cannot modify the password of user `jane`"

Jane is in lldap_admin. The bind account is in lldap_password_manager, which by design can change the passwords of normal users only. That is the right boundary: a stolen bind password cannot take over an admin account. Admins change their own password in the LLDAP UI, and another admin can set it for them there.

If nobody can sign in to LLDAP any more, reset the break-glass admin account to the password in secrets/lldap_admin_password. The LLDAP config template documents the force_ldap_user_pass_reset switch for this. Set it through a temporary override file and start LLDAP:

cd /opt/authelia
printf '%s\n' 'services:' '  lldap:' '    environment:' '      LLDAP_FORCE_LDAP_USER_PASS_RESET: "true"' | sudo tee compose.override.yaml >/dev/null
sudo docker compose up -d lldap
sleep 10
sudo docker compose logs lldap --since 1m | grep -i "admin password"

The log confirms the reset, once for every restart of the container:

lldap-1  | 2026-10-06T19:34:51.212017140+00:00  WARN     ┕━ Forcing admin password reset to the config-provided password [ 53.2ms | 92.52% ]
lldap-1  | 2026-10-06T19:34:51.523384636+00:00  WARN     ┕━ Forcing admin password reset to the config-provided password [ 52.8ms | 92.88% ]
lldap-1  | 2026-10-06T19:34:51.923343721+00:00  WARN     ┕━ Forcing admin password reset to the config-provided password [ 51.3ms | 92.89% ]

With the switch on, LLDAP resets the password and exits on every start, so the container restarts in a loop. That is expected. Remove the override and start it normally:

sudo rm compose.override.yaml
sudo docker compose up -d --wait lldap
sudo cat secrets/lldap_admin_password

We tested it on a host restored from a backup taken after the admin password had been changed: the admin login with the secret file’s password failed with 401 before these two blocks and succeeded after them.

Every Grafana user gets the Viewer role

Group membership is right in LLDAP and the Grafana debug log even shows the groups, yet the role is Viewer:

level=debug msg="User info result" result="Id: 62ac37c4-ddc5-4359-acf2-cc0bb1546ffa, Name: Jane Doe, Email: [email protected], Login: jane, Role: Viewer, Groups: [grafana_admins lldap_admin], OrgRoles: map[1:Viewer]"

Since 4.39, Authelia puts only the standard claims in the ID token, and groups is served from the userinfo endpoint. Grafana evaluates role_attribute_path against the ID token first, where groups is missing, so the expression falls through to its 'Viewer' default and Grafana never looks further. The claims_policies entry in the OIDC block puts the groups into the ID token, and the same login then returned Admin. Remember to restart Authelia after the change.

Config changes do nothing

docker compose up -d recreates a container only when the Compose file or the image changes. An edit to configuration.yml is neither, so the old rules stay active and nothing in the output says so. Run sudo docker compose restart authelia after every config edit, and authelia config validate before it.

Keep reading

UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP Php PHP2Shell (CVE-2026-109618, CVE-2026-109619): Check and Patch PHP PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Php PHP2Shell on End-of-Life PHP 8.1, 8.0 and 7.4 (WHMCS, ionCube) Install Wazuh 5 on Ubuntu 26.04: New Cluster Architecture Monitoring Install Wazuh 5 on Ubuntu 26.04: New Cluster Architecture Install Stalwart Mail Server on Ubuntu 26.04 with Docker Containers Install Stalwart Mail Server on Ubuntu 26.04 with Docker List Pods running in a specific node in Kubernetes Cluster Containers List Pods running in a specific node in Kubernetes Cluster

Leave a Comment

Press ESC to close