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

| Component | Listens on | Role |
|---|---|---|
| Nginx | 0.0.0.0:80, 0.0.0.0:443 | TLS, auth_request to Authelia, proxy to apps |
| Authelia | 127.0.0.1:9091 | Portal, access rules, TOTP, passkeys, OIDC provider |
| LLDAP | 127.0.0.1:17170 (web), 3890 inside Docker (LDAP) | Users, groups, passwords |
| PostgreSQL | Docker network only | Two databases: authelia and lldap |
| Redis | Docker network only | Authelia 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.
| Section | Needed for | Extra things you need |
|---|---|---|
| Core setup | Everyone | auth.example.com and lldap.example.com |
| Protect another app | Any web app without its own login, or with header login | A DNS name for that app |
| Grafana over OpenID Connect | Apps that log in with OIDC and map roles from groups | A DNS name for Grafana |
| Email and password reset | Self-service password reset | An 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.comfor the portal andlldap.example.comfor the user admin UI. Both must sit under one parent domain (example.comhere), 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:

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

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:

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:

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:

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:

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

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:

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:

Both factors now show on the settings page:

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:

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:

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:

Open https://grafana.example.com. The login page has a new 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:

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

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:

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

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