Single Sign-On for Homelabs: Authentik, Traefik Forward-Auth, and Cloudflare Tunnel
I run 15+ self-hosted services. Each one had its own login page, its own password, its own session timeout. Plex has plex.tv auth. Portainer has local accounts. The Arr stack has Forms authentication. Tautulli has yet another password. Opening my homelab dashboard meant clicking through login screens like it was 2005.
Today I fixed that. One login — everywhere. Internally on *.homelab.internal and externally on *.semesmieh.com via Cloudflare Tunnel. With MFA. Here's exactly how.
Architecture Overview
The authentication flow works like this:
- User visits
https://sonarr.homelab.internal - Traefik intercepts the request and calls Authentik's forward-auth endpoint
- If no valid session cookie exists, Authentik returns a 401 and Traefik redirects to the login page at
auth.homelab.internal - User authenticates (password + MFA)
- Authentik sets a session cookie scoped to
.homelab.internaland redirects back - Traefik sees the valid cookie on subsequent requests and passes through to the app
The key insight: because the cookie is scoped to the parent domain (.homelab.internal), a single login works for every subdomain. No per-app configuration needed for forward-auth — it's all handled at the reverse proxy layer.
Components:
- Authentik — Identity Provider (OIDC, forward-auth, user management)
- Traefik — Reverse proxy with forwardAuth middleware
- Pi-hole — Wildcard DNS resolution (
*.homelab.internal→ server IP) - Cloudflare Tunnel — Secure external access without port forwarding
- Self-signed CA — Internal TLS with trusted wildcard certificate
Deploying Authentik
Authentik runs as four containers: the server (API + UI), a background worker, PostgreSQL, and Redis. Here's the complete docker-compose.yml:
# ~/authentik/docker-compose.yml
services:
postgresql:
image: docker.io/library/postgres:16-alpine
container_name: authentik-postgresql
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
start_period: 20s
interval: 30s
retries: 5
timeout: 5s
volumes:
- database:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${PG_PASS}
POSTGRES_USER: authentik
POSTGRES_DB: authentik
networks:
- authentik-internal
redis:
image: docker.io/library/redis:alpine
container_name: authentik-redis
command: --save 60 1 --loglevel warning
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
start_period: 20s
interval: 30s
retries: 5
timeout: 3s
volumes:
- redis:/data
networks:
- authentik-internal
server:
image: ghcr.io/goauthentik/server:2026.2.2
container_name: authentik-server
restart: unless-stopped
command: server
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
volumes:
- ./media:/media
- ./custom-templates:/templates
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.authentik-http.rule=Host(`auth.homelab.internal`)
- traefik.http.routers.authentik-http.entrypoints=web
- traefik.http.routers.authentik-http.middlewares=authentik-https-redirect
- traefik.http.middlewares.authentik-https-redirect.redirectscheme.scheme=https
- traefik.http.routers.authentik-https.rule=Host(`auth.homelab.internal`)
- traefik.http.routers.authentik-https.entrypoints=websecure
- traefik.http.routers.authentik-https.tls=true
- traefik.http.services.authentik.loadbalancer.server.port=9000
networks:
- authentik-internal
- traefik
worker:
image: ghcr.io/goauthentik/server:2026.2.2
container_name: authentik-worker
restart: unless-stopped
command: worker
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./media:/media
- ./custom-templates:/templates
- ./certs:/certs
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- authentik-internal
volumes:
database:
redis:
networks:
authentik-internal:
driver: bridge
traefik:
name: traefik
external: true
The .env file:
# ~/authentik/.env
AUTHENTIK_SECRET_KEY=REDACTED
PG_PASS=REDACTED
AUTHENTIK_BOOTSTRAP_PASSWORD=REDACTED
AUTHENTIK_BOOTSTRAP_TOKEN=REDACTED
AUTHENTIK_BOOTSTRAP_EMAIL=anas@semesmieh.com
Key decisions:
- Two networks:
authentik-internalkeeps PostgreSQL and Redis isolated. Only the server and worker join the sharedtraefiknetwork. - Bootstrap variables: Set the admin password, API token, and email on first boot — no manual setup wizard needed.
- Worker has Docker socket: Required for the embedded outpost to discover containers on the host.
DNS & Certificates
Wildcard DNS via Pi-hole
Pi-hole v6 supports wildcard DNS natively. In pihole.toml:
# /etc/pihole/pihole.toml (excerpt)
[dns]
hosts = [
"192.168.20.69 auth.homelab.internal",
"192.168.20.69 homepage.homelab.internal",
"192.168.20.69 sonarr.homelab.internal",
"192.168.20.69 radarr.homelab.internal",
# ... all other services
]
Every *.homelab.internal subdomain resolves to the server's static IP. Traefik then routes based on the Host header.
Self-Signed CA & Wildcard Certificate
I created a local Certificate Authority valid for 10 years, and a wildcard certificate for *.homelab.internal:
# Generate the CA (once, valid 10 years)
openssl req -x509 -new -nodes \
-keyout homelab-ca.key -out homelab-ca.crt \
-days 3650 -subj "/CN=Homelab Local CA"
# Generate a wildcard cert signed by the CA (valid ~2 years)
openssl req -new -nodes \
-keyout homelab.key -out homelab.csr \
-subj "/CN=*.homelab.internal"
cat > san.cnf <<EOF
[req]
distinguished_name = req_distinguished_name
[req_distinguished_name]
[v3_ext]
subjectAltName = DNS:*.homelab.internal, DNS:homelab.internal
EOF
openssl x509 -req -in homelab.csr \
-CA homelab-ca.crt -CAkey homelab-ca.key -CAcreateserial \
-out homelab.crt -days 825 \
-extfile san.cnf -extensions v3_ext
Import homelab-ca.crt into your browser's trust store and you'll get green padlocks on every internal service.
Traefik TLS Configuration
# ~/traefik/dynamic/tls.yaml
tls:
certificates:
- certFile: /certs/homelab.crt
keyFile: /certs/homelab.key
stores:
default:
defaultCertificate:
certFile: /certs/homelab.crt
keyFile: /certs/homelab.key
Forward-Auth Middleware
This is where the magic happens. A single Traefik middleware definition protects every app behind Authentik without touching the apps themselves.
Authentik: Create a Proxy Provider
In Authentik, create a Proxy Provider in forward_domain mode:
- Name: homelab-forward-auth
- Mode: Forward auth (domain level)
- External host:
https://auth.homelab.internal - Cookie domain:
homelab.internal
Then create an Application linked to this provider, and add the provider to the embedded outpost. The outpost exposes the /outpost.goauthentik.io/ path that Traefik calls.
Traefik: Dynamic Configuration
This file defines the forwardAuth middleware, the outpost router, and the outpost service:
# ~/traefik/dynamic/authentik.yaml
http:
routers:
authentik-outpost:
rule: "HostRegexp(`{host:.+}`) && PathPrefix(`/outpost.goauthentik.io/`)"
entryPoints:
- websecure
tls: {}
service: authentik-outpost
priority: 25
middlewares:
authentik:
forwardAuth:
address: http://authentik-server:9000/outpost.goauthentik.io/auth/traefik
trustForwardHeader: true
authResponseHeaders:
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
- X-authentik-jwt
- X-authentik-meta-jwks
- X-authentik-meta-outpost
- X-authentik-meta-provider
- X-authentik-meta-app
- X-authentik-meta-version
addAuthCookiesToResponse:
- authentik_proxy_8936af85
services:
authentik-outpost:
loadBalancer:
servers:
- url: "http://authentik-server:9000/outpost.goauthentik.io"
The addAuthCookiesToResponse field is critical — it passes the proxy session cookie back to the browser through Traefik. The hash suffix is generated by Authentik based on your provider's client ID.
Applying Middleware to Apps
For apps managed by Docker labels (e.g., Homepage):
labels:
- traefik.http.routers.homepage-https.middlewares=authentik@file
For apps defined in Traefik's file provider (e.g., in homelab-services.yaml):
http:
routers:
sonarr-https:
rule: "Host(`sonarr.homelab.internal`)"
entryPoints:
- websecure
middlewares:
- authentik@file
tls: {}
service: sonarr-service
That's it. Any router with authentik@file middleware now requires Authentik authentication.
The Cookie Domain Problem
This cost me hours. Originally I used *.homelab as my internal domain. Everything worked in Traefik, Pi-hole resolved it fine, certs were valid. But SSO failed with a 400 error on every login attempt.
homelab) as a TLD per RFC 6265. They refuse to set cookies with Domain=homelab because that would be a supercookie. The same restriction applies to .local, .localhost, and anything on the Public Suffix List.
The fix: migrate everything to .homelab.internal — a two-label domain that browsers treat as a proper registrable domain. The migration touched:
- Pi-hole DNS: Update all host entries from
*.homelabto*.homelab.internal - TLS certificate: Regenerate with
*.homelab.internalSAN - Traefik routers: Update every
Host()rule - Authentik provider: Change external_host and cookie_domain
- Docker labels: Update Host rules on every container
After the migration, SSO worked immediately. Lesson learned: always use a multi-label domain for internal services.
Eliminating Double Logins
Forward-auth protects access at the proxy layer, but many apps still show their own login screen after you've already authenticated. Here's how I eliminated the second login for each app:
| App | Method | Result |
|---|---|---|
| Sonarr / Radarr / Prowlarr | AuthenticationMethod=External | No login prompt |
| Portainer | Native OIDC | SSO button, auto-create users |
| qBittorrent | Subnet whitelist | Bypasses auth for proxy traffic |
| Tautulli | Guest access enabled | No login prompt |
| Seerr | Forward-auth only | Still needs Plex login (no native OIDC in v3) |
| Homepage / Dockhand | No built-in auth | Forward-auth is sufficient |
| Plex | Excluded from SSO | Uses plex.tv auth (own system) |
| Home Assistant | Excluded from SSO | Uses its own auth (trusted_proxies possible) |
Arr Stack: External Authentication
Sonarr, Radarr, and Prowlarr all support an External auth mode that trusts the reverse proxy to handle authentication:
<!-- ~/arrstack/sonarr/config.xml (same for radarr, prowlarr) -->
<Config>
<AuthenticationMethod>External</AuthenticationMethod>
<AuthenticationRequired>DisabledForLocalAddresses</AuthenticationRequired>
<ApiKey>REDACTED</ApiKey>
<!-- ... other settings ... -->
</Config>
Stop the container, edit config.xml, start it again. The app no longer prompts for credentials — it trusts that Traefik + Authentik already verified the user.
Portainer: Native OIDC
Portainer supports OAuth2/OIDC natively. I created a dedicated OAuth2 provider in Authentik with:
- Client type: Confidential
- Redirect URIs:
https://portainer.homelab.internal/andhttps://portainer.semesmieh.com/ - Scopes: openid, email, profile
- Authorization flow: implicit-consent (no extra click)
Then configured Portainer via its API:
curl -X PUT "https://portainer:9443/api/settings" \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"AuthenticationMethod": 3,
"OAuthSettings": {
"ClientID": "YOUR_CLIENT_ID",
"ClientSecret": "YOUR_CLIENT_SECRET",
"AccessTokenURI": "https://auth.homelab.internal/application/o/token/",
"AuthorizationURI": "https://auth.homelab.internal/application/o/authorize/",
"ResourceURI": "https://auth.homelab.internal/application/o/userinfo/",
"RedirectURI": "https://portainer.homelab.internal/",
"UserIdentifier": "email",
"Scopes": "openid email profile",
"OAuthAutoCreateUsers": true,
"SSO": true,
"LogoutURI": "https://auth.homelab.internal/application/o/portainer/end-session/"
}
}'
Now Portainer shows a "Login with OAuth" button. With SSO enabled, if you're already authenticated with Authentik, it logs you in automatically.
qBittorrent: Subnet Whitelist
qBittorrent doesn't support external auth, but it can bypass authentication for trusted subnets. Since all traffic arrives through Traefik (already authenticated), we whitelist everything:
# ~/arrstack/qbittorrent/qBittorrent/qBittorrent.conf
[Preferences]
WebUI\Address=*
WebUI\AuthSubnetWhitelist=0.0.0.0/0
WebUI\AuthSubnetWhitelistEnabled=true
WebUI\ServerDomains=*
Tautulli: Guest Access
Tautulli can be configured to allow guest access (no login) when it's already protected by the proxy:
# ~/tautulli/config/config.ini
[General]
allow_guest_access = 1
http_username =
http_password =
External Access via Cloudflare Tunnel
For access outside my home network, I use Cloudflare Tunnel. No port forwarding, no dynamic DNS, no exposed attack surface. The tunnel connects outbound from my server to Cloudflare's edge, and traffic flows back through it.
Second Proxy Provider
External access needs its own proxy provider because the cookie domain is different:
- Name: semesmieh-forward-auth
- Mode: Forward auth (domain level)
- External host:
https://auth.semesmieh.com - Cookie domain:
semesmieh.com
This provider is added to the same embedded outpost. The outpost now handles both internal and external domains.
External Middleware
A separate middleware in authentik.yaml references the external provider's cookie:
authentik-external:
forwardAuth:
address: http://authentik-server:9000/outpost.goauthentik.io/auth/traefik
trustForwardHeader: true
authResponseHeaders:
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
- X-authentik-jwt
- X-authentik-meta-jwks
- X-authentik-meta-outpost
- X-authentik-meta-provider
- X-authentik-meta-app
- X-authentik-meta-version
addAuthCookiesToResponse:
- authentik_proxy_7885229f
External Traefik Routers
A dedicated file defines all external routers pointing to *.semesmieh.com:
# ~/traefik/dynamic/external-services.yaml (excerpt)
http:
routers:
auth-external-https:
rule: "Host(`auth.semesmieh.com`)"
entryPoints:
- websecure
tls: {}
service: authentik-service
homepage-external-https:
rule: "Host(`homepage.semesmieh.com`)"
entryPoints:
- websecure
middlewares:
- authentik-external@file
tls: {}
service: homepage-external
sonarr-external-https:
rule: "Host(`sonarr.semesmieh.com`)"
entryPoints:
- websecure
middlewares:
- authentik-external@file
tls: {}
service: sonarr-external
# Apps excluded from SSO (own auth systems):
plex-external-https:
rule: "Host(`plex.semesmieh.com`)"
entryPoints:
- websecure
tls: {}
service: plex-service@file
services:
authentik-service:
loadBalancer:
servers:
- url: "http://authentik-server:9000"
homepage-external:
loadBalancer:
servers:
- url: "http://192.168.20.69:3000"
sonarr-external:
loadBalancer:
servers:
- url: "http://192.168.20.69:8989"
Cloudflare Tunnel Ingress
The tunnel is remotely managed via the Cloudflare dashboard. Each service maps a public hostname to the internal Traefik endpoint:
# Tunnel ingress rules (configured via CF API/dashboard)
ingress:
- hostname: auth.semesmieh.com
service: https://192.168.20.69:443
originRequest:
noTLSVerify: true
- hostname: homepage.semesmieh.com
service: https://192.168.20.69:443
originRequest:
noTLSVerify: true
- hostname: sonarr.semesmieh.com
service: https://192.168.20.69:443
originRequest:
noTLSVerify: true
# ... 15+ more hostnames, all pointing to Traefik
- service: http_status:404
I created CNAME records for each subdomain pointing to the tunnel's .cfargotunnel.com address. All traffic flows: Internet → Cloudflare Edge → Tunnel → Traefik → forwardAuth → App.
DNS Records
# 18 CNAME records, all pointing to the tunnel
auth.semesmieh.com → <tunnel-id>.cfargotunnel.com
homepage.semesmieh.com → (same)
sonarr.semesmieh.com → (same)
radarr.semesmieh.com → (same)
plex.semesmieh.com → (same)
portainer.semesmieh.com → (same)
# ... and so on for every exposed service
MFA: TOTP + WebAuthn
With services exposed to the internet, MFA is non-negotiable. Authentik supports TOTP (authenticator apps) and WebAuthn (hardware keys, passkeys) out of the box.
I enrolled both:
- TOTP: Scanned QR code into my authenticator app — works everywhere
- WebAuthn: Registered a passkey via Windows Hello — seamless on my primary machine
The default authentication flow in Authentik already prompts for MFA if devices are enrolled. No additional flow configuration needed.
What I Learned
- Cookie domains are everything. Single-label domains silently break SSO. Always use a multi-label domain like
.homelab.internalfor internal services. - Forward-auth beats native OIDC for most apps. You configure it once in Traefik and it protects any number of apps. Only use native OIDC when the app supports it well (like Portainer).
- Two providers, one outpost. Internal and external domains need separate proxy providers (different cookie domains), but they share the same outpost and the same Traefik forwardAuth endpoint.
- Authentik's embedded outpost is enough. No need for a standalone proxy container — the embedded outpost handles forward-auth directly through the server container.
- Cloudflare Tunnel removes all the networking pain. No NAT, no firewall rules, no dynamic DNS. Just outbound HTTPS from your server to Cloudflare's edge.
- Eliminate double logins aggressively. If an app supports External auth or subnet whitelisting, use it. The proxy is your auth layer — the app doesn't need its own.
Final Result
From zero to full SSO in a single session:
- 15+ apps protected by a single login
- Two domains:
*.homelab.internal(internal) and*.semesmieh.com(external) - MFA everywhere — TOTP + WebAuthn
- Zero port forwarding — Cloudflare Tunnel for external access
- One password, one session — no more login fatigue
The entire setup is reproducible from the configs in my homelab repo.