Containers

Install Authentik SSO with Docker Compose, Nginx and LDAP

The authentik compose file from the docs starts with one command, and it even works behind Nginx on the first try. That is because the default trusted proxy list covers every private address range, so any container on the host, or any machine on your LAN, can send a fake X-Forwarded-For and authentik will log it as the client IP. Brute force scoring and GeoIP both run on that address. Narrow the list to localhost, the obvious fix, and the login page breaks with a mixed content error, because Docker hands the Nginx connection to the container from a bridge gateway address, not from 127.0.0.1. The current release enforces that list on every forwarded header, as the 2026.8 release notes point out.

Original content from computingforgeeks.com - post 172903

This authentik Docker Compose guide builds the version you can put real users on: PostgreSQL, Nginx with Let’s Encrypt, an admin account created without exposing the setup wizard, enforced MFA with passkeys, password reset over SMTP, Grafana and Forgejo over OpenID Connect, Nginx forward auth for an app with no login of its own, and an LDAP outpost serving LDAPS. Configuration lives in blueprint files you can keep in Git, and the guide ends with a timed restore and a measured upgrade. Every command was run on Ubuntu 26.04 and replayed on Ubuntu 24.04 in October 2026 with authentik 2026.8.3.

What the finished stack looks like

Nginx terminates TLS on 443 for every name and is the only thing reachable from outside, apart from LDAPS on 636 if you run the optional LDAP outpost. The authentik server and worker share one PostgreSQL database. authentik no longer uses Redis, which leaves one container fewer to run. If you are still choosing an identity provider, the Keycloak production setup in Docker builds the same kind of stack on Keycloak. The official compose.yml stays untouched, and every local change goes into .env and a compose.override.yml, which means an upgrade is a fresh download of the official file. To weigh all the options first, our Keycloak, authentik, Zitadel and Authelia comparison ran the four side by side on one VM.

authentik Docker Compose architecture diagram: Nginx on 443 in front of the authentik server on 127.0.0.1:9000, the worker and server sharing PostgreSQL 16, and an optional LDAP outpost on 636

The worker never takes a request from outside. It reads blueprint files and runs background jobs against the same database, which is why the backup further down saves the host directories it mounts (blueprints, certs, custom-templates, data) alongside the database dump.

ComponentListens onRole
Nginx0.0.0.0:80, 0.0.0.0:443TLS for authentik, Grafana and the forward auth app
authentik server127.0.0.1:9000Web UI, API, OIDC endpoints, embedded proxy outpost
authentik workerDocker network onlyBackground tasks, blueprints, certificate discovery
PostgreSQL 16Docker network onlyEverything: users, flows, sessions, task queue
LDAP outpost (optional)0.0.0.0:636 (LDAPS)LDAP directory and bind for legacy clients
Grafana, whoami (optional examples)127.0.0.1:3000, 127.0.0.1:8081Example OIDC app and example forward auth app

The guide has three parts. The core setup is the same for everyone: install authentik, put it behind Nginx with a certificate, and turn on MFA, passkeys and brute force protection. The email and application sections that follow are independent and optional, so follow only the ones that match what you run. Each one starts with the hostname, DNS record and certificate it needs, and nothing in the core setup depends on them. The last part covers backups, upgrades and the errors we hit.

SectionNeeded forExtra DNS name and certificate
Core setupEveryoneauthentik.example.com only
Email and password resetSelf-service password reset, notificationsNone, but you need an SMTP relay account
Grafana over OIDCApps that support OpenID ConnectYour Grafana name, if Grafana runs on this host
Forgejo over OIDCConfiguring apps as code (blueprints)None on this host (Forgejo keeps its own)
Nginx forward authApps with no login of their ownThe protected app’s name
LDAP outpostNAS boxes, printers, SSSD, legacy appsAn LDAPS name, plus TCP 636 open to those clients

Prerequisites and sizing

Login rate is what sizes an authentik host. Every password check runs PBKDF2 with 1,000,000 iterations, and on the test box (an Intel i7-6700 core) one verification took 0.44 to 0.50 seconds of CPU. A burst of 50 people signing in at 9 a.m. is about 25 seconds of work for one core, so count cores against your peak logins, not your user total. Passkey logins skip that hash entirely. The second driver is PostgreSQL connections: with Redis removed, authentik keeps its cache, sessions and task queue in the database, and the idle stack below already held 15 connections. The docs put the floor at 2 CPU cores and 2 GB of RAM.

Measured after the full setup, the authentik containers used about 1 GB together (server 571 MiB, worker 294 MiB, PostgreSQL 117 MiB, LDAP outpost 48 MiB). The lab VM had 4 vCPU and 8 GB RAM, which is a floor for following along, not a production recommendation. For a few thousand users, 4 cores and 4 GB on the authentik host with PostgreSQL on fast storage is a sane start, and the server container scales out by adding replicas behind Nginx.

  • A Linux host with Docker Engine and the Compose plugin. Ubuntu 26.04 and 24.04 were both tested. The Docker CE install guide for Ubuntu covers the repository setup.
  • A DNS A record for the authentik name (authentik.example.com in this guide) pointing at this host. Any DNS provider works. On Cloudflare, keep it DNS only, because a proxied record hides the client IP that authentik uses for brute force scoring. The optional sections each list the extra name they need.
  • Inbound TCP 80 and 443 for the HTTP-01 challenge and the site. A host without inbound port 80 can use DNS-01 instead.
  • A TOTP app on your phone (Aegis, 2FAS, Google or Microsoft Authenticator, or your password manager), because the baseline makes MFA mandatory, including for the admin. A device with passkey support is only needed to test passkeys.

Set the shell variables

The core setup needs two values: the authentik hostname and your email address, which Let’s Encrypt and the admin account use. They go into variables once, so you edit them here and paste the rest as is. Secrets never go into this block: the commands that need a password ask for it at a prompt. The optional sections, including email, set their own variables when you get to them.

export AK_DOMAIN="authentik.example.com"
export ADMIN_EMAIL="[email protected]"
echo "authentik on ${AK_DOMAIN}, admin ${ADMIN_EMAIL}"

The variables last for the current shell only, and later sections assume you are still in that shell. If you reconnect, re-run this block (plus the variable of any optional section you are in) and the wait_blueprint function from the baseline section, then cd /opt/authentik again.

Get a Let’s Encrypt certificate

Install Nginx, Certbot with its Nginx plugin, and jq for reading JSON output later:

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

The Nginx and Let’s Encrypt guide for Ubuntu 26.04 explains Certbot in more depth. The certonly --nginx form answers the HTTP-01 challenge through the running Nginx without rewriting any config, and the deploy hook reloads Nginx after every renewal:

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

The optional sections reuse this exact command with their own name in place of ${AK_DOMAIN}.

Private network or no port 80: use DNS-01

A host on a private LAN, behind NAT, or without inbound port 80 proves ownership with a DNS TXT record instead. Certbot has a plugin per DNS provider:

DNS providerUbuntu packageCertbot flag
Cloudflarepython3-certbot-dns-cloudflare--dns-cloudflare
Amazon Route 53python3-certbot-dns-route53--dns-route53
DigitalOceanpython3-certbot-dns-digitalocean--dns-digitalocean
Google Cloud DNSpython3-certbot-dns-google--dns-google
Linodepython3-certbot-dns-linode--dns-linode
OVHpython3-certbot-dns-ovh--dns-ovh
BIND or any RFC 2136 serverpython3-certbot-dns-rfc2136--dns-rfc2136

The lab sits on a private network, so it used Cloudflare. Substitute the plugin and credentials file for your provider if you are on something else. The token needs Zone DNS Edit on the zone, 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

Then request the certificate over DNS. If you follow the optional sections later, run this same command again with their name instead of ${AK_DOMAIN}:

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

Either way, Certbot ends with the saved paths:

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/authentik.example.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/authentik.example.com/privkey.pem
This certificate expires on 2027-01-03.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.

Download the authentik Docker Compose file and write .env

Keep the stack under /opt/authentik. The compose file at this URL always pins the newest authentik release, so the same command is also your upgrade path:

sudo mkdir -p /opt/authentik && cd /opt/authentik
sudo wget -q https://docs.goauthentik.io/compose.yml
grep -m1 -oP 'AUTHENTIK_TAG:-\K[0-9.]+' compose.yml

The release it pins prints on its own line:

2026.8.3

