4 min read @leo

One Login for the Homelab: Authelia SSO with Traefik Forward-Auth

One Login for the Homelab: Authelia SSO with Traefik Forward-Auth

Every self-hosted service ships its own login page. Some (like Prometheus) ship none at all. This is how I put a single Authelia sign-on gate in front of my homelab — one login for everything — using Traefik forward-auth, all managed by GitOps (Flux). It covers the deployment, the two levels of "SSO," and every sharp edge I hit.

Authelia sign-in portal
The Authelia portal — one login in front of every service.

Two levels of "SSO" — know which you want

Authelia forward-auth is a gate in front of an app, not a replacement for its login. That distinction matters:

  • Gate only — Authelia authenticates you, then the app still shows its own login. Good for apps with no login (Prometheus) or where you just want a wall. Downside: double login for apps that have their own auth.
  • True SSO — the app trusts Authelia's Remote-User header and logs you in automatically. No second prompt. Requires app support (Grafana auth.proxy, Gitea reverse-proxy auth).

1. Deploy Authelia

File-based single-user, one-factor to start. Secrets come from Vault via External-Secrets; state is a small SQLite PVC. Config (trimmed):

# configuration.yml (ConfigMap)
theme: dark
server: { address: "tcp://0.0.0.0:9091/" }
authentication_backend:
  file:
    path: /config/users_database.yml
access_control:
  default_policy: deny
  rules:
    - domain: [prometheus.leolab.pro, grafana.leolab.pro, git.leolab.pro]
      policy: one_factor
session:
  cookies:
    - domain: leolab.pro            # cookie shared across *.leolab.pro
      authelia_url: https://auth.leolab.pro
storage:
  local: { path: /data/db.sqlite3 }
notifier:
  filesystem: { filename: /data/notification.txt }

Secrets (session, storage-encryption, jwt) inject via *_FILE env from an ExternalSecret; the user database (with an argon2id hash) mounts as a file:

# hash a password
docker run --rm authelia/authelia:4.38 \
  authelia crypto hash generate argon2 --password 'your-password'
# users_database.yml (whole file goes in the secret, not just the hash)
users:
  leo:
    displayname: "Admin"
    password: "$argon2id$v=19$m=65536,t=3,p=4$...."
    email: [email protected]
    groups: [admins]

Name the Service authelia and Kubernetes injects AUTHELIA_PORT, AUTHELIA_SERVICE_* env vars. Authelia reads any AUTHELIA_-prefixed env as configuration → it collides with server.address and the pod crash-loops. Fix:

spec:
  template:
    spec:
      enableServiceLinks: false   # stop the injected AUTHELIA_* env

⚠️ Gotcha 2 — the user database is a full YAML file

The users-database secret must contain the entire users_database.yml (the users: → <name> → password: structure), not just the bare hash. A bare hash gives users: non zero value required and Authelia won't start.

2. The Traefik forward-auth middleware

A Middleware that calls Authelia and copies the identity headers back:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata: { name: authelia }
spec:
  forwardAuth:
    address: http://authelia.authelia.svc.cluster.local:9091/api/authz/forward-auth
    trustForwardHeader: true
    authResponseHeaders: [Remote-User, Remote-Groups, Remote-Email, Remote-Name]

Attach it to any app's Ingress with an annotation:

annotations:
  traefik.ingress.kubernetes.io/router.middlewares: myns-authelia@kubernetescrd

⚠️ Gotcha 3 — Traefik v3 blocks cross-namespace middleware

By default a v3 Ingress can't reference a Middleware in another namespace. Rather than enable allowCrossNamespace (and restart Traefik), just create the same Middleware object in each app's namespace — the forwardAuth.address still points at the one Authelia Service cluster-wide.

3. Whole-host vs path-scoped (Ghost)

Most apps: gate the whole host. But a public blog with an admin path (Ghost: public site + /ghost admin) must stay public while only the admin is gated. Solution — a second, path-scoped Ingress; PathPrefix(/ghost) outranks the blog's /:

# ghost-admin ingress: only /ghost, with the middleware
rules:
  - host: ghost.leolab.pro
    http: { paths: [{ path: /ghost, pathType: Prefix, backend: {...} }] }
annotations:
  traefik.ingress.kubernetes.io/router.middlewares: ghost-authelia@kubernetescrd

The main blog ingress stays untouched and never depends on Authelia.

⚠️ Gotcha 4 — Gitea breaks the git CLI unless you bypass

Gate git.leolab.pro naively and git clone/push over HTTPS, the API, and the container registry all break — CLIs can't follow the browser login redirect. Bypass those paths in Authelia and gate only the web UI:

access_control:
  rules:
    - domain: git.leolab.pro
      resources:
        - "^/api($|/.*)"
        - "^/v2($|/.*)"          # container registry
        - "^/[^/]+/[^/]+/(info/refs|git-upload-pack|git-receive-pack)($|/.*)"
      policy: bypass
    - domain: git.leolab.pro
      policy: one_factor          # web UI only

4. True SSO — trusting the header

For apps that support it, skip the second login entirely.

Grafanaauth.proxy trusts Remote-User and auto-logs-in:

[auth.proxy]
enabled = true
header_name = Remote-User
header_property = username
auto_sign_up = true
whitelist = 10.42.0.0/16        # pod CIDR — only Traefik reaches Grafana
[users]
auto_assign_org_role = Admin

Gitea — reverse-proxy auth auto-logs-in/registers from the header:

[service]
ENABLE_REVERSE_PROXY_AUTHENTICATION = true
ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = true
[security]
REVERSE_PROXY_AUTHENTICATION_USER = Remote-User
REVERSE_PROXY_TRUSTED_PROXIES = 10.42.0.0/16

Security note: header-trust means the app blindly believes Remote-User. It's only safe because the apps are reachable solely through Traefik+Authelia — anything that could hit the pod directly could forge the header. Keep them off direct/NodePort exposure.

Who gets what

ServiceMode
Grafana, GiteaTrue SSO (header auto-login, no app password)
Prometheus, Excalidraw, Stirling-PDFGate only (no native login — Authelia is their auth)
Ghost admin (/ghost)Gate only (Ghost keeps its own login behind it)
Public blog, Uptime-Kuma, CommaFeedUntouched / native login

Side effect: dashboards that call the API

Anything that scrapes a gated service's API from inside the cluster (a homepage dashboard, an exporter) will now get 401. Point those at the in-cluster service URL instead of the public hostname — service-to-service traffic never touches Traefik/Authelia:

# homepage widget
url: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090

Roll it out carefully

  1. Deploy Authelia; confirm the portal loads and login works — before attaching any middleware (attaching while Authelia is down 500s the app).
  2. Canary on a low-risk app (Prometheus) to prove the redirect→login→back flow.
  3. Gate the rest, one at a time. Do Grafana last — if Authelia misbehaves you don't want to lock yourself out of the dashboards you'd debug it with.
  4. Keep a break-glass: kubectl port-forward svc/grafana 3000:80 reaches it directly, no gate.

End result: one Authelia login, shared across every *.leolab.pro service — auto-login where the app supports it, a clean gate where it doesn't, the public blog still public, and the git CLI still working.