Skip to main content
Overview

One Identity Provider for Everything

Merox
Merox HPC Sysadmin
6 min read
Homelab Intermediate

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

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 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://<your-guacamole-domain>
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:
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.example.com'
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

ServiceURLMethod
Guacamolermt.merox.devAuthentik Proxy
Portainer100.72.22.38: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.


Infrastructure repo: github.com/meroxdotdev/cloudlab-merox. Full homelab overview: The Rack — Homelab 2026.

Share this post