Authentik SSO

Single sign-on for all *.edmd.me services via forward-auth. Deployed May 17, 2026 on CT100.

Overview

Authentik provides centralized authentication for homelab services. Instead of each service managing its own login, Caddy checks with Authentik before allowing access. If you’re not authenticated, you get redirected to the Authentik login page at auth.edmd.me. After login, a session cookie valid across all *.edmd.me services keeps you authenticated everywhere.

Version 2025.4
Container authentik-server, authentik-worker, authentik-redis
Host CT100 (192.168.8.100)
Ports 9100 (HTTP โ†’ internal 9000), 9444 (HTTPS โ†’ internal 9443)
URL https://auth.edmd.me
Mode Forward-domain (single provider covers all *.edmd.me)
Cookie domain edmd.me
Compose file /opt/authentik/docker-compose.yml on CT100
Data dir /mnt/container-data/authentik/ (media, redis, certs, templates)
Database PostgreSQL 17 on CT100 โ€” database authentik, user authentik
Architecture

The authentication flow works like this:

  1. User requests https://grafana.edmd.me/
  2. Caddy (CT103) sends a forward-auth subrequest to Authentik (CT100:9100) at /outpost.goauthentik.io/auth/caddy
  3. Authentik’s embedded outpost checks for a valid session cookie
  4. If authenticated: Returns 200 with user identity headers โ†’ Caddy proxies to the backend
  5. If not authenticated: Returns 302 redirect โ†’ user sees the Authentik login at auth.edmd.me
  6. After login, Authentik sets a cookie on .edmd.me and redirects back to the original URL
  7. Subsequent requests to any *.edmd.me service reuse the same cookie โ€” no re-login needed

Key components:

Component Role
authentik-server Main application server + embedded outpost (Go proxy)
authentik-worker Background tasks (Celery)
authentik-redis Session store + cache
PostgreSQL (existing) Stores users, flows, providers, applications
Caddy forward_auth Intercepts requests and checks auth status
Caddy Forward-Auth Configuration

To protect a service with Authentik, add the forward_auth block before the reverse_proxy in that service’s Caddy handle on CT103:

@grafana host grafana.edmd.me metrics.edmd.me
handle @grafana {
    forward_auth 192.168.8.100:9100 {
        uri /outpost.goauthentik.io/auth/caddy
        copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements 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
        trusted_proxies private_ranges
    }
    reverse_proxy 192.168.8.100:3200
}

Adding auth to a new service โ€” step by step:

  1. SSH into CT103: ssh root@192.168.8.221 then pct exec 103 -- bash
  2. Edit /etc/caddy/Caddyfile
  3. Find the service’s handle block
  4. Add the forward_auth block (copy from above) immediately before reverse_proxy
  5. Validate: caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
  6. Reload: caddy reload --config /etc/caddy/Caddyfile
  7. Test: visit the service URL โ€” you should get redirected to auth.edmd.me

The copy_headers line passes user identity downstream. Backend services can read X-Authentik-Username, X-Authentik-Email, etc. to know who’s logged in without implementing their own auth.

The trusted_proxies private_ranges line tells Caddy to trust forwarded headers from private IPs (the Authentik server).

Provider & Application
Setting Value
Provider name caddy-forward-auth
Provider PK 1
Mode forward_domain
External host https://auth.edmd.me
Cookie domain edmd.me
Access token validity 24 hours
Application name Homelab Services
Application slug homelab-services
Authorization flow default-provider-authorization-implicit-consent
Authentication flow default-authentication-flow
Invalidation flow default-invalidation-flow
Outpost Embedded (built into authentik-server)

The forward_domain mode is what allows a single provider to protect all *.edmd.me services. It sets a cookie on the .edmd.me domain, so once authenticated at any service, you’re authenticated everywhere.

Docker Compose

Located at /opt/authentik/docker-compose.yml on CT100:

services:
  authentik-redis:
    image: redis:alpine
    container_name: authentik-redis
    restart: unless-stopped
    command: --save 60 1 --loglevel warning
    volumes:
      - /mnt/container-data/authentik/redis:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
      start_period: 20s
      interval: 30s
      retries: 5
      timeout: 3s
    networks:
      - authentik

  authentik-server:
    image: ghcr.io/goauthentik/server:2025.4
    container_name: authentik-server
    restart: unless-stopped
    command: server
    environment:
      AUTHENTIK_SECRET_KEY: "<redacted>"
      AUTHENTIK_REDIS__HOST: authentik-redis
      AUTHENTIK_POSTGRESQL__HOST: 192.168.8.100
      AUTHENTIK_POSTGRESQL__PORT: 5432
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: "<redacted>"
      AUTHENTIK_HOST: "https://auth.edmd.me"
      AUTHENTIK_HOST_BROWSER: "https://auth.edmd.me"
    volumes:
      - /mnt/container-data/authentik/media:/media
      - /mnt/container-data/authentik/custom-templates:/templates
    ports:
      - "9100:9000"
      - "9444:9443"
    depends_on:
      authentik-redis:
        condition: service_healthy
    networks:
      - authentik

  authentik-worker:
    image: ghcr.io/goauthentik/server:2025.4
    container_name: authentik-worker
    restart: unless-stopped
    command: worker
    environment:
      AUTHENTIK_SECRET_KEY: "<redacted>"
      AUTHENTIK_REDIS__HOST: authentik-redis
      AUTHENTIK_POSTGRESQL__HOST: 192.168.8.100
      AUTHENTIK_POSTGRESQL__PORT: 5432
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: "<redacted>"
      AUTHENTIK_HOST: "https://auth.edmd.me"
      AUTHENTIK_HOST_BROWSER: "https://auth.edmd.me"
    volumes:
      - /mnt/container-data/authentik/media:/media
      - /mnt/container-data/authentik/custom-templates:/templates
      - /mnt/container-data/authentik/certs:/certs
    depends_on:
      authentik-redis:
        condition: service_healthy
    networks:
      - authentik

