One Identity Provider for Everything

merox merox #security#homelab

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:

Terminal window
echo "PG_PASS=$(openssl rand -base64 36)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60)" >> .env

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

In 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_username

Enable 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-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 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:

Terminal window
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: v1
kind: Secret
metadata:
name: authentik-outpost-secret
type: Opaque
stringData:
AUTHENTIK_TOKEN: <token from outpost>

app/helmrelease.yaml:

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:
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: 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:

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

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.