Once you self-host more than a handful of apps, the logins pile up. Some have decent auth, some have a single shared password, and a couple probably have no login at all because you "only reach them over a reverse proxy." That last group is the problem: a single misconfigured proxy rule exposes them to the whole internet.
Authelia fixes this by sitting in front of everything. Your reverse proxy asks Authelia "is this request allowed?" before it ever reaches the app. Users hit one login portal, prove who they are with a password plus a TOTP code, and Authelia hands them a session cookie that works across every subdomain. Apps with no built-in auth suddenly have a strong gate, and apps that do have logins gain a second factor for free.
This guide wires Authelia to Caddy using the native forward_auth directive, with Redis for sessions and file-based users so there's no external database to babysit. It assumes a fresh Ubuntu 22.04 or 24.04 VPS.
auth.example.com and one app subdomain at your VPSdocker-compose.ymlforward_auth authelia:9091 block to its Caddy sitedefault_policy: deny and allow apps explicitlyTotal time: about 30 minutes.
80 and 443 open to the internetwhoami test container, but any reverse-proxied app worksA key constraint: Authelia's session cookie is scoped to a parent domain. Every app you protect needs to be a subdomain of the same domain, for example auth.example.com, git.example.com, and files.example.com all under example.com. You cannot protect example.org and example.net with one cookie.
In your DNS provider, create A records for the auth portal and each app:
auth.example.com → YOUR_VPS_IPV4
whoami.example.com → YOUR_VPS_IPV4
Add AAAA records too if you use IPv6. Verify before continuing:
dig +short auth.example.com
dig +short whoami.example.com
Both should return your VPS IP. DNS has to resolve before Caddy can issue certificates.
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm both tools respond:
docker --version
docker compose version
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Only Caddy is published to the host. Authelia and Redis stay on the internal Docker network and are never reachable from outside.
sudo mkdir -p /opt/authelia/authelia
sudo mkdir -p /opt/authelia/caddy-data /opt/authelia/caddy-config
cd /opt/authelia
The authelia/ directory holds Authelia's config, user database, session store, and the TOTP/secret state. It is the directory you back up.
Authelia needs three independent secrets: a session secret, a storage encryption key, and a JWT secret for password-reset links. Generate them and drop them into an .env file that Compose will read:
cd /opt/authelia
{
echo "AUTHELIA_SESSION_SECRET=$(openssl rand -hex 32)"
echo "AUTHELIA_STORAGE_ENCRYPTION_KEY=$(openssl rand -hex 32)"
echo "AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET=$(openssl rand -hex 32)"
} | sudo tee .env
sudo chmod 600 .env
Authelia's file backend stores users in a YAML file with Argon2-hashed passwords. First, generate a hash:
docker run --rm authelia/authelia:4.38 \
authelia crypto hash generate argon2 --password 'change-this-strong-password'
Copy the full $argon2id$... string it prints. Then create /opt/authelia/authelia/users_database.yml:
users:
admin:
disabled: false
displayname: 'Admin'
password: '$argon2id$v=19$m=65536,t=3,p=4$PASTE_YOUR_HASH_HERE'
email: '[email protected]'
groups:
- admins
The username (admin here) is what you type at the login portal, not the display name. Add more users by repeating the block under users:.
Create /opt/authelia/authelia/configuration.yml:
theme: dark
server:
address: 'tcp://0.0.0.0:9091'
log:
level: info
authentication_backend:
file:
path: /config/users_database.yml
password:
algorithm: argon2
access_control:
default_policy: deny
rules:
- domain: 'whoami.example.com'
policy: two_factor
subject:
- 'group:admins'
session:
secret: '{{ env "AUTHELIA_SESSION_SECRET" }}'
cookies:
- domain: 'example.com'
authelia_url: 'https://auth.example.com'
default_redirection_url: 'https://whoami.example.com'
expiration: '1h'
inactivity: '15m'
redis:
host: redis
port: 6379
regulation:
max_retries: 3
find_time: '2m'
ban_time: '5m'
storage:
encryption_key: '{{ env "AUTHELIA_STORAGE_ENCRYPTION_KEY" }}'
local:
path: /config/db.sqlite3
identity_validation:
reset_password:
jwt_secret: '{{ env "AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET" }}'
notifier:
filesystem:
filename: /config/notification.txt
What the important parts do:
default_policy: deny means nothing is allowed unless a rule says so. This is the safe default - you opt apps in, not out.two_factor for whoami.example.com and limits it to the admins group.session.cookies[].domain is the parent domain. Get this wrong and the cookie won't be shared across subdomains.filesystem notifier writes TOTP enrollment and reset links to a file. It's perfect for first setup; you'll switch to SMTP in Step 12.Create /opt/authelia/docker-compose.yml:
services:
authelia:
image: authelia/authelia:4.38
container_name: authelia
restart: unless-stopped
volumes:
- ./authelia:/config
environment:
AUTHELIA_SESSION_SECRET: "${AUTHELIA_SESSION_SECRET}"
AUTHELIA_STORAGE_ENCRYPTION_KEY: "${AUTHELIA_STORAGE_ENCRYPTION_KEY}"
AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET: "${AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET}"
networks:
- authnet
redis:
image: redis:7-alpine
container_name: authelia-redis
restart: unless-stopped
command: redis-server --save 60 1
volumes:
- ./redis:/data
networks:
- authnet
whoami:
image: traefik/whoami
container_name: whoami
restart: unless-stopped
networks:
- authnet
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- authnet
networks:
authnet:
The secrets come from the .env file you wrote in Step 5, which Compose loads automatically from the project directory.
This is where the magic happens. Create /opt/authelia/Caddyfile:
# Reusable forward-auth snippet
(authelia) {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
}
# The Authelia login portal itself - never put it behind auth
auth.example.com {
encode zstd gzip
reverse_proxy authelia:9091
}
# A protected app: import the snippet, then proxy as usual
whoami.example.com {
encode zstd gzip
import authelia
reverse_proxy whoami:80
}
Here is the flow: a request to whoami.example.com hits the import authelia line, Caddy asks Authelia at /api/authz/forward-auth whether the session is valid, and only if Authelia returns 200 does the request reach whoami. Otherwise Caddy redirects the browser to auth.example.com to log in. The copy_headers line passes the authenticated identity to the upstream app, which apps that support proxy auth (Grafana, Forgejo, and others) can read directly.
cd /opt/authelia
sudo docker compose up -d
sudo docker compose logs -f authelia caddy
Watch for Caddy issuing certificates and Authelia logging Authelia v4.38 is starting. Then open https://whoami.example.com. You should be redirected to the Authelia portal at auth.example.com.
Log in with the username (admin) and the password you hashed in Step 6. Authelia will ask you to register a one-time-password device. Because you're still on the filesystem notifier, the enrollment link is written to a file instead of emailed. Read it:
sudo docker exec authelia cat /config/notification.txt
Open the link from that file in your browser, scan the QR code with your TOTP app, and enter the generated code. After that, log in again with password plus the six-digit code. You should land on the whoami page, which prints your request headers - look for Remote-User: admin to confirm the identity is being forwarded.
The filesystem notifier is fine for setup but useless for real users - nobody can read a file inside your container. Replace the notifier block in configuration.yml with SMTP from any transactional provider (Postmark, Mailgun, Brevo, or your own Postfix):
notifier:
smtp:
address: 'submission://smtp.example-mail.com:587'
username: 'apikey'
password: '{{ env "AUTHELIA_NOTIFIER_SMTP_PASSWORD" }}'
sender: 'Authelia <[email protected]>'
subject: '[Authelia] {title}'
Add the password to your .env and pass it through in docker-compose.yml the same way as the other secrets, then docker compose up -d. Now password resets and new-device enrollments go out by email.
Adding an app is now two small edits. In configuration.yml, append an access-control rule:
- domain: 'files.example.com'
policy: two_factor
subject:
- 'group:admins'
In the Caddyfile, add a site that imports the snippet:
files.example.com {
encode zstd gzip
import authelia
reverse_proxy nextcloud:80
}
Reload both:
sudo docker compose restart authelia
sudo docker exec caddy caddy reload --config /etc/caddy/Caddyfile
For apps that genuinely need to stay public (a status page, a webhook receiver), use policy: bypass for that domain or simply leave the import authelia line out of its Caddy site.
Redirect loop between the app and the portal. The cookie domain in configuration.yml doesn't match your real parent domain, so the browser never keeps the session. It must be the registrable domain (example.com), not the subdomain.
Caddy returns 401 for everything, even after login. The authelia_url in the session config must be the full https://auth.example.com and that portal has to be reachable. Check docker compose logs authelia for cookie-domain warnings on startup.
Authelia won't start: "configuration key not expected." You're on an older config schema. The keys above target 4.38; pin the image tag and read the migration notes if you bump major versions.
TOTP codes are always rejected. The VPS clock has drifted. Run timedatectl and enable NTP with sudo timedatectl set-ntp true - TOTP is time-based and a 30-second skew breaks it.
Locked out by the regulation rule. Too many bad attempts triggered a ban. Wait out ban_time, or restart Authelia to clear in-memory bans during testing.
authentication_backend.file for authentication_backend.ldap./opt/authelia/authelia on a schedule - it holds the user database, session store, and the encryption key state.That's it. One portal, one second factor, and every app behind it - including the ones that never had a login of their own.
Need a VPS that's ready for a Docker stack like this? Our Linux plans include fast NVMe storage and IPv6 out of the box. See the options.