# Public App Exposure with Zitadel SSO (oauth2-proxy on the VPS) How LAN apps are exposed to the public internet behind Zitadel SSO, using the Hetzner VPS as the edge. Currently on this path: Koffan (`koffan.brianpooe.com`) and IT-Tools (`it-tools.brianpooe.com`); the setup is built to be reused for any future app. > [!NOTE] > Forgejo (`git.brianpooe.com`) also runs through this VPS edge but is > deliberately **not** on this SSO path — public repos need to be > browsable/clonable with no login, like GitHub. It gets its own plain > `reverse_proxy` vhost (`crowdsec` + `rate_limit`, no `forward_auth`) in > `caddy/Caddyfile.template`. Forgejo enforces its own per-repo visibility > regardless of network path, so private repos stay private without an SSO > gate. Its public git-over-SSH path is separate again — a raw TCP forward, > not Caddy at all — see the VPS's `docs/netbird-acl-playbook.md`. > [!NOTE] > This intentionally does **not** use NetBird's built-in reverse-proxy > ("Services") feature. Self-hosted NetBird requires running your own proxy > cluster, and that proxy manages its own TLS — the edge in front of it must do > raw TLS passthrough, which only Traefik supports. Our edge is Caddy + > CrowdSec, and replacing it (or losing CrowdSec in front of public apps) was > not worth it. oauth2-proxy gives the same Zitadel SSO gate while keeping the > existing edge. ## Request path ``` Browser └─ https://koffan.brianpooe.com (wildcard *.brianpooe.com → VPS) └─ VPS Caddy (+ CrowdSec) ├─ forward_auth → oauth2-proxy:4180 → Zitadel OIDC (SSO gate) └─ reverse_proxy https://10.0.15.5 over NetBird (Pi Caddy) · Host/SNI rewritten to koffan.home.brianpooe.com └─ Pi Caddy → koffan LXC 10.0.10.12:8080 ``` Key constraint: the VPS NetBird peer only has a route to the Caddy Pi (`10.0.15.5/32`, network "Pi Caddy RS") — it can **not** reach LAN apps directly. Every public app is therefore proxied through the Pi Caddy, which keeps a single ingress point into the LAN and reuses the existing CADDY→TRUSTED firewall policies. No NetBird ACL changes are needed per app. ## Components (all on the VPS, `~/netbird-vps/`) | Piece | Where | Notes | |---|---|---| | oauth2-proxy | `oauth2-proxy/docker-compose.yml.template` | Joins the `caddy` docker network, listens on 4180. All config via `OAUTH2_PROXY_*` env vars in the root `.env`. | | Caddy vhost | `caddy/Caddyfile.template` (koffan block) | `forward_auth` + 401→login redirect + proxy to the Pi. | | Zitadel app | project **public-apps** → application **oauth2-proxy** | Web / authorization code / PKCE; client secret lives only in the VPS `.env`. | The session cookie domain is `.brianpooe.com`, so once a user has logged in to one gated app, other gated apps on subdomains reuse the session. `OAUTH2_PROXY_REDIRECT_URL` is deliberately **not** set: the proxy derives the OIDC callback from the request host, so one instance serves every gated app — each app's callback URL just needs to be registered on the Zitadel application. ## The Caddy vhost pattern oauth2-proxy's `/oauth2/auth` endpoint only answers 202 or 401 — it never redirects — so the vhost needs an explicit 401 handler to start the login flow: ```caddy koffan.brianpooe.com { import common_tls import common_security import common_access_log # oauth2-proxy's own endpoints (login start, OIDC callback, sign-out). handle /oauth2/* { crowdsec reverse_proxy oauth2-proxy:4180 } handle { crowdsec forward_auth oauth2-proxy:4180 { uri /oauth2/auth copy_headers X-Auth-Request-User X-Auth-Request-Email @unauthed status 401 handle_response @unauthed { redir * /oauth2/start?rd={scheme}://{host}{uri} } } reverse_proxy https://10.0.15.5 { header_up Host koffan.home.brianpooe.com transport http { tls_server_name koffan.home.brianpooe.com } } } } ``` The Host/SNI rewrite is what lets the Pi Caddy match its internal `koffan.home.brianpooe.com` vhost and present its valid wildcard certificate. ## Adding another app 1. Register the new callback URL on the existing **oauth2-proxy** app in Zitadel (`https://.brianpooe.com/oauth2/callback`). This can be done via the management API with the admin service-user PAT stored on the VPS (`zitadel/zitadel-data/admin.pat`, service user `automation`, ORG_OWNER). 2. Copy the koffan block in `caddy/Caddyfile.template`, change the hostname and internal target. 3. Render and restart: ```bash cd ~/netbird-vps ./substitute_env.sh caddy/Caddyfile.template caddy/Caddyfile .env docker compose restart caddy ``` No DNS work needed — `*.brianpooe.com` already points at the VPS. ## Gotchas - **Restart Caddy, don't reload.** `substitute_env.sh` recreates the rendered `Caddyfile` with a new inode, but the container bind-mounts the single file. `caddy reload` then keeps serving the *old* config with no error. Always `docker compose restart caddy` after re-rendering. - **Zitadel image has no shell.** `docker exec ... cat` fails; read files from the container with `docker cp zitadel-external-tls:/path - | tar -xO`. - **The login-client PAT is not an admin.** It only carries `IAM_LOGIN_CLIENT` and gets 403 on project/app creation — use the `automation` service-user PAT for management API calls. - **Double login on koffan.** Koffan's own shared household password is still enabled behind the SSO gate. `KOFFAN_DISABLE_AUTH=true` would make Zitadel the only gate, but it also removes the password for LAN users who reach koffan directly via the Pi Caddy.