Single sign-on for all *.edmd.me services via forward-auth. Deployed May 17, 2026 on CT100.
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 |
The authentication flow works like this:
- User requests
https://grafana.edmd.me/ - Caddy (CT103) sends a forward-auth subrequest to Authentik (CT100:9100) at
/outpost.goauthentik.io/auth/caddy - Authentik’s embedded outpost checks for a valid session cookie
- If authenticated: Returns 200 with user identity headers โ Caddy proxies to the backend
- If not authenticated: Returns 302 redirect โ user sees the Authentik login at
auth.edmd.me - After login, Authentik sets a cookie on
.edmd.meand redirects back to the original URL - Subsequent requests to any
*.edmd.meservice 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 |
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:
- SSH into CT103:
ssh root@192.168.8.221thenpct exec 103 -- bash - Edit
/etc/caddy/Caddyfile - Find the service’s
handleblock - Add the
forward_authblock (copy from above) immediately beforereverse_proxy - Validate:
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile - Reload:
caddy reload --config /etc/caddy/Caddyfile - 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).
| 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.
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) |
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.
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.
| 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)
These are the issues encountered during deployment (May 2026) so they don’t bite again:
-
Brand domain must match external URL. Authentik’s brand/tenant
domainfield must be set toauth.edmd.me. The default isauthentik-defaultwhich causes 404s on the outpost. -
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.
-
Provider mode matters.
forward_singlerequires one provider per service and matches on the Host header.forward_domainhandles all subdomains with one provider using a cookie domain โ use this for*.edmd.me. -
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. -
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).
-
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 IP192.168.8.100for PostgreSQL since port 5432 is published on all interfaces. -
Don’t add
header_up Hostto 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.
| 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.