Generate the database password and the secret key. AUTHENTIK_SECRET_KEY signs sessions and cookies, so it must never change after the first start and it belongs in your backups:

echo "PG_PASS=$(openssl rand -base64 36 | tr -d '\n')" | sudo tee .env >/dev/null
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')" | sudo tee -a .env >/dev/null
sudo chmod 600 .env

The remaining settings bind the authentik ports to localhost (Nginx is the only way in), record the public URL (stored as a setting now, used by later releases), trust exactly one proxy address, and switch off the startup analytics ping. AUTHENTIK_TAG is read from the compose file you just downloaded, so the LDAP outpost added later always runs the same release as the server:

AK_TAG=$(grep -m1 -oP 'AUTHENTIK_TAG:-\K[0-9.]+' compose.yml)
printf '%s\n' "AUTHENTIK_TAG=${AK_TAG}" \
  "COMPOSE_PORT_HTTP=127.0.0.1:9000" \
  "COMPOSE_PORT_HTTPS=127.0.0.1:9443" \
  "AUTHENTIK_WEB__BASE_URL=https://${AK_DOMAIN}" \
  "AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS=172.30.30.1/32" \
  "AUTHENTIK_DISABLE_STARTUP_ANALYTICS=true" | sudo tee -a .env >/dev/null

Trust Nginx, and only Nginx

Left at its default, AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS trusts 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. We confirmed the stock setup works behind Nginx: with the variable unset, the server logged the real client IP and "scheme": "https". The catch is who else that trusts. Any container on the host and any machine in those ranges can set X-Forwarded-For to an address of its choosing, and the brute force policy then scores the fake address.

Narrowing it to localhost does not work. Nginx connects to 127.0.0.1:9000, but the Docker userland proxy forwards that connection into the container from the bridge gateway address. With 127.0.0.1/32 as the only trusted proxy, the lab server logged every request as "remote": "::ffff:172.18.0.1", "scheme": "http": authentik ignored X-Forwarded-Proto, built http:// URLs on an HTTPS page, and the browser blocked them.

The fix is to pin the Compose network to a known subnet, so its gateway is a fixed address, and trust only that address. Create the override file. This guide uses vim, but any editor works, nano included:

sudo vim /opt/authentik/compose.override.yml

Add the network pin, plus a worker volume list that mounts a blueprints directory and leaves out the Docker socket. The official file mounts /var/run/docker.sock into the worker so authentik can deploy outposts by itself, which gives that container root-equivalent control of the host. Every outpost in this guide is deployed by hand, so the socket is not needed:

services:
  worker:
    volumes: !override
      - ./data:/data
      - ./certs:/certs
      - ./custom-templates:/templates
      - ./blueprints:/blueprints/custom

networks:
  default:
    ipam:
      config:
        - subnet: 172.30.30.0/24

The !override tag replaces the worker volume list from compose.yml instead of merging with it (the lab ran Compose 5.6, and older Compose releases without the tag fail to parse the file). Check the merged result before going further:

cd /opt/authentik
sudo docker compose config --format json | jq -c '.services.worker.volumes | map(.source + ":" + .target)'

No socket in the list:

["/opt/authentik/data:/data","/opt/authentik/certs:/certs","/opt/authentik/custom-templates:/templates","/opt/authentik/blueprints:/blueprints/custom"]

Create the admin without the setup wizard

A fresh authentik redirects anyone who reaches it to /if/flow/initial-setup/, where the first visitor sets the akadmin password. On a public host that is a race you do not need to run. Set the password hash before the first start instead. The hash is made inside the authentik image, so pull the images first. The server and PostgreSQL images take about 2.4 GB on disk, and the progress shows here instead of in a silent step later:

sudo docker compose pull

The prompt keeps the password out of history, and hash_password prints only the hash on stdout:

read -rs -p "Password for akadmin: " AK_ADMIN_PASS; echo
AK_HASH=$(printf '%s' "${AK_ADMIN_PASS}" | sudo docker compose run --rm -T server hash_password 2>/dev/null)
echo "AUTHENTIK_BOOTSTRAP_PASSWORD_HASH='${AK_HASH}'" | sudo tee -a .env >/dev/null
echo "AUTHENTIK_BOOTSTRAP_EMAIL=${ADMIN_EMAIL}" | sudo tee -a .env >/dev/null
unset AK_ADMIN_PASS AK_HASH

Confirm the hash landed. The command must print 1. A 0 means the hash step failed and .env holds an empty value, so fix that before the first start, or the setup wizard would stay open to the first visitor:

sudo grep -c "^AUTHENTIK_BOOTSTRAP_PASSWORD_HASH='pbkdf2_sha256" .env

The single quotes matter, because the hash is full of $ signs and Compose would try to expand them. The current release only accepts the password on stdin or at a prompt, so older guides that pass it as an argument to hash_password fail.

Start authentik

Start the stack:

sudo docker compose up -d

The first start runs every database migration. That took about a minute on the idle Ubuntu 24.04 replay and 288 seconds on the busier 26.04 host. During that window the worker reports unhealthy (its logs repeat relation "authentik_tasks_workerstatus" does not exist) and then recovers on its own once the server finishes migrating. Wait for it instead of restarting anything:

until [ "$(sudo docker inspect -f '{{.State.Health.Status}}' authentik-worker-1)" = healthy ]; do sleep 5; done
sudo docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"

Both authentik ports are bound to localhost only:

SERVICE      STATUS                        PORTS
postgresql   Up About a minute (healthy)   5432/tcp
server       Up About a minute (healthy)   127.0.0.1:9000->9000/tcp, 127.0.0.1:9443->9443/tcp
worker       Up About a minute (healthy)

The bootstrap values are only read on the very first start. Remove them from .env so the hash does not sit in a file that ends up in backups:

sudo sed -i '/^AUTHENTIK_BOOTSTRAP_/d' .env
sudo docker compose up -d

Put Nginx in front of authentik

Create the site file:

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

Paste the configuration below. proxy_http_version 1.1 and the upgrade headers are mandatory now. The server was rewritten in Rust for this release, and upstream bug reports show outposts turning “Unhealthy” with WebSocket errors behind an Nginx that still speaks HTTP/1.0 to it. authentik already sends X-Frame-Options, X-Content-Type-Options and Referrer-Policy, so only HSTS and the CSP from the authentik hardening guide are added here (adding the others again produced duplicate, conflicting headers in our test):

upstream authentik {
    server 127.0.0.1:9000;
    keepalive 10;
}

map $http_upgrade $connection_upgrade_keepalive {
    default upgrade;
    ''      '';
}

server {
    listen 80;
    listen [::]:80;
    server_name authentik.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name authentik.example.com;

    ssl_certificate     /etc/letsencrypt/live/authentik.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/authentik.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header Content-Security-Policy "default-src 'self'; img-src https: data:; object-src 'none'; frame-ancestors 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';" always;

    client_max_body_size 25m;

    location / {
        proxy_pass http://authentik;
        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade_keepalive;
    }
}

Swap the placeholder name for yours and enable the site:

sudo sed -i "s/authentik.example.com/${AK_DOMAIN}/g" /etc/nginx/sites-available/authentik.conf
sudo ln -sf /etc/nginx/sites-available/authentik.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default

Ubuntu 24.04 only: fold http2 into the listen lines first

Ubuntu 24.04 ships nginx 1.24, which predates the separate http2 directive, so nginx -t fails with unknown directive "http2". On 24.04, run this edit before testing the config. Ubuntu 26.04 readers skip it. The later sections remind you to run the same edit on their site files:

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/authentik.conf

Test the configuration and reload Nginx:

sudo nginx -t && sudo systemctl reload nginx

Open the firewall for SSH and the web ports. If your SSH server listens on a port other than 22, add sudo ufw allow <port>/tcp before enabling, or you lock yourself out:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw --force enable

Now confirm authentik trusts the proxy. Make one request through Nginx and read how the server logged it:

curl -s -o /dev/null "https://${AK_DOMAIN}/api/v3/root/config/"
sudo docker compose logs server --since 1m --no-log-prefix | grep root/config | tail -1 | jq -c '{remote, scheme}'

Because curl runs on the server itself, remote shows the server’s own public address, and from a browser it shows yours. What matters is a real address instead of a 172.x Docker gateway, and https:

{"remote":"10.0.1.20","scheme":"https"}

Sign in and check the dashboard

