All articles
TutorialsJun 10, 2026 · 19 min read

Self-Host Authelia for SSO and 2FA on a VPS

Self-Host Authelia for SSO and 2FA on a VPS

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.

TL;DR

  • Point auth.example.com and one app subdomain at your VPS
  • Run Authelia, Redis, and Caddy from a single docker-compose.yml
  • Generate three long secrets and one hashed user password
  • Protect any app by adding a forward_auth authelia:9091 block to its Caddy site
  • Enroll TOTP on first login, then set default_policy: deny and allow apps explicitly
  • Swap the filesystem notifier for SMTP once it works

Total time: about 30 minutes.

What You Need

  • A VPS with at least 1 GB RAM running Ubuntu 22.04 or 24.04
  • A domain where you can add DNS records (all protected apps must share one parent domain)
  • Ports 80 and 443 open to the internet
  • An app to protect - this guide includes a whoami test container, but any reverse-proxied app works
  • A TOTP app on your phone (Aegis, Ente Auth, 1Password, etc.)

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

Step 1: Point Subdomains at Your VPS

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.

Step 2: Install Docker and Docker Compose

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

Step 3: Open the Firewall

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.

Step 4: Create the Project Layout

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.

Step 5: Generate Secrets

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
The storage encryption key protects every TOTP secret in the database. If you lose it, every user has to re-enroll their second factor, and existing sessions break. Copy all three values into your password manager now, before you go any further.

Step 6: Create the User Database

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

Step 7: Write the Authelia Configuration

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.
  • The single rule requires 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.
  • The 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.

Step 8: Write the Compose File

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.

Step 9: Write the Caddyfile

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.

Step 10: Start the Stack

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.

Step 11: Enroll Your Second Factor

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.

With `default_policy: deny`, a typo in an access-control rule locks everyone out of that app, including you. Test rule changes against a throwaway subdomain first, and keep an SSH session open so you can always edit `configuration.yml` and run `docker compose restart authelia`.

Step 12: Switch to SMTP for Production

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.

Step 13: Protect More Apps

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.

Troubleshooting

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.

Going Further

  • Add LDAP instead of the file backend if you already run an identity source, by swapping authentication_backend.file for authentication_backend.ldap.
  • Use OpenID Connect for apps that speak OIDC natively (Grafana, Forgejo, Nextcloud) so they get real SSO instead of just header auth.
  • Keep it private. Pair this with our Tailscale guide so the portal is only reachable on your tailnet, and read the Caddy reverse-proxy patterns post for more upstream tricks.
  • Back up /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.