↓ Skip to main content

One Identity Provider for Everything

·8 mins
Table of Contents
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.

flowchart LR
  user["browser"]
  subgraph oracle["Oracle Cloud"]
    ak["Authentik"] --> guac["Guacamole"]
  end
  subgraph home["K8s cluster"]
    op["proxy outpost"] --> js["Jellyseerr"]
  end
  user -- "Cloudflare Tunnel" --> ak
  user --> op
  op -. "session check" .-> ak

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 | tr -d '\n')" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')" >> .env

The tr isn’t decoration: openssl wraps base64 output at 64 characters, so a 60-byte key comes out as two lines, and the second one lands in .env as a line of its own.

docker-compose.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
x-authentik-env: &authentik-env
  AUTHENTIK_POSTGRESQL__HOST: postgresql
  AUTHENTIK_POSTGRESQL__USER: authentik
  AUTHENTIK_POSTGRESQL__NAME: authentik
  AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
  AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}

services:
  postgresql:
    image: docker.io/library/postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${PG_PASS}
      POSTGRES_USER: authentik
      POSTGRES_DB: authentik
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"]
      interval: 30s
      retries: 5
    volumes:
      - postgres_data:/var/lib/postgresql/data

  server:
    image: ghcr.io/goauthentik/server:2026.2.3
    restart: unless-stopped
    command: server
    shm_size: 512mb
    environment: *authentik-env
    ports:
      - '9000:9000'
    volumes:
      - ./data:/data
      - ./custom-templates:/templates
    depends_on:
      postgresql:
        condition: service_healthy

  worker:
    image: ghcr.io/goauthentik/server:2026.2.3
    restart: unless-stopped
    command: worker
    shm_size: 512mb
    environment: *authentik-env
    volumes:
      - ./data:/data
      - ./custom-templates:/templates
    depends_on:
      postgresql:
        condition: service_healthy

volumes:
  postgres_data:

No Redis. Authentik moved its cache and task queue into PostgreSQL in 2025.10, so older compose files that still start a Redis container are running one nothing uses.

docker compose up -d

Authentik 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>:9000
rmt.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:

OrderStageType
0totp-validationAuthenticator Validate Stage
10default-authentication-loginUser 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 signs in with a password through the default authentication flow, not the Google source flow you just put TOTP on, 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-proxy
Mode:          Proxy
External host: https://rmt.merox.dev
Internal host: http://<guacamole-host>:8080
Authorization flow: default-provider-authorization-implicit-consent

Admin → Applications → Applications → Create

Name:     Guacamole
Slug:     guacamole
Provider: guacamole-proxy

The 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-oauth
Client type:  Confidential
Redirect URI: http://<tailscale-ip>:9000

Then an application for it, as with Guacamole, and copy the provider’s client ID and secret.

In Portainer: Settings → Authentication → OAuth → Custom

Client ID / Secret: from the provider
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/
Redirect URL:       http://<tailscale-ip>:9000
Logout URL:         https://<authentik-domain>/application/o/portainer/end-session/
User identifier:    preferred_username
Scopes:             openid profile email

The redirect URL has to match the provider’s character for character, scheme and port included.

Enable 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-proxy
Mode:          Proxy
External host: https://<your-app-domain>
Internal host: http://<app-service>.<namespace>.svc.cluster.local:<port>
SSL validation: OFF

Application: Admin → Applications → Applications → Create

Name:     Jellyseerr
Slug:     jellyseerr
Provider: jellyseerr-proxy

Outpost: Admin → Outposts → Create

Name:        k8s-outpost
Type:        Proxy
Integration: None
Apps:        Jellyseerr

After 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.

Deploying the outpost
#

The outpost is one container, ghcr.io/goauthentik/proxy, configured by two variables: where Authentik is, and the token. Keep its version in step with the server’s.

kubectl create secret generic authentik-outpost-secret \
  --from-literal=AUTHENTIK_TOKEN=<token>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
apiVersion: apps/v1
kind: Deployment
metadata:
  name: authentik-outpost
spec:
  replicas: 1
  selector:
    matchLabels: { app: authentik-outpost }
  template:
    metadata:
      labels: { app: authentik-outpost }
    spec:
      containers:
        - name: proxy
          image: ghcr.io/goauthentik/proxy:2026.2.3
          env:
            - name: AUTHENTIK_HOST
              value: https://sso.merox.dev
          envFrom:
            - secretRef:
                name: authentik-outpost-secret
          ports:
            - containerPort: 9000
---
apiVersion: v1
kind: Service
metadata:
  name: authentik-outpost
spec:
  selector: { app: authentik-outpost }
  ports:
    - name: http
      port: 9000

Then route the app’s hostname to the authentik-outpost service on port 9000, with an Ingress or a Gateway API route like the one in the other tab.

For GitOps with Flux and bjw-s app-template, under kubernetes/apps/<namespace>/authentik-outpost/:

app/secret.sops.yaml — encrypt with SOPS before committing:

apiVersion: v1
kind: Secret
metadata:
  name: authentik-outpost-secret
type: Opaque
stringData:
  AUTHENTIK_TOKEN: <token from outpost>

app/helmrelease.yaml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: authentik-outpost
spec:
  interval: 1h
  chartRef:
    kind: OCIRepository
    name: app-template
  values:
    controllers:
      authentik-outpost:
        replicas: 1
        strategy: RollingUpdate
        containers:
          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: tmp

app/httproute.yaml (Gateway API — adjust for your ingress if needed):

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: jellyseerr-external
spec:
  parentRefs:
    - name: external
      namespace: kube-system
      sectionName: https
  hostnames:
    - 'download.merox.dev'
  rules:
    - backendRefs:
        - name: authentik-outpost
          port: 9000

app/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ./secret.sops.yaml
  - ./helmrelease.yaml
  - ./httproute.yaml

Encrypt and push:

sops --encrypt --in-place app/secret.sops.yaml
git add . && git commit -m "feat: add authentik k8s outpost" && git push
Note

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
#

ServiceURLMethod
Guacamolermt.merox.devAuthentik Proxy
Portainer<tailscale-ip>:9000OAuth2 (Tailscale only)
Jellyseerrdownload.merox.devK8s 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.

The Authentik host is an Ansible role and the outpost a Flux app, both in meroxdotdev/infrastructure; the older cloudlab-merox repo is archived. Full homelab overview: the rack tour.