Single Sign-On for Homelabs: Authentik, Traefik Forward-Auth, and Cloudflare Tunnel

By Anas Semesmieh · May 11, 2026 · Homelab, SSO, Authentik

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.

Every config file in this post is real and running in production on my homelab right now. Secrets are redacted, but the structure is copy-paste ready.

Architecture Overview

The authentication flow works like this:

  1. User visits https://sonarr.homelab.internal
  2. Traefik intercepts the request and calls Authentik's forward-auth endpoint
  3. If no valid session cookie exists, Authentik returns a 401 and Traefik redirects to the login page at auth.homelab.internal
  4. User authenticates (password + MFA)
  5. Authentik sets a session cookie scoped to .homelab.internal and redirects back
  6. 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:

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:

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:

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.

Root cause: Browsers treat single-label domains (like 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:

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:

AppMethodResult
Sonarr / Radarr / ProwlarrAuthenticationMethod=ExternalNo login prompt
PortainerNative OIDCSSO button, auto-create users
qBittorrentSubnet whitelistBypasses auth for proxy traffic
TautulliGuest access enabledNo login prompt
SeerrForward-auth onlyStill needs Plex login (no native OIDC in v3)
Homepage / DockhandNo built-in authForward-auth is sufficient
PlexExcluded from SSOUses plex.tv auth (own system)
Home AssistantExcluded from SSOUses 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:

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:

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:

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

The entire setup is reproducible from the configs in my homelab repo.