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.
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 OAuth
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 via Google are “External” by default — they can’t access the admin
panel. Set type to “Internal” and add to authentik Admins group if needed.
3. Two-Factor Authentication
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 after your account is set up — it has a static password and
bypasses 2FA. Admin → Directory → Users → akadmin → Disable.
4. Guacamole — 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 shows its own login after Authentik auth — that’s intentional. Authentik handles identity; Guacamole handles access control to specific connections.
5. Portainer — OAuth2 Native SSO
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. K8s Outpost — Jellyseerr
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.
Authentik UI setup
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 intentional — if you use GitOps, your tooling owns the deployment and Authentik only owns the config. No cluster API access needed from the Authentik server.
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 doesn’t support OIDC, so it still shows its own login after Authentik auth. The outpost acts as a gate — unauthenticated requests never reach the app. Once logged in to Jellyseerr, the session persists.
Result
| Service | URL | Method |
|---|---|---|
| Guacamole | rmt.merox.dev | Authentik Proxy |
| Portainer | 100.72.22.38 | 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.