Browse to https://authentik.example.com/if/admin/ and sign in as akadmin with the password from the bootstrap step. The overview shows the version, a healthy worker count, and the embedded outpost. The client IP column in Recent events is the address you browsed from, a second confirmation that the proxy trust works:

authentik admin overview showing version 2026.8.3, one healthy worker and the embedded outpost

One setting is worth changing before anyone else logs in. Under System, Settings, set Avatars to initials. The default is gravatar,initials, which sends an MD5 hash of every user email address to Gravatar each time an avatar renders.The bootstrap step already gave akadmin your address, so update notifications for the authentik Admins group reach you once email is configured.

Apply the security baseline as a blueprint

A fresh authentik lets users sign in with a password alone (when a user has no authenticator, the MFA stage simply continues) and shows who is behind a username on the login page. Fixing that by clicking leaves nothing to review later. The authentik blueprints are YAML files that the worker applies on change and re-applies every hour, so the fix lives in a file instead.

Create the baseline file:

sudo vim /opt/authentik/blueprints/sso-baseline.yaml

Paste the blueprint. It requires a resident passkey with user verification, makes MFA mandatory (TOTP or passkey, chosen by the user at first login), hides the matched user, adds passkey autofill to the login form, and binds a reputation policy that blocks addresses after repeated failures:

version: 1
metadata:
  name: SSO baseline
entries:
  - model: authentik_stages_authenticator_webauthn.authenticatorwebauthnstage
    identifiers:
      name: default-authenticator-webauthn-setup
    attrs:
      friendly_name: Passkey
      resident_key_requirement: required
      user_verification: required

  - model: authentik_stages_authenticator_validate.authenticatorvalidatestage
    identifiers:
      name: default-authentication-mfa-validation
    attrs:
      not_configured_action: configure
      device_classes: [static, totp, webauthn]
      configuration_stages:
        - !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]]
        - !Find [authentik_stages_authenticator_webauthn.authenticatorwebauthnstage, [name, default-authenticator-webauthn-setup]]
      webauthn_user_verification: required

  - model: authentik_stages_identification.identificationstage
    identifiers:
      name: default-authentication-identification
    attrs:
      user_fields: [email, username]
      show_matched_user: false
      pretend_user_exists: true
      webauthn_stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, default-authentication-mfa-validation]]

  - model: authentik_flows.flow
    id: login-flow
    identifiers:
      slug: default-authentication-flow

  - model: authentik_policies_reputation.reputationpolicy
    id: bad-reputation
    identifiers:
      name: block-bad-reputation
    attrs:
      check_ip: true
      check_username: false
      threshold: -5

  - model: authentik_policies.policybinding
    identifiers:
      target: !KeyOf login-flow
      policy: !KeyOf bad-reputation
      order: 0
    attrs:
      negate: true
      enabled: true
      timeout: 30

The worker watches that directory and applies a file shortly after it changes. Shortly is not instantly: in our Ubuntu 24.04 replay the Forgejo blueprint took seven seconds to land, and the next command, which needed the new application, failed with a 404 because it ran first. Wait for the worker to report its result before you use anything a blueprint creates. This helper asks the worker for the status it recorded, and the later sections reuse it:

wait_blueprint() {
  for i in $(seq 1 30); do
    s=$(sudo docker compose exec -T worker ak shell -c "from authentik.blueprints.models import BlueprintInstance as B; i = B.objects.filter(path='custom/$1').first(); print(i.status if i else 'missing')" 2>/dev/null | tail -1)
    case "$s" in successful|warning|error) break ;; esac
    sleep 2
  done
  echo "$1: $s"
}
cd /opt/authentik && wait_blueprint sso-baseline.yaml

The file applied cleanly:

sso-baseline.yaml: successful

Anything other than successful needs a look. Customization, Blueprints in the admin UI shows the same status, and sudo docker compose exec -T worker ak apply_blueprint custom/sso-baseline.yaml prints the failing entry with its serializer error.

Two details in that file came from failures. The identification stage entry repeats user_fields because the serializer validates the whole stage, and a partial update without it fails with “When no user fields are selected, at least one source must be selected”. The policy binding targets the flow through !KeyOf on a declared flow entry rather than !Find, which is covered in the errors section at the end.

With the baseline applied, the next login for any user without an authenticator stops at a choice between a passkey and a TOTP app:

authentik login requiring the user to enroll a passkey or TOTP authenticator before continuing

The user picks one, enrolls it, and the login completes. Every later login asks for that authenticator, and that includes akadmin: have a TOTP app ready before you log in again. Aegis (Android), 2FAS (Android and iOS), Google or Microsoft Authenticator, or the TOTP feature of your password manager all work.

Losing that phone would lock you out of the only admin account, and without email there is no reset link. authentik has a break-glass command for this. It prints a one-time login path for akadmin that is valid for the number of minutes you give it:

sudo docker compose exec -T worker ak create_recovery_key 10 akadmin 2>/dev/null | grep -v '^{'

Open https://authentik.example.com followed by the printed path within those 10 minutes. Once you are in, open your user settings (the gear icon), go to Credentials, select the lost device under MFA Devices and Delete it, then use Enroll to add a new one. Anyone with shell access to the host can run it, which is one more reason to keep SSH tight:

Store this link safely, as it will allow anyone to access authentik as akadmin.
This recovery token is valid for 10 minutes.
/recovery/use-token/TOKEN/

Create groups and users

The core needs one test user to prove logins work. The two groups are what the optional application examples map to roles, so create them now if you plan to follow those sections. Members of platform-admins get admin rights in Grafana and Forgejo and are allowed through forward auth and LDAP. Members of developers get plain access. Create both under Directory, Groups, then add users under Directory, Users with New User. Open each user and use Set password. If you set up email later, Email recovery link lets users choose their own password instead.

In the lab, alice and erin went into platform-admins, while bob and carol went into developers. Each enrolled MFA at first login: TOTP for alice, bob and erin, a passkey for carol.

Check the result the way a user would. Open https://authentik.example.com/if/user/ in a private browser window and sign in as your test user. The login asks for the password, then shows the authenticator choice above, and ends on the user dashboard. That is the minimum working setup. Email and the application sections below are optional, and the backup and upgrade sections apply to everyone.

Optional: email and self-service password reset

Without a mail relay, authentik still works: users sign in with their password plus MFA, and an admin sets a forgotten password with Set password on the user page under Directory, Users. Self-service “Forgot password?” links and email notifications need SMTP. Skip this section if you have no relay yet and come back when you do; nothing else in the guide depends on it.

You need an SMTP account: the relay hostname, a username and password, and a sender address the relay accepts. Your mail provider, Amazon SES, Mailgun or your company relay all work. Set the three non-secret values:

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

authentik reads its global mail settings from .env. Port 587 with USE_TLS=true means STARTTLS. A relay that only offers port 465 needs AUTHENTIK_EMAIL__PORT=465 and AUTHENTIK_EMAIL__USE_SSL=true instead. Never set USE_TLS and USE_SSL together. The password is single-quoted so a $ in it survives the Compose variable expansion (a password that contains a single quote needs changing):

cd /opt/authentik
read -rs -p "SMTP password: " SMTP_PASS; echo
printf '%s\n' "AUTHENTIK_EMAIL__HOST=${SMTP_HOST}" \
  "AUTHENTIK_EMAIL__PORT=587" \
  "AUTHENTIK_EMAIL__USERNAME=${SMTP_USER}" \
  "AUTHENTIK_EMAIL__PASSWORD='${SMTP_PASS}'" \
  "AUTHENTIK_EMAIL__USE_TLS=true" \
  "AUTHENTIK_EMAIL__FROM=\"Example SSO <${SMTP_FROM}>\"" | sudo tee -a .env >/dev/null
unset SMTP_PASS
sudo docker compose up -d

Once the worker is healthy again, send a message to a mailbox you can read:

sudo docker compose exec -T worker ak test_email "${ADMIN_EMAIL}" 2>&1 | grep -v '^{'

The command reports success only after the relay accepted the message:

Switching to schema 'public'
Test email sent to [email protected]

The quotes around the From value survive the Compose env file parsing. Check it with sudo docker compose exec -T worker ak dump_config | jq .email, which printed "from": "Acme Labs SSO <[email protected]>" in our lab.

With mail working, add the password recovery flow. authentik ships it as an example blueprint that is not active by default:

cd /opt/authentik
sudo docker compose exec -T worker ak apply_blueprint example/flows-recovery-email-mfa-verification.yaml 2>&1 | grep -v '^{"'

