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.
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 plainreverse_proxyvhost (crowdsec+rate_limit, noforward_auth) incaddy/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'sdocs/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
-
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 userautomation, ORG_OWNER). -
Copy the koffan block in
caddy/Caddyfile.template, change the hostname and internal target. -
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.shrecreates the renderedCaddyfilewith a new inode, but the container bind-mounts the single file.caddy reloadthen keeps serving the old config with no error. Alwaysdocker compose restart caddyafter re-rendering. - Zitadel image has no shell.
docker exec ... catfails; read files from the container withdocker cp zitadel-external-tls:/path - | tar -xO. - The login-client PAT is not an admin. It only carries
IAM_LOGIN_CLIENTand gets 403 on project/app creation — use theautomationservice-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=truewould make Zitadel the only gate, but it also removes the password for LAN users who reach koffan directly via the Pi Caddy.