networks:
  authentik:
    driver: bridge

Important environment variables:

Variable Purpose
AUTHENTIK_HOST Tells the embedded outpost where to find itself externally
AUTHENTIK_HOST_BROWSER URL the browser uses for redirects
AUTHENTIK_SECRET_KEY Encryption key for sessions and tokens
AUTHENTIK_POSTGRESQL__HOST Uses CT100’s host IP (not container name โ€” bridge network doesn’t resolve DNS)
Data Directories

All on /mnt/container-data/authentik/ (nvmepool on CT100):

Path Owner Purpose
media/ UID 1000 Uploaded icons, branding assets
redis/ UID 999 Redis RDB snapshots
certs/ UID 1000 Custom certificates (if needed)
custom-templates/ UID 1000 Custom login page templates

Ownership matters: Authentik runs as UID 1000, Redis as UID 999. Wrong ownership causes crashes.

Administration

Admin interface: https://auth.edmd.me/if/admin/

Common tasks:

Task How
Add a user Admin โ†’ Directory โ†’ Users โ†’ Create
Change auth flow Admin โ†’ Flows & Stages
View active sessions Admin โ†’ Applications โ†’ homelab-services โ†’ Sessions
Check outpost health Admin โ†’ Applications โ†’ Outposts โ†’ authentik Embedded Outpost
API access curl -H "Authorization: Bearer <token>" http://192.168.8.100:9100/api/v3/

API token (for automation scripts): Stored in Vaultwarden. Create new tokens at Admin โ†’ Directory โ†’ Tokens & App Passwords.

Troubleshooting
Symptom Cause Fix
404 on protected service Outpost not matching the Host header Check provider mode is forward_domain with cookie_domain edmd.me. Restart authentik-server after changes
“Failed to detect forward URL from Caddy” Missing X-Forwarded-* headers Ensure requests come through Caddy’s forward_auth directive, not direct curl
500 on auth endpoint Outpost matched but missing forwarded headers Normal for bare curl tests; real Caddy requests include the needed headers
Login works but cookie doesn’t persist across services Cookie domain wrong Verify provider cookie_domain is edmd.me (not auth.edmd.me)
Outpost shows unhealthy Server restart needed after config change docker restart authentik-server on CT100
Redis crash on startup Wrong ownership on redis data dir chown -R 999:999 /mnt/container-data/authentik/redis
Worker crash / permission denied Wrong ownership on media dir chown -R 1000:1000 /mnt/container-data/authentik/media
Brand domain mismatch Brand still set to default API: PATCH /api/v3/core/brands/<uuid>/ with {"domain": "auth.edmd.me"}

Useful debug commands:

# Check outpost health (from CT100)
docker exec authentik-server ak healthcheck

# View server logs
docker logs --tail 50 authentik-server

# Check outpost endpoint directly
curl -s http://localhost:9100/outpost.goauthentik.io/auth/caddy
# Should return 200 (when called from localhost)

# Test from Caddy's perspective (from CT103)
curl -s -o /dev/null -w '%{http_code}' \
  -H "Host: auth.edmd.me" \
  -H "X-Forwarded-Host: grafana.edmd.me" \
  -H "X-Forwarded-Proto: https" \
  -H "X-Forwarded-Uri: /" \
  http://192.168.8.100:9100/outpost.goauthentik.io/auth/caddy
# Should return 401 (unauthenticated) or 302 (redirect to login)
Gotchas & Lessons Learned

These are the issues encountered during deployment (May 2026) so they don’t bite again:

  1. Brand domain must match external URL. Authentik’s brand/tenant domain field must be set to auth.edmd.me. The default is authentik-default which causes 404s on the outpost.

  2. AUTHENTIK_HOST env var is required. Without it, the embedded outpost doesn’t know its external URL and can’t construct redirect URLs. Set it in docker-compose, not just the outpost config.

  3. Provider mode matters. forward_single requires one provider per service and matches on the Host header. forward_domain handles all subdomains with one provider using a cookie domain โ€” use this for *.edmd.me.

  4. Restart after config changes. The outpost caches its configuration. After changing brand domain, provider mode, or outpost config via API, always docker restart authentik-server.

  5. Port 9100 not 9000. Authentik listens on 9000 internally, but we map to 9100 externally because Roon Server uses 9100-9200 on CT105 (no conflict since different container, but avoids confusion with the standard port).

  6. PostgreSQL via host IP. The authentik containers run on a custom bridge network (authentik). They can’t resolve other containers by name on the default bridge. Use CT100’s host IP 192.168.8.100 for PostgreSQL since port 5432 is published on all interfaces.

  7. Don’t add header_up Host to forward_auth. In forward_domain mode, the outpost needs to see the original Host header to know which domain is being accessed. Caddy’s forward_auth correctly passes it through.

Currently Protected Services
Service URL Status
Grafana grafana.edmd.me / metrics.edmd.me โœ… Protected

To protect additional services, add the forward_auth block from the Caddy Integration section to their Caddy handle.

Recommended candidates for protection:

  • Portainer (admin access to all containers)
  • Proxmox web UI (hypervisor control)
  • N8N (automation with full system access)
  • Prometheus (infrastructure metrics)
  • Dozzle (container logs)

Services to leave unprotected:

  • Plex (has its own auth, used by Plexamp on mobile)
  • Immich (has its own auth, used by mobile app)
  • Vaultwarden โ€” retired May 2026; CT104 destroyed. Credentials live in ~/Sync/ED/SECRETS.md. See Vaultwarden tombstone.