A second small blueprint wires it in. It adds the “Forgot password?” link to the login form, sets the brand’s recovery flow so admins can email recovery links, and turns off “Activate user on success” in the recovery email stage:

sudo vim /opt/authentik/blueprints/sso-recovery.yaml

Paste the three entries:

version: 1
metadata:
  name: SSO password recovery
entries:
  - model: authentik_stages_email.emailstage
    identifiers:
      name: default-recovery-email
    attrs:
      activate_user_on_success: false
      subject: Reset your password

  - model: authentik_stages_identification.identificationstage
    identifiers:
      name: default-authentication-identification
    attrs:
      user_fields: [email, username]
      recovery_flow: !Find [authentik_flows.flow, [slug, default-recovery-flow]]

  - model: authentik_brands.brand
    identifiers:
      domain: authentik-default
    attrs:
      flow_recovery: !Find [authentik_flows.flow, [slug, default-recovery-flow]]

Wait for the worker to apply it:

wait_blueprint sso-recovery.yaml

The example flow ships with “Activate user on success” switched on, and we proved what that means: a deactivated user who still has access to their mailbox requested a reset, clicked the link, and was logged in with the account active again. With the recovery blueprint value of false the same user could set a password but stayed deactivated.

Connect your applications

The next four sections are independent examples. Pick the ones that match your apps and skip the rest; none of them depends on another, and the backup and upgrade sections later apply either way.

Connect Grafana with the application wizard

This example runs Grafana on the same host as authentik. Pick its hostname, create a DNS A record for it, and request its certificate with the same command as before (or the DNS-01 version):

export GRAFANA_DOMAIN="grafana.example.com"
sudo certbot certonly --nginx -d "${GRAFANA_DOMAIN}" --non-interactive --agree-tos -m "${ADMIN_EMAIL}" \
  --deploy-hook "systemctl reload nginx"

If your Grafana already runs elsewhere with HTTPS, set GRAFANA_DOMAIN to its name, skip the certificate, and use the provider settings below with the environment values from the compose file in your own Grafana configuration.

Grafana is the OIDC example done through the admin UI, because most readers will add their first app that way. The wizard under Applications, Applications, New Application has five steps. Name the application Grafana (the slug becomes grafana) and pick OAuth2/OpenID Provider:

authentik new application wizard choosing the OAuth2 OpenID provider type

The provider step in the current release has a bug that cost us an hour. Typing into the Logout URI field the first time re-renders the form, which deletes any redirect URI rows you already added and generates a new client ID and secret. We measured it: a redirect URI typed before the Logout URI was gone after one keystroke, and the client ID on screen changed (our test harness compared SHA-256 prefixes of the field: 70902f62 before, c4340f79 after). So fill the step in this order:

  • Authorization flow: default-provider-authorization-implicit-consent, so internal users are not asked to approve Grafana on every new device.
  • Logout URI first: https://grafana.example.com/logout.
  • Signing Key: authentik Self-signed Certificate. Without a signing key, tokens are signed with HS256 using the client secret.
  • Then Add entry under Redirect URIs: Strict, Authorization, https://grafana.example.com/login/generic_oauth.
  • Grant types: untick everything except Authorization Code and Refresh token. A new provider comes with Implicit, Hybrid, Client credentials, Password and Device-code all enabled.
  • Under Advanced protocol settings, move “authentik default OAuth Mapping: Application Entitlements” into the selected scopes. Grafana reads roles from it.

With the Logout URI filled first, the redirect row survives:

authentik OAuth2 provider form with the Grafana redirect URI in strict mode

In the bindings step, use Bind existing policy/group/user, switch to Group, and bind platform-admins and developers. Users outside both groups get “Permission denied” from authentik before Grafana ever sees them:

binding a group to the Grafana application in the authentik wizard

Review and Create Application. Then open Applications, Providers, Provider for Grafana. Our provider was saved with every redirect URI and grant type listed twice, a second bug in the same wizard:

authentik provider page showing the Grafana redirect URI saved twice by the wizard

Click Edit, remove the extra redirect row with its minus button, and Save Changes. Saving the edit form also removed the duplicate grant types. Copy the Client ID and Client Secret from this page. Never copy them from the wizard, which may have shown values that were regenerated before saving.

Roles come from Application Entitlements. Open the Grafana application, go to the Application entitlements tab, Create entitlement named Grafana Admins, then expand it and bind the platform-admins group:

authentik Grafana application entitlements tab with the Grafana Admins entitlement

Grafana runs in Docker next to authentik in this lab. A native Grafana install on Ubuntu takes the same values in the [auth.generic_oauth] section of grafana.ini. The secrets go into an env file next to the compose file:

sudo mkdir -p /opt/grafana && cd /opt/grafana
read -rp "Grafana client ID: " GRAFANA_CLIENT_ID
read -rs -p "Grafana client secret: " GRAFANA_CLIENT_SECRET; echo
printf '%s\n' "GF_AUTH_GENERIC_OAUTH_CLIENT_ID=${GRAFANA_CLIENT_ID}" "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=${GRAFANA_CLIENT_SECRET}" | sudo tee .env >/dev/null
sudo chmod 600 .env
unset GRAFANA_CLIENT_SECRET

Create the compose file:

sudo vim /opt/grafana/compose.yaml

The endpoints follow the fixed authentik layout under /application/o/. Grafana maps the Grafana Admins entitlement to Admin and everyone else to Viewer, logs users in by their authentik username, and skips its own login form:

services:
  grafana:
    image: grafana/grafana:13.2.3
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"
    env_file: .env
    environment:
      GF_SERVER_ROOT_URL: https://grafana.example.com/
      GF_AUTH_GENERIC_OAUTH_ENABLED: "true"
      GF_AUTH_GENERIC_OAUTH_NAME: authentik
      GF_AUTH_GENERIC_OAUTH_SCOPES: openid profile email entitlements
      GF_AUTH_GENERIC_OAUTH_AUTH_URL: https://authentik.example.com/application/o/authorize/
      GF_AUTH_GENERIC_OAUTH_TOKEN_URL: https://authentik.example.com/application/o/token/
      GF_AUTH_GENERIC_OAUTH_API_URL: https://authentik.example.com/application/o/userinfo/
      GF_AUTH_GENERIC_OAUTH_USE_PKCE: "true"
      GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH: preferred_username
      GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH: contains(entitlements[*], 'Grafana Admins') && 'Admin' || 'Viewer'
      GF_AUTH_SIGNOUT_REDIRECT_URL: https://authentik.example.com/application/o/grafana/end-session/
      GF_AUTH_OAUTH_AUTO_LOGIN: "true"
      GF_AUTH_DISABLE_LOGIN_FORM: "true"
    volumes:
      - grafana-data:/var/lib/grafana

volumes:
  grafana-data:

Fill in the names and start it:

sudo sed -i -e "s/grafana.example.com/${GRAFANA_DOMAIN}/g" -e "s/authentik.example.com/${AK_DOMAIN}/g" compose.yaml
sudo docker compose up -d --wait

The Grafana Nginx site is a plain proxy with WebSocket headers for Grafana Live:

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

Paste the server blocks. Grafana Live needs the Upgrade and Connection headers passed through:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    listen [::]:80;
    server_name grafana.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name grafana.example.com;

    ssl_certificate     /etc/letsencrypt/live/grafana.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/grafana.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

Enable it the same way as the authentik site (on Ubuntu 24.04, run the http2 edit from the authentik Nginx section on grafana.conf first):

sudo sed -i "s/grafana.example.com/${GRAFANA_DOMAIN}/g" /etc/nginx/sites-available/grafana.conf
sudo ln -sf /etc/nginx/sites-available/grafana.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Opening Grafana now goes straight to authentik. In the lab, alice (in platform-admins, password plus TOTP) and bob (in developers) each signed in once, and the Grafana API reported the mapped roles:

alice: login=alice [email protected] name=Alice Mwangi orgRole=['Admin'] authLabels=['Generic OAuth']
bob: login=bob [email protected] name=Bob Otieno orgRole=['Viewer'] authLabels=['Generic OAuth']

alice lands in Grafana as an organization Admin:

Grafana user administration page after alice signed in through authentik with the Admin role

bob landed as a Viewer, so he can browse dashboards but not change them. Moving him into platform-admins in authentik makes him an Admin at his next Grafana login.

Connect Forgejo with a blueprint

