Self-hosted SSO has a reputation for being a weekend ruiner. It is not. It is a four-hour job the first time, and a three-line add per new service after that. This essay is the deploy plan I wish I'd had in front of me on the Saturday morning I started. Part of the rig.
The goal, in one sentence
One browser login — a username, a password, a TOTP code — grants me access to Jellyfin, Immich, Nextcloud, Sonarr/Radarr, Home Assistant, the *arr stack, and anything else I put behind Traefik. No per-service credentials. No "which password was Jellyfin again."
The pieces
- Traefik 3 — reverse proxy, reads labels off containers, handles TLS via Let's Encrypt or internal Step-CA, supports ForwardAuth middleware for delegating auth to an external service.
- Authelia 4.38 — the external auth service. Handles the login page, sessions, 2FA, password file / LDAP backend. Lightweight Go binary.
- Redis — session storage for Authelia. Single small container.
That's the whole architecture. No Keycloak, no Authentik, no self-hosted Identity Provider empire. Authelia is the minimum viable thing that does the job.
The deploy plan — four hours, one Saturday
Hour 1 — Traefik
If you already run Traefik, skip this. If you don't: stand up a Traefik container with Docker socket mounted, a labels-based config, and your chosen TLS strategy. Mine uses Step-CA for internal certs and Let's Encrypt only for the two services I expose publicly.
services:
traefik:
image: traefik:v3.2
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./acme:/acme
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(\`traefik.home\`)
- traefik.http.routers.api.service=api@internal
Verify: hit https://traefik.home, see the dashboard, TLS green in the browser. If that works, you have a reverse proxy. Move on.
Hour 2 — Authelia
Authelia has three config pieces: the main YAML, the users database (a file or LDAP), and an access control policy. Starting config:
# configuration.yml
server:
address: "tcp://:9091"
authentication_backend:
file:
path: /config/users_database.yml
access_control:
default_policy: deny
rules:
- domain: "jellyfin.home"
policy: one_factor
- domain: "immich.home"
policy: one_factor
- domain: "sonarr.home"
policy: two_factor
- domain: "traefik.home"
policy: two_factor
session:
name: authelia_session
secret: "$(openssl rand -hex 32)"
expiration: 12h
inactivity: 1h
cookies:
- domain: home
authelia_url: "https://auth.home"
storage:
local:
path: /config/db.sqlite3
encryption_key: "$(openssl rand -hex 32)"
notifier:
filesystem:
filename: /config/notifier.log
Three choices reflected here that matter:
- Default deny. Every service I add must explicitly be allowed. Better than "default allow, deny specific" — that's how people end up with admin panels accidentally exposed.
- Two-factor on admin-ish services (Traefik dashboard, Sonarr, Radarr). One-factor on consumer-ish services (Jellyfin, Immich) so my partner and in-law aren't fighting a TOTP app every night.
- Filesystem notifier for password-reset links during setup. In production I'll swap this for SMTP, but on first deploy it saves me SMTP debugging on day one.
Hour 3 — Wiring Traefik to Authelia
Add the ForwardAuth middleware in Traefik's dynamic config:
# traefik/dynamic.yml
http:
middlewares:
authelia:
forwardAuth:
address: http://authelia:9091/api/verify?rd=https://auth.home/
trustForwardHeader: true
authResponseHeaders:
- Remote-User
- Remote-Groups
- Remote-Name
- Remote-Email
Then every protected service gets one label:
labels:
- traefik.enable=true
- traefik.http.routers.jellyfin.rule=Host(\`jellyfin.home\`)
- traefik.http.routers.jellyfin.middlewares=authelia@file
That's it. That's the contract. Add the middleware reference, hit the URL, Authelia's login page appears.
Hour 4 — User setup, TOTP, and the password file
Generate a bcrypt hash for each user:
docker run --rm authelia/authelia:4.38 \
authelia crypto hash generate argon2 --password 'your-password-here'
Put the result in users_database.yml:
users:
theo:
displayname: "Theo Marren"
password: "$argon2id$v=19$m=65536,t=3,p=4$..."
email: [email protected]
groups: ["admins"]
partner:
displayname: "Partner"
password: "$argon2id$v=19$m=65536,t=3,p=4$..."
email: [email protected]
groups: ["household"]
First login at https://auth.home. Authelia prompts to set up TOTP; scan the QR with Aegis (or any TOTP app, not SMS). You are done.
The one thing that tripped me up
Jellyfin has its own auth and the forward-auth headers don't feed it automatically. I have two options:
- Leave Jellyfin's own login in front of Authelia. Two logins. Ugly.
- Use Authelia headers to auto-log-in to Jellyfin via the LDAP backend + Jellyfin's LDAP plugin. Nice. More moving parts.
I went with option 1 for now. My partner has her Jellyfin credentials saved in Safari — the Authelia login is extra but not painful.
The next essay will be about Step-CA
Authelia + Traefik closes the auth door. The next hole is that browsers still scream about self-signed certs on internal-only services. Step-CA fixes that — a private CA my household devices trust, automated certs via ACME. That's a separate weekend.
Context for the whole security layer: The self-hosting stack I actually use in 2026.
# issues (0)
$ no issues filed yet. be the first — the form is below.