homelab-blueprint/docs/network/public-app-sso.md
Brian Pooe fef521909b feat(forgejo): expose publicly at git.brianpooe.com with SSO-free access
Public repos are now browsable/clonable anonymously (HTTPS) and over SSH,
proxied through the Hetzner VPS. Unlike other public apps this is
deliberately not behind the oauth2-proxy/Zitadel gate, since Forgejo
already enforces its own per-repo visibility. Root redirects to the
.profile landing page. Documents the REQUIRE_SIGNIN_VIEW gotcha and the
Zitadel OIDC redirect_uri fix needed whenever FORGEJO_DOMAIN changes.
2026-07-19 18:34:13 +02:00

5.6 KiB

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:

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://<app>.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:

    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.