This example connects a Forgejo or Gitea you already run with HTTPS on its own name. It needs no DNS record or certificate on the authentik host, only its hostname:

export FORGEJO_DOMAIN="git.example.com"

This app is done as a blueprint, which is how the rest of your applications should arrive once you trust the pattern. The provider gets a fixed client ID, and authentik generates the secret. Create the file in your home directory first, because the worker applies anything that appears in blueprints/ immediately and the placeholder name must already be replaced by then:

vim ~/forgejo.yaml

The provider, the application and two group bindings:

version: 1
metadata:
  name: Forgejo
entries:
  - model: authentik_providers_oauth2.oauth2provider
    id: provider
    identifiers:
      name: Provider for Forgejo
    attrs:
      authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
      invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
      client_type: confidential
      client_id: forgejo
      grant_types: [authorization_code, refresh_token]
      redirect_uris:
        - matching_mode: strict
          url: https://git.example.com/user/oauth2/authentik/callback
          redirect_uri_type: authorization
      signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
      property_mappings:
        - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
        - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
        - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]

  - model: authentik_core.application
    id: app
    identifiers:
      slug: forgejo
    attrs:
      name: Forgejo
      provider: !KeyOf provider
      meta_launch_url: https://git.example.com/user/login

  - model: authentik_policies.policybinding
    identifiers:
      target: !KeyOf app
      group: !Find [authentik_core.group, [name, platform-admins]]
      order: 0

  - model: authentik_policies.policybinding
    identifiers:
      target: !KeyOf app
      group: !Find [authentik_core.group, [name, developers]]
      order: 10

Install it with the real hostname and wait for the worker to apply it:

cd /opt/authentik
sed "s/git.example.com/${FORGEJO_DOMAIN}/g" ~/forgejo.yaml | sudo tee blueprints/forgejo.yaml >/dev/null
wait_blueprint forgejo.yaml

Read the generated secret from the provider page (Applications, Providers, Provider for Forgejo). Then, in the Compose directory of your Forgejo instance (the Gitea guide for Ubuntu 26.04 covers a full install, and Forgejo uses the same commands), add authentik as an OpenID Connect source. The group claim maps platform-admins to Forgejo site admin:

export AK_DOMAIN="authentik.example.com"
read -rs -p "Forgejo client secret: " FORGEJO_SECRET; echo
sudo docker compose exec -T -u git forgejo forgejo admin auth add-oauth --name authentik --provider openidConnect \
  --key forgejo --secret "${FORGEJO_SECRET}" \
  --auto-discover-url "https://${AK_DOMAIN}/application/o/forgejo/.well-known/openid-configuration" \
  --scopes "openid email profile" --group-claim-name groups --admin-group platform-admins --skip-local-2fa
unset FORGEJO_SECRET

The source name authentik must match the last path segment of the redirect URI in the blueprint. Set [oauth2_client] USERNAME = preferred_username and ENABLE_AUTO_REGISTRATION = true in the Forgejo app.ini, or with the official image the environment variables FORGEJO__oauth2_client__USERNAME=preferred_username and FORGEJO__oauth2_client__ENABLE_AUTO_REGISTRATION=true (our test instance used the variables). Without them, first logins stop at a “complete your account” form. --skip-local-2fa is safe because authentik already demanded MFA.

The first account on a new Forgejo is a site admin no matter what, so admin access for alice proves nothing about the mapping. bob and erin do:

alice: landed ok, /admin status=200 title='Dashboard - Forgejo: Beyond coding. We forge.'
bob: landed ok, /admin status=403 title=''
erin: landed ok, /admin status=200 title='Dashboard - Forgejo: Beyond coding. We forge.'

Protect an app with Nginx forward auth

Forward auth puts an authentik login in front of an app that has none. The example protects traefik/whoami, which echoes the headers it receives; replace it with your own app when you are done testing. Set the app’s hostname, create its DNS record, and request a certificate:

export WHOAMI_DOMAIN="whoami.example.com"
sudo certbot certonly --nginx -d "${WHOAMI_DOMAIN}" --non-interactive --agree-tos -m "${ADMIN_EMAIL}" \
  --deploy-hook "systemctl reload nginx"

How it works: Nginx asks the authentik embedded outpost about every request with auth_request, sends unauthenticated users to the login flow, and passes the user name, email and groups to the app as headers. Start the example app:

sudo docker run -d --name whoami --restart unless-stopped -p 127.0.0.1:8081:80 traefik/whoami:latest

The provider and application come from a blueprint. Forward auth (single application) mode means the provider protects exactly one external host, and only platform-admins may pass:

vim ~/whoami.yaml

The proxy provider, its application and a single group binding:

version: 1
metadata:
  name: whoami forward auth
entries:
  - model: authentik_providers_proxy.proxyprovider
    id: provider
    identifiers:
      name: Provider for whoami
    attrs:
      mode: forward_single
      external_host: https://whoami.example.com
      authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
      invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
      access_token_validity: hours=8

  - model: authentik_core.application
    id: app
    identifiers:
      slug: whoami
    attrs:
      name: whoami
      provider: !KeyOf provider
      meta_launch_url: https://whoami.example.com/

  - model: authentik_policies.policybinding
    identifiers:
      target: !KeyOf app
      group: !Find [authentik_core.group, [name, platform-admins]]
      order: 0

Install it and wait for the apply:

cd /opt/authentik
sed "s/whoami.example.com/${WHOAMI_DOMAIN}/g" ~/whoami.yaml | sudo tee blueprints/whoami.yaml >/dev/null
wait_blueprint whoami.yaml

The Nginx site follows the current authentik snippet, including the newer $ak_http_host map and the larger proxy buffers. The /outpost.goauthentik.io location goes to the embedded outpost on the same port as the UI:

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

The app location calls the outpost for every request and forwards the identity headers it returns:

map $http_upgrade $connection_upgrade_keepalive_whoami {
    default upgrade;
    ''      '';
}

map $http_host $ak_http_host {
    default $http_host;
    ''      $host;
}

server {
    listen 80;
    listen [::]:80;
    server_name whoami.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name whoami.example.com;

    ssl_certificate     /etc/letsencrypt/live/whoami.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/whoami.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    proxy_buffers 8 16k;
    proxy_buffer_size 32k;

    location / {
        proxy_pass http://127.0.0.1:8081;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade_keepalive_whoami;

        auth_request /outpost.goauthentik.io/auth/nginx;
        error_page 401 = @goauthentik_proxy_signin;
        auth_request_set $auth_cookie $upstream_http_set_cookie;
        add_header Set-Cookie $auth_cookie;

        auth_request_set $authentik_username $upstream_http_x_authentik_username;
        auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
        auth_request_set $authentik_entitlements $upstream_http_x_authentik_entitlements;
        auth_request_set $authentik_email $upstream_http_x_authentik_email;
        auth_request_set $authentik_name $upstream_http_x_authentik_name;
        auth_request_set $authentik_uid $upstream_http_x_authentik_uid;

        proxy_set_header X-authentik-username $authentik_username;
        proxy_set_header X-authentik-groups $authentik_groups;
        proxy_set_header X-authentik-entitlements $authentik_entitlements;
        proxy_set_header X-authentik-email $authentik_email;
        proxy_set_header X-authentik-name $authentik_name;
        proxy_set_header X-authentik-uid $authentik_uid;
    }

    location /outpost.goauthentik.io {
        proxy_pass http://127.0.0.1:9000/outpost.goauthentik.io;
        proxy_set_header Host $ak_http_host;
        proxy_set_header X-Original-URL $scheme://$ak_http_host$request_uri;
        add_header Set-Cookie $auth_cookie;
        auth_request_set $auth_cookie $upstream_http_set_cookie;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
    }

    location @goauthentik_proxy_signin {
        internal;
        add_header Set-Cookie $auth_cookie;
        return 302 /outpost.goauthentik.io/start?rd=$scheme://$ak_http_host$request_uri;
    }
}

Enable it (on Ubuntu 24.04, run the http2 edit from the authentik Nginx section on whoami.conf first):

sudo sed -i "s/whoami.example.com/${WHOAMI_DOMAIN}/g" /etc/nginx/sites-available/whoami.conf
sudo ln -sf /etc/nginx/sites-available/whoami.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

One step remains, and skipping it is the most common forward auth failure. The application must be assigned to an outpost. Under Applications, Outposts, edit authentik Embedded Outpost and move whoami into Selected Applications. This step is kept out of the blueprint on purpose: a blueprint sets the complete application list of the outpost and would remove apps added by hand at its next hourly run.

