One Identity Provider for Everything
Replacing scattered logins with Authentik on Oracle Cloud. Google login everywhere, proxy auth for Guacamole, OAuth2 for Portainer, and a K8s outpost for cluster services.
Every homelab reaches a point where the authentication situation becomes embarrassing. Mine had: Cloudflare Access for Portainer, Guacamole with its own user database, Homepage with no auth, Pi-hole with a hardcoded password. Each service its own island.
The fix isn’t another password manager. It’s a single identity provider that everything talks to. This is that setup — Authentik on Oracle Cloud, Google login everywhere, TOTP on all flows, and a proxy outpost inside K8s for cluster services.
If your homelab is four services and you’re the only user, this is more machinery than the problem deserves — a password manager and a mesh VPN cover it. The point where it starts paying is when other people need access, or when you have enough services that revoking one person means remembering all of them.
Architecture
Authentik runs on Oracle Cloud, not on the home K8s cluster. The reasoning is practical: if the cluster goes down, I still need to access Portainer to diagnose it — which means auth needs to be on a separate failure domain.
Internet └── Cloudflare Tunnel (Oracle Cloud) └── Authentik Server :9000 ├── sso.merox.dev → login portal └── rmt.merox.dev → Guacamole (proxied)
K8s Cluster (home) └── Authentik Proxy Outpost └── download.merox.dev → Jellyseerr (proxied)Portainer stays Tailscale-only — no internet exposure — but uses Authentik as its OAuth2 provider.
1 — Deploy Authentik
Generate the secrets first — these go in a .env file next to your compose:
echo "PG_PASS=$(openssl rand -base64 36)" >> .envecho "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60)" >> .envdocker-compose.yml:
services: postgresql: image: docker.io/library/postgres:16-alpine restart: unless-stopped environment: POSTGRES_PASSWORD: ${PG_PASS} POSTGRES_USER: authentik POSTGRES_DB: authentik volumes: - postgres_data:/var/lib/postgresql/data
redis: image: docker.io/library/redis:alpine restart: unless-stopped
34 collapsed lines
server: image: ghcr.io/goauthentik/server:2026.2.3 restart: unless-stopped command: server environment: AUTHENTIK_REDIS__HOST: redis AUTHENTIK_POSTGRESQL__HOST: postgresql AUTHENTIK_POSTGRESQL__USER: authentik AUTHENTIK_POSTGRESQL__NAME: authentik AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} ports: - '9000:9000' depends_on: - postgresql - redis
worker: image: ghcr.io/goauthentik/server:2026.2.3 restart: unless-stopped command: worker environment: AUTHENTIK_REDIS__HOST: redis AUTHENTIK_POSTGRESQL__HOST: postgresql AUTHENTIK_POSTGRESQL__USER: authentik AUTHENTIK_POSTGRESQL__NAME: authentik AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} depends_on: - postgresql - redis
volumes: postgres_data:docker compose up -dAuthentik is available at http://<your-server>:9000. Complete the initial setup wizard at /if/flow/initial-setup/.
DNS
sso.merox.dev uses Cloudflare’s Universal SSL wildcard (*.merox.dev) — no extra cert config needed. Add routes in Cloudflare Zero Trust pointing to your server’s Authentik port:
sso.merox.dev → http://<authentik-server>:9000rmt.merox.dev → http://<authentik-server>:9000 (for Guacamole)2 — Google as the identity source
In Google Cloud Console: APIs & Services → Credentials → OAuth 2.0 Client ID
Redirect URI:
https://sso.merox.dev/source/oauth/callback/google/In Authentik: Admin → Directory → Federation & Social Login → Create → Google — paste Client ID and Secret.
Visiting sso.merox.dev now auto-redirects to Google login.
Tip
New users arriving via Google are “External” by default and can’t reach the admin panel. Set the type to “Internal” and add them to authentik Admins if they need it.
3 — TOTP on every flow
Admin → Flows & Stages → Flows → default-source-authentication → Stage Bindings
Add two bindings:
| Order | Stage | Type |
|---|---|---|
| 0 | totp-validation | Authenticator Validate Stage |
| 10 | default-authentication-login | User Login Stage |
In the Authenticator Validate Stage: Device classes: TOTP, Not configured action: Force configure.
First-time login now prompts TOTP enrollment automatically.
Note
Disable akadmin once your own account works. It has a static password and bypasses the 2FA you just configured, which makes every stage binding above decorative. Admin → Directory → Users → akadmin → Disable.
4 — Guacamole, in proxy mode
Admin → Applications → Providers → Create → Proxy Provider
Name: guacamole-proxyMode: ProxyExternal host: https://rmt.merox.devInternal host: http://<guacamole-host>:8080Authorization flow: default-provider-authorization-implicit-consentAdmin → Applications → Applications → Create
Name: GuacamoleSlug: guacamoleProvider: guacamole-proxyThe Cloudflare tunnel already routes rmt.merox.dev to Authentik at :9000. Guacamole is never directly reachable — traffic hits Authentik first, always.
Note
Guacamole still shows its own login after Authentik. That’s intentional — Authentik answers who you are, Guacamole answers which connections you get.
5 — Portainer, native OAuth2
Portainer has direct Docker socket access — exposing it to the internet, even behind Authentik, is unnecessary risk. It stays Tailscale-only, but uses Authentik as its OAuth2 provider so there’s no separate login screen.
Bind it to the Tailscale interface instead of 0.0.0.0 — so only VPN-connected devices can reach it:
ports: - '<tailscale-ip>:9000:9000' - '<tailscale-ip>:9443:9443'Admin → Applications → Providers → Create → OAuth2/OpenID Provider
Name: portainer-oauthClient type: ConfidentialRedirect URI: http://<tailscale-ip>:9000In Portainer: Settings → Authentication → OAuth → Enable
Authorization URL: https://<authentik-domain>/application/o/authorize/Access Token URL: https://<authentik-domain>/application/o/token/Resource URL: https://<authentik-domain>/application/o/userinfo/User identifier: preferred_usernameEnable Hide internal authentication prompt — Portainer redirects directly to Authentik on load.
6 — A proxy outpost inside the cluster
The outpost runs inside the cluster as a pod and proxies back to sso.merox.dev for session validation. Every request hits the outpost first — if there’s no valid Authentik session, the user gets redirected to login.
In the Authentik UI
Provider: Admin → Applications → Providers → Create → Proxy Provider
Name: jellyseerr-proxyMode: ProxyExternal host: https://<your-app-domain>Internal host: http://<app-service>.<namespace>.svc.cluster.local:<port>SSL validation: OFFApplication: Admin → Applications → Applications → Create
Name: JellyseerrSlug: jellyseerrProvider: jellyseerr-proxyOutpost: Admin → Outposts → Create
Name: k8s-outpostType: ProxyIntegration: NoneApps: JellyseerrAfter creation → View Deployment Info → copy the token.
Tip
Integration: None is deliberate. With GitOps, Flux owns the deployment and Authentik owns only the config — which means the Authentik server never needs credentials to your cluster API.
Option A — Helm
The quickest path, works on any K8s setup:
kubectl create secret generic authentik-outpost-secret \ --from-literal=AUTHENTIK_TOKEN=<token>
helm upgrade --install authentik-outpost oci://ghcr.io/goauthentik/helm-charts/authentik-remote-cluster \ --set authentik.host=https://sso.merox.dev \ --set authentik.token=<token>Then expose the outpost with an Ingress or Gateway route pointing to port 9000.
Option B — Flux + app-template (GitOps)
For GitOps setups with Flux and bjw-s app-template. Create the following files under kubernetes/apps/<namespace>/authentik-outpost/:
app/secret.sops.yaml — encrypt with SOPS before committing:
apiVersion: v1kind: Secretmetadata: name: authentik-outpost-secrettype: OpaquestringData: AUTHENTIK_TOKEN: <token from outpost>app/helmrelease.yaml:
apiVersion: helm.toolkit.fluxcd.io/v2kind: HelmReleasemetadata: name: authentik-outpostspec: interval: 1h chartRef: kind: OCIRepository name: app-template values: controllers: authentik-outpost: replicas: 1 strategy: RollingUpdate containers:57 collapsed lines
app: image: repository: ghcr.io/goauthentik/proxy tag: 2026.2.3 env: AUTHENTIK_HOST: 'https://sso.merox.dev' AUTHENTIK_INSECURE: 'false' AUTHENTIK_HOST_BROWSER: 'https://sso.merox.dev' envFrom: - secretRef: name: authentik-outpost-secret probes: liveness: enabled: true custom: true spec: httpGet: path: /outpost.goauthentik.io/ping port: &port 9000 initialDelaySeconds: 10 periodSeconds: 10 readiness: enabled: true custom: true spec: httpGet: path: /outpost.goauthentik.io/ping port: *port initialDelaySeconds: 5 periodSeconds: 10 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: { drop: ['ALL'] } resources: requests: cpu: 10m limits: memory: 256Mi defaultPodOptions: securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 service: app: ports: http: port: *port persistence: tmpfs: type: emptyDir advancedMounts: authentik-outpost: app: - path: /tmp subPath: tmpapp/httproute.yaml (Gateway API — adjust for your ingress if needed):
apiVersion: gateway.networking.k8s.io/v1kind: HTTPRoutemetadata: name: jellyseerr-externalspec: parentRefs: - name: external namespace: kube-system sectionName: https hostnames: - 'download.merox.dev' rules: - backendRefs: - name: authentik-outpost port: 9000app/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - ./secret.sops.yaml - ./helmrelease.yaml - ./httproute.yamlEncrypt and push:
sops --encrypt --in-place app/secret.sops.yamlgit add . && git commit -m "feat: add authentik k8s outpost" && git pushNote
Jellyseerr has no OIDC support, so it shows its own login too. The outpost is a gate, not a replacement — unauthenticated requests never reach the app at all, and the Jellyseerr session persists once you’re through.
Result
| Service | URL | Method |
|---|---|---|
| Guacamole | rmt.merox.dev | Authentik Proxy |
| Portainer | <tailscale-ip>:9000 |
OAuth2 (Tailscale only) |
| Jellyseerr | download.merox.dev | K8s Outpost Proxy |
One login at sso.merox.dev. Google auth + TOTP. Every externally-exposed service requires a valid Authentik session before traffic reaches it.
For apps with native OIDC support — Grafana, n8n — the same pattern applies but without the double login: use an OAuth2 provider instead of the proxy outpost.
Infrastructure repo: github.com/meroxdotdev/cloudlab-merox. Full homelab overview: The Rack — Homelab 2026.