adding the whoami application to the authentik embedded outpost

In the same dialog, open Advanced settings and check authentik_host in the configuration. authentik is supposed to fill it in from the URL of the first admin login, and on our Ubuntu 26.04 host it did. On the Ubuntu 24.04 replay it stayed empty, so the outpost sent every browser to http://localhost/application/o/authorize/ and Chrome failed with ERR_CONNECTION_REFUSED. curl never noticed, because it does not follow the redirect. Set it to your authentik URL and save:

setting authentik_host in the embedded outpost advanced configuration

Before the assignment, the outpost answered its health check but the app returned HTTP 500, and Nginx logged auth request unexpected status: 404. After it, an anonymous request is sent to the outpost login start:

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" "https://${WHOAMI_DOMAIN}/"

The 302 points at the outpost start URL on the app hostname:

302 https://whoami.example.com/outpost.goauthentik.io/start?rd=https://whoami.example.com/

In a browser, alice went through password and TOTP and landed on whoami, which printed the identity headers authentik injected:

X-Authentik-Email: [email protected]
X-Authentik-Groups: platform-admins
X-Authentik-Name: Alice Mwangi
X-Authentik-Uid: 1b276f40c1db19a5e77ffa19c1c41e44ebcd54bbbfd87376f8d3fda69b5a73c0
X-Authentik-Username: alice

bob is in developers, so authentik stopped him with “Permission denied” and the request never reached the app:

authentik permission denied page for a user outside the group bound to the forward auth application

The app trusts these headers completely, so it must only be reachable through Nginx. That is why whoami publishes on 127.0.0.1 and why Nginx sets the headers itself, overwriting anything a client sends.

Run the LDAP outpost with LDAPS

Some clients still only speak LDAP: NAS boxes, printers, older wikis, Linux hosts on SSSD. The LDAP outpost serves the authentik users and groups as a read-only directory and checks binds against authentik. It runs as its own container. It is not a full directory server: clients cannot write to it, and if you need one with Kerberos and host policies, a FreeIPA server with Rocky Linux clients or OpenLDAP in Docker is the better fit.

This section needs an LDAPS hostname with a DNS record pointing at this host, TCP 636 open to the clients that will use it, and the ldap-utils package for testing. LDAP_CLIENTS is the network allowed to reach LDAPS, usually your LAN or the subnet your NAS and servers sit on:

export LDAP_DOMAIN="ldap.example.com"
export LDAP_CLIENTS="10.0.1.0/24"
sudo apt install -y ldap-utils
sudo certbot certonly --nginx -d "${LDAP_DOMAIN}" --non-interactive --agree-tos -m "${ADMIN_EMAIL}" \
  --deploy-hook "systemctl reload nginx"

The outpost needs a certificate for LDAPS, and authentik imports certificates from its certs directory by folder name. Copy the Let’s Encrypt files there:

cd /opt/authentik
sudo mkdir -p "certs/${LDAP_DOMAIN}"
sudo cp -L "/etc/letsencrypt/live/${LDAP_DOMAIN}/fullchain.pem" "/etc/letsencrypt/live/${LDAP_DOMAIN}/privkey.pem" "certs/${LDAP_DOMAIN}/"
sudo docker compose exec -T worker ak shell -c "from authentik.crypto.tasks import certificate_discovery; certificate_discovery.send()" >/dev/null 2>&1

Discovery also runs once an hour on its own (at minute 23 on our host, a minute derived from the hostname). That copy goes stale at the first renewal, so add a Certbot deploy hook that refreshes it:

sudo vim /etc/letsencrypt/renewal-hooks/deploy/authentik-ldap-cert.sh

Certbot runs every deploy hook after any renewal, so the script exits early unless the LDAP name was renewed:

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

LDAP_DOMAIN="ldap.example.com"
DEST="/opt/authentik/certs/${LDAP_DOMAIN}"

case " ${RENEWED_DOMAINS:-} " in
  *" ${LDAP_DOMAIN} "*) ;;
  *) exit 0 ;;
esac

install -d -m 755 "$DEST"
install -m 644 "${RENEWED_LINEAGE}/fullchain.pem" "$DEST/fullchain.pem"
install -m 600 "${RENEWED_LINEAGE}/privkey.pem" "$DEST/privkey.pem"
cd /opt/authentik && docker compose exec -T worker ak shell -c "from authentik.crypto.tasks import certificate_discovery; certificate_discovery.send()" >/dev/null 2>&1

Make it executable, set the domain, and prove it fires with a dry run:

sudo chmod 750 /etc/letsencrypt/renewal-hooks/deploy/authentik-ldap-cert.sh
sudo sed -i "s/^LDAP_DOMAIN=.*/LDAP_DOMAIN=\"${LDAP_DOMAIN}\"/" /etc/letsencrypt/renewal-hooks/deploy/authentik-ldap-cert.sh
sudo certbot renew --dry-run --cert-name "${LDAP_DOMAIN}" --run-deploy-hooks

The LDAP blueprint creates a dedicated bind flow without the MFA stage (LDAP clients cannot answer a passkey prompt), a service account whose bind password is an app password token instead of a human password, the provider with its LDAPS certificate, and the outpost. The service account gets the “search full LDAP directory” permission on the provider directly. Without it, a bind user sees only itself. The old “search group” setting no longer exists.

vim ~/ldap.yaml

The bind flow and its three stages come first, then the service account, its token, the provider, the application, two bindings and the outpost:

version: 1
metadata:
  name: LDAP outpost
entries:
  - model: authentik_flows.flow
    id: bind-flow
    identifiers:
      slug: ldap-bind
    attrs:
      name: LDAP bind
      title: LDAP bind
      designation: authentication

  - model: authentik_stages_identification.identificationstage
    id: bind-identification
    identifiers:
      name: ldap-bind-identification
    attrs:
      user_fields: [username]

  - model: authentik_stages_password.passwordstage
    id: bind-password
    identifiers:
      name: ldap-bind-password
    attrs:
      backends:
        - authentik.core.auth.InbuiltBackend
        - authentik.core.auth.TokenBackend

  - model: authentik_stages_user_login.userloginstage
    id: bind-login
    identifiers:
      name: ldap-bind-login

  - model: authentik_flows.flowstagebinding
    identifiers:
      target: !KeyOf bind-flow
      stage: !KeyOf bind-identification
      order: 10

  - model: authentik_flows.flowstagebinding
    identifiers:
      target: !KeyOf bind-flow
      stage: !KeyOf bind-password
      order: 20

  - model: authentik_flows.flowstagebinding
    identifiers:
      target: !KeyOf bind-flow
      stage: !KeyOf bind-login
      order: 30

  - model: authentik_core.user
    id: svc
    identifiers:
      username: ldapservice
    attrs:
      name: LDAP bind account
      type: service_account
      path: service-accounts

  - model: authentik_core.token
    identifiers:
      identifier: ldapservice-bind
    attrs:
      user: !KeyOf svc
      intent: app_password
      expiring: false
      description: Bind password for LDAP clients

  - model: authentik_providers_ldap.ldapprovider
    id: provider
    identifiers:
      name: Provider for LDAP
    attrs:
      authorization_flow: !KeyOf bind-flow
      invalidation_flow: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
      base_dn: dc=ldap,dc=example,dc=com
      certificate: !Find [authentik_crypto.certificatekeypair, [name, ldap.example.com]]
      tls_server_name: ldap.example.com
      mfa_support: false
    permissions:
      - permission: authentik_providers_ldap.search_full_directory
        user: !KeyOf svc

  - model: authentik_core.application
    id: app
    identifiers:
      slug: ldap
    attrs:
      name: LDAP
      provider: !KeyOf provider

  - model: authentik_policies.policybinding
    identifiers:
      target: !KeyOf app
      user: !KeyOf svc
      order: 0

  - model: authentik_policies.policybinding
    identifiers:
      target: !KeyOf app
      group: !Find [authentik_core.group, [name, platform-admins]]
      order: 10

  - model: authentik_outposts.outpost
    identifiers:
      name: ldap
    attrs:
      type: ldap
      providers:
        - !KeyOf provider
      config:
        authentik_host: https://authentik.example.com
        log_level: info

The base DN dc=ldap,dc=example,dc=com is an arbitrary name for the virtual tree, so change it in the file if your clients expect something else. Install it with both hostnames substituted and wait for the apply:

sed -e "s/ldap.example.com/${LDAP_DOMAIN}/g" -e "s/authentik.example.com/${AK_DOMAIN}/g" ~/ldap.yaml | sudo tee blueprints/ldap.yaml >/dev/null
wait_blueprint ldap.yaml

The outpost container authenticates with a token that belongs to the outpost. Read it into .env without printing it:

cd /opt/authentik
LDAP_OUTPOST_TOKEN=$(sudo docker compose exec -T worker ak shell -c "from authentik.outposts.models import Outpost; print(Outpost.objects.get(name='ldap').token.key)" 2>/dev/null | tail -1)
echo "LDAP_OUTPOST_TOKEN=${LDAP_OUTPOST_TOKEN}" | sudo tee -a .env >/dev/null
unset LDAP_OUTPOST_TOKEN

Add the outpost service to the override file:

sudo vim /opt/authentik/compose.override.yml

The complete file now reads as follows. Only port 636 is published: in our tests authentik accepted a plain-text bind on 389 without StartTLS, so 389 stays inside the Docker network. The image tag comes from the same AUTHENTIK_TAG as the server, because the untagged ghcr.io/goauthentik/ldap image still resolves to a frozen latest from the 2025.2 line:

services:
  worker:
    volumes: !override
      - ./data:/data
      - ./certs:/certs
      - ./custom-templates:/templates
      - ./blueprints:/blueprints/custom

  ldap:
    image: ghcr.io/goauthentik/ldap:${AUTHENTIK_TAG:?set AUTHENTIK_TAG in .env}
    restart: unless-stopped
    ports:
      - "636:6636"
    environment:
      AUTHENTIK_HOST: http://server:9000
      AUTHENTIK_INSECURE: "false"
      AUTHENTIK_TOKEN: ${LDAP_OUTPOST_TOKEN:?set LDAP_OUTPOST_TOKEN in .env}
    depends_on:
      server:
        condition: service_healthy

networks:
  default:
    ipam:
      config:
        - subnet: 172.30.30.0/24

Start it and check the outpost connected:

sudo docker compose up -d
sleep 10
sudo docker compose logs ldap --no-log-prefix 2>&1 | grep -E 'Starting authentik outpost|Successfully connected websocket' | jq -c '{event, version}'

The outpost runs the same release as the server and holds a WebSocket to it:

{"event":"Successfully connected websocket","version":null}
{"event":"Starting authentik outpost","version":"2026.8.3"}

The bind password for ldapservice is the app password token from the blueprint. Directory, Tokens and App passwords shows it, or read it in the shell:

LDAP_BIND_PASS=$(sudo docker compose exec -T worker ak shell -c "from authentik.core.models import Token; print(Token.objects.get(identifier='ldapservice-bind').key)" 2>/dev/null | tail -1)
ldapsearch -LLL -H "ldaps://${LDAP_DOMAIN}:636" -D "cn=ldapservice,ou=users,dc=ldap,dc=example,dc=com" -w "${LDAP_BIND_PASS}" \
  -b "ou=users,dc=ldap,dc=example,dc=com" "(objectClass=user)" cn mail memberOf

The service account sees every user with their groups. The output below is trimmed to two users; the full listing also had akadmin and the service users of both outposts:

dn: cn=alice,ou=users,dc=ldap,dc=example,dc=com
memberOf: cn=platform-admins,ou=groups,dc=ldap,dc=example,dc=com
cn: alice
mail: [email protected]

dn: cn=bob,ou=users,dc=ldap,dc=example,dc=com
memberOf: cn=developers,ou=groups,dc=ldap,dc=example,dc=com
cn: bob
mail: [email protected]

Our other binds behaved as designed. alice bound with her own password (no TOTP, because the bind flow has no MFA stage) and saw only her own entry. bob, who is not bound to the LDAP application, got ldap_bind: Insufficient access (50). A wrong password returned ldap_bind: Invalid credentials (49). Because LDAP skips MFA, keep the LDAP application bound to the few groups that need it.

Docker publishes 636 straight past UFW

With UFW enabled and no rule for 636, LDAPS was still reachable from every machine on the LAN. Docker writes its own iptables rules for published ports, and those run before the UFW rules. The fix is a rule in the DOCKER-USER chain, which Docker evaluates first and never touches. Keep it in /etc/ufw/after.rules so it survives reboots:

printf '%s\n' "" "*filter" ":DOCKER-USER - [0:0]" \
  "-A DOCKER-USER -p tcp -m conntrack --ctorigdstport 636 --ctdir ORIGINAL -s ${LDAP_CLIENTS} -j RETURN" \
  "-A DOCKER-USER -p tcp -m conntrack --ctorigdstport 636 --ctdir ORIGINAL -j DROP" \
  "COMMIT" | sudo tee -a /etc/ufw/after.rules >/dev/null
sudo ufw reload
sudo iptables -S DOCKER-USER

The chain now drops LDAPS from everything outside LDAP_CLIENTS:

-N DOCKER-USER
-A DOCKER-USER -s 10.0.1.0/24 -p tcp -m conntrack --ctorigdstport 636 --ctdir ORIGINAL -j RETURN
-A DOCKER-USER -p tcp -m conntrack --ctorigdstport 636 --ctdir ORIGINAL -j DROP

In the lab, a host outside the allowed range lost access to 636 while an allowed host kept it, and the rules were back in place after a reboot.

Passkeys, password reset and brute force, tested

Each of these ran in a real browser against the lab. The results below are what your users will see.

Passkeys. carol chose Passkey at her first login and registered a resident credential bound to the authentik hostname. In a fresh browser with no cookies, the autofill on the login page offered her passkey and signed her in four and a half seconds later, without a username or password. The event log recorded the login with auth_method: auth_webauthn_pwl, and the default flow skipped the MFA stage because a passkey already is the second factor.

Password reset (needs the email section). The “Forgot password?” link appears on the password step, after the username, not on the first screen. alice requested a reset by email address, and the message arrived from the configured sender with the subject from the recovery blueprint. The link opened a confirmation page first (mail scanners that pre-open links no longer burn the token), then asked for her TOTP code, then the new password, and logged her in. The old password stopped working on LDAP binds immediately:

authentik password reset page asking the user to confirm the email address before continuing

Brute force. The reputation policy blocks by address. From a clean IP, five wrong passwords for bob were answered with “Invalid password”, and the sixth attempt was refused at the start of the flow with “Flow does not apply to current user.” Other addresses were not affected. The score is the sum over every account seen from that address, which matters behind shared NAT. When we repeated the attack from the office address that alice and carol also used, their successful logins (each success adds a point) kept the address above the threshold and nothing was blocked. To unblock an address, delete its row under Customization, Reputation scores.

Back up and test a restore

authentik keeps everything that matters in PostgreSQL. The rest is the .env file (without the secret key, restored sessions and encrypted fields are useless), the compose files, the blueprints, and the certificate and template directories. Create the backup script:

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

The dump uses the custom format, which pg_restore can restore selectively, and the script keeps 14 days:

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

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

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

docker compose exec -T postgresql pg_dump -U authentik -d authentik -Fc > "$DEST/authentik-db-$STAMP.dump"
tar -czf "$DEST/authentik-files-$STAMP.tar.gz" .env compose.yml compose.override.yml data certs custom-templates blueprints
chmod 600 "$DEST"/authentik-*-"$STAMP".*

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

Make it executable and add a systemd service and timer:

sudo chmod 750 /usr/local/bin/authentik-backup.sh
sudo vim /etc/systemd/system/authentik-backup.service

A oneshot unit that waits for Docker:

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

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

The timer runs it nightly at 02:30 with a random delay, and catches up after downtime:

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

Paste the schedule:

[Unit]
Description=Nightly authentik backup

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

[Install]
WantedBy=timers.target

Enable the timer and run the first backup now:

sudo systemctl daemon-reload
sudo systemctl enable --now authentik-backup.timer
sudo systemctl start authentik-backup.service
sudo journalctl -u authentik-backup.service -n 5 --no-pager -o cat

On the Ubuntu 24.04 replay the run produced a 3.1 MB database dump and a 6.7 KB file archive. The 26.04 host, after all the tests in this guide, took 7 seconds for 3.4 MB:

Starting authentik-backup.service - authentik database and file backup...
backup ok: 3.1M in /var/backups/authentik
authentik-backup.service: Deactivated successfully.
Finished authentik-backup.service - authentik database and file backup.

Copy /var/backups/authentik off the host. A backup on the same disk as the database is not a backup. For point-in-time recovery instead of nightly dumps, the PostgreSQL PITR guide applies to this database unchanged.

The drill was destructive on purpose. After the backup, we created a marker user, then ran docker compose down -v (which deletes the database volume) and moved /opt/authentik away. The restore starts only PostgreSQL first. If the server starts against an empty database, it runs every migration and the restore then collides with the fresh schema:

STAMP=20261005-002901  # use the timestamp from: sudo ls /var/backups/authentik
sudo mkdir -p /opt/authentik && cd /opt/authentik
sudo tar -xzf "/var/backups/authentik/authentik-files-${STAMP}.tar.gz"
sudo docker compose up -d --wait postgresql
sudo cat "/var/backups/authentik/authentik-db-${STAMP}.dump" | sudo docker compose exec -T postgresql pg_restore -U authentik -d authentik --clean --if-exists --no-owner
sudo docker compose up -d

From an empty directory to a healthy server took 89 seconds on the 26.04 host and 22 seconds on the 24.04 replay, where the images were already cached. The marker user was gone (the database matched the backup moment), TOTP worked for alice and bob in Grafana with their existing secrets, the LDAP outpost reconnected with its token from .env, and forward auth answered with a 302 again.

Upgrade without skipping a release

authentik upgrades one major line at a time: 2026.5.x can go straight to 2026.8.x, but 2026.2.x must stop at the latest 2026.5 patch first. The current release enforces this in code. Take a backup, re-download the compose file (which carries the new tag), update the pinned tag in .env, then pull and restart:

sudo systemctl start authentik-backup.service
cd /opt/authentik
sudo wget -q -O compose.yml https://docs.goauthentik.io/compose.yml
AK_TAG=$(grep -m1 -oP 'AUTHENTIK_TAG:-\K[0-9.]+' compose.yml)
sudo sed -i "s/^AUTHENTIK_TAG=.*/AUTHENTIK_TAG=${AK_TAG}/" .env
sudo docker compose pull
sudo docker compose up -d

We measured the supported path on a separate stack: 2026.5.7 to 2026.8.3, with a request to /-/health/ready/ every second during the upgrade. From up -d to a healthy server took 86 seconds, and the ready endpoint failed for 78 of them. A single authentik server cannot do a rolling upgrade, so plan a short window. Users and settings carried over. Migrations run inside the server container at startup under a database lock, so you do not run ak migrate yourself. Go straight to the newest patch of the target line: users who went from 2026.5.7 to 2026.8.2 reported a crash loop with an InconsistentMigrationHistory error (issue 25996), which 2026.8.3 fixed.

Jumping two lines fails in a way that is easy to miss, because the container shows as up while it restarts in a loop (nine restarts in the first ninety seconds of our test). Pointing a 2026.2.7 database at 2026.8.3 and reading the server log shows why:

sudo docker compose logs server --no-log-prefix | grep -m2 -E 'database lock|Major version'

The server refuses to migrate and exits before touching the schema:

2026-10-05 00:50:24 [info     ] waiting to acquire database lock
2026-10-05 00:50:24 [error    ] Major version skips are not allowed. See: https://docs.goauthentik.io/install-config/upgrade/?from=2026.2.7&to=2026.8.3 from_version=<Version('2026.2.7')> to_version=<Version('2026.8.3')>

The fix is to step through the skipped line: set AUTHENTIK_TAG to the latest 2026.5 patch, start it until the server is healthy, then upgrade again. The check reads the newest row of the authentik_version_history table, so a stack that once ran an untagged image can fail it even on a normal upgrade.

Downgrades are not supported. If an upgrade fails, restore the backup you took first.

Errors we hit and how we fixed them

“The request failed and the interceptors did not return an alternative response”

The login card showed this message and the browser console logged Mixed Content: The page at 'https://...' was loaded over HTTPS, but requested an insecure resource 'http://...'. It appeared after we narrowed the trusted proxy list to 127.0.0.1/32, and it also hits anyone whose Compose network sits outside the default private ranges. authentik did not trust the address Nginx connected from, so it ignored X-Forwarded-Proto and built http:// URLs. Check how the server logs a request ("scheme": "http" confirms it), then set AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS to the gateway of the Compose network as shown above.

authentik login card failing with the interceptors error behind an untrusted Nginx proxy

With the gateway address trusted, the same page loaded normally after a restart of the stack.

“The fields policy, target, order must make a unique set.”

Our first version of the baseline targeted the login flow with !Find [authentik_flows.flow, [slug, default-authentication-flow]], and applying it again failed (the lab log shows this exact error; a later repro reported Blueprint invalid on every run). !Find returns the primary key of the flow, but a policy binding points at the separate policy binding UUID of the flow, and on our replay host the two were different values (60fee21e... against b15a3289...). The importer looked for the existing binding under the wrong UUID, did not find it, and tried to insert a duplicate. Declaring the flow as an entry with an id and targeting it with !KeyOf, the pattern the shipped default blueprints use, fixed it: the final file re-applied twice without errors on both test hosts, and its status stayed successful through the hourly runs.

“Failed to get token from provider” in Grafana

The Grafana login page showed “Login failed, Failed to get token from provider”, and its log had oauth2: "invalid_client" "Client authentication failed". The client secret in Grafana did not match the one in authentik, which is exactly what you get after copying credentials from the wizard. Copy them from the provider page and restart Grafana.

“auth request unexpected status: 404 while sending to client”

Nginx logged this and the forward auth app returned HTTP 500, while /outpost.goauthentik.io/ping still answered 204. The proxy provider existed but no outpost served it. Add the application to the embedded outpost.

“Major version skips are not allowed”

The server restarted in a loop after an upgrade. The database was two release lines behind the image. Restore your pre-upgrade backup if anything else changed, then go through the intermediate line as described in the upgrade section.

Forward auth sends the browser to http://localhost/

The app redirects to /outpost.goauthentik.io/start, which then sends the browser to http://localhost/application/o/authorize/, and the browser reports ERR_CONNECTION_REFUSED. The embedded outpost has an empty authentik_host. Set it under Applications, Outposts, authentik Embedded Outpost, Advanced settings, as shown in the forward auth section.

“ldap_bind: Insufficient access (50)”

The user exists and the password is right, but the user is not allowed to use the LDAP application. Bind the user, or a group they belong to, to the LDAP application.

“Flow does not apply to current user.”

Every login attempt from one address failed at the first step with this message. That address crossed the reputation threshold. Delete its score under Customization, Reputation scores, and find out why it failed five times.

Defaults to change before real users arrive

Most of this guide is about changing defaults that are fine for a demo and wrong for production. Here is every one we changed, in one place:

SettingDefaultWhat we setWhy
Trusted proxy CIDRsRFC 1918 ranges and loopback172.30.30.1/32 on a pinned networkNginx through Docker arrives from the bridge gateway, and anything trusted can spoof client IPs
Initial setupOpen wizard at /if/flow/initial-setup/Bootstrap password hashNo first-visitor race on a public host
Worker Docker socketMountedRemoved with !override (the worker still starts as root, then drops to the authentik user)Socket access is root on the host
MFA not configured actionContinueConfigure TOTP or passkeyPassword-only users otherwise sail through
Recovery “activate user on success”On in the example flowOffA deactivated user with mailbox access reactivated themselves
Avatarsgravatar,initialsinitialsEvery avatar render sends an email hash to Gravatar
New OAuth2 provider grant typesAll but token exchangeAuthorization code and refresh tokenPassword and device-code grants widen the attack surface
LDAP plain bind on 389Accepted without StartTLS636 only, filtered in DOCKER-USERPasswords would cross the network in clear text
Startup analyticsEnabledDisabledOne less call home

Read this table again after every upgrade. Releases do change defaults: 2026.5 moved the listen address to [::], and 2026.8 started applying the trusted proxy list to every forwarded header, so check the release notes for new settings before you pull.

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 Stalwart Mail Server on Ubuntu 26.04 with Docker Containers Install Stalwart Mail Server on Ubuntu 26.04 with Docker Install Wazuh 5 on Ubuntu 26.04: New Cluster Architecture Monitoring Install Wazuh 5 on Ubuntu 26.04: New Cluster Architecture Check OpenShift / OKD Cluster Version and Operators Status Containers Check OpenShift / OKD Cluster Version and Operators Status

Leave a Comment

Press ESC to close