Xiber NetOS — Public Hostname & SSO
Cloudflare Tunnel deployment with Microsoft Entra ID single sign-on.
Overview
NetOS can be exposed publicly through a Cloudflare Tunnel and protected with Microsoft Entra ID SSO. The auth layer sits in front of the app — unauthenticated users never reach the API or web UI.
Internet
→ cloudflared (Cloudflare Tunnel for netos.xiberian.net)
→ sso-proxy (oauth2-proxy, Entra OIDC, :4180)
→ public-proxy (nginx, :8080 — routes /api → FastAPI, / → Next.js)
→ api + web containersThis ordering matches docker-compose.public.yml: cloudflared depends on sso-proxy, sso-proxy's upstream is http://public-proxy:8080, and public-proxy proxies to web:3000 and api:8000. Authentication happens at sso-proxy before any request reaches nginx or the app.
Target Hostname
https://netos.xiberian.netThe hostname routes to the sso-proxy container, not directly to web or API.
Runtime Components
| Container | Role | Port |
|---|---|---|
cloudflared | Outbound tunnel to Cloudflare edge | — |
sso-proxy | OAuth2-Proxy with Entra OIDC | 4180 |
public-proxy | Nginx reverse proxy | 8080 |
web | Next.js UI | 3000 |
api | FastAPI backend | 8000 |
postgres | PostgreSQL 16 | 5432 |
redis | Redis 7 | 6379 |
Routing Rules (Nginx)
| Path | Destination |
|---|---|
/ | Web app (Next.js) |
/api/ | FastAPI backend |
/docs-api/ | FastAPI Swagger UI |
/openapi.json | OpenAPI schema |
Prerequisites
- Cloudflare account with Zero Trust enabled
- Microsoft Entra ID tenant (Xiber:
4944843d-e347-4fe7-a279-4006ce5efc33) - Docker Engine 24+ with Docker Compose v1 (
docker-compose 1.29.x). The NetOS server does not have the Compose v2 plugin (docker compose) available undersudo; use the hyphenateddocker-composebinary.
Setup Steps
1. Create Entra App Registration
- Go to Azure Portal → App registrations → New registration
- Name:
Xiber NetOS - Redirect URI (Web):
https://netos.xiberian.net/oauth2/callback - Note the Application (client) ID and create a client secret
Recommended access policy:
- Assign only Xiber users or a dedicated
NetOS-Userssecurity group - Require MFA and compliant device if available
- Map Entra groups to NetOS roles (exec, finance, network_eng, etc.)
2. Create Environment File
Copy the example and fill in real values:
cp infra/docker/.env.public.example infra/docker/.env.publicRequired variables (consumed by docker-compose.public.yml; see infra/docker/.env.public.example):
| Variable | Description | Example |
|---|---|---|
NETOS_PUBLIC_HOSTNAME | Public hostname (cookie/whitelist domain) | netos.xiberian.net |
NETOS_PUBLIC_URL | Public base URL; oauth2-proxy builds the redirect URL from this | https://netos.xiberian.net |
NETOS_ALLOWED_EMAIL_DOMAIN | Email domain oauth2-proxy will admit (--email-domain) | xiber.net |
ENTRA_OIDC_ISSUER_URL | Entra OIDC issuer | https://login.microsoftonline.com/{tenant_id}/v2.0 |
ENTRA_OIDC_EMAIL_CLAIM | OIDC claim used as email (default preferred_username) | preferred_username |
ENTRA_OIDC_USER_ID_CLAIM | OIDC claim used as user id (default oid) | oid |
ENTRA_CLIENT_ID | App registration client ID | 29833a06-d27e-... |
ENTRA_CLIENT_SECRET | App registration client secret | b-V8Q~bO_YRL... |
OAUTH2_PROXY_COOKIE_SECRET | Random 32-byte base64 string | (generate below) |
CLOUDFLARE_TUNNEL_TOKEN | Tunnel token from Cloudflare dashboard | eyJhIjoi... |
ALLOW_DEV_AUTH | Must be false in production | false |
TRUST_PROXY_AUTH_HEADERS | Trust identity headers from the proxy | true |
CORS_ALLOWED_ORIGINS | Allowed browser origin(s) | https://netos.xiberian.net |
NETOS_ALLOWED_EMAIL_DOMAINis an email-domain filter enforced by oauth2-proxy. If login succeeds at Microsoft but oauth2-proxy then returns 403, the UPN suffix likely does not match this domain — temporarily set it to*to confirm, then restore the real domain.
Generate cookie secret:
python3 -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"3. Create Cloudflare Tunnel
- Go to Cloudflare Zero Trust → Tunnels → Create a tunnel
- Name:
netos - Copy the tunnel token into
.env.publicasCLOUDFLARE_TUNNEL_TOKEN - Add a public hostname:
- Hostname:
netos.xiberian.net - Service:
http://sso-proxy:4180
- Optionally add a Cloudflare Access policy (Microsoft Entra ID, Xiber users only) as a second enforcement layer
4. Start the Public Stack
The public overlay layers on the server base file (docker-compose.server.yml), not the local dev file. Use Compose v1 with the netos project name:
cd infra/docker
docker-compose -p netos \
--env-file .env.public \
-f docker-compose.server.yml \
-f docker-compose.public.yml \
--profile public \
up -dDo not use
docker-compose.ymlas the base here — that is the local dev stack (ALLOW_DEV_AUTH=true, ports published on all interfaces) and must not front the public hostname. If you hit the Compose v1KeyError: 'ContainerConfig'recreate bug,docker stop+docker rmthe affectednetos_<svc>_1container before re-runningup -d(see the Admin & Operations deploy notes). To redeploy only the public web container,scripts/deploy-prod-web.shdoes this for you.
5. Verify
Check containers:
docker-compose -p netos \
--env-file .env.public \
-f docker-compose.server.yml \
-f docker-compose.public.yml \
--profile public \
psOpen in browser:
https://netos.xiberian.netYou should be redirected to Microsoft login. After authenticating, you'll see the NetOS UI.
Architecture Notes
Why OAuth2-Proxy + Cloudflare Access?
Both layers serve complementary purposes:
| Layer | Purpose |
|---|---|
| Cloudflare Access | Edge enforcement — blocks unauthenticated traffic before it reaches your server |
| OAuth2-Proxy | Origin enforcement — protects the app even if tunnel routing is misconfigured |
Keep both. The overhead is negligible and the defense-in-depth is worth it.
How the API consumes SSO identity today
In production the API does not validate JWTs itself. It trusts identity headers that oauth2-proxy sets after a successful Entra login:
- oauth2-proxy authenticates the user against Entra and sets
X-Auth-Request-Email/X-Auth-Request-User(--set-xauthrequest=true,--pass-user-headers=true). - nginx (
public-proxy) forwards those headers to the API. - The API accepts them only when
TRUST_PROXY_AUTH_HEADERS=trueand the request's client IP falls insideTRUSTED_AUTH_PROXY_CIDRS— so headers cannot be spoofed by clients outside the Docker/proxy network. - NetOS then looks up the internal
usersrecord and applies its own roles/permissions. See Authorization & RBAC.
Note the proxy is configured with --pass-authorization-header=false and --pass-access-token=false, so the raw Entra token is intentionally not forwarded to the app.
Future work: in-app JWT signature validation against the Entra JWKS endpoint and direct Entra-group → NetOS-role mapping. Tracked in Roadmap → Authentication.
Security Checklist
| Item | Status |
|---|---|
| HTTPS via Cloudflare | Automatic with tunnel |
| Entra OIDC authentication | Via OAuth2-Proxy |
| Cloudflare Access policy | Recommended additional layer |
| Cookie secret rotation | Manual — regenerate and restart periodically |
| Client secret rotation | Via Azure Portal — update .env.public after rotation |
| MFA enforcement | Configure in Entra Conditional Access |
| Role-based access | Database-driven RBAC live; Entra-group → role mapping planned |
| Dev auth disabled in production | ALLOW_DEV_AUTH=false (server default) — required so dev@xiber.com cannot auto-bootstrap as Super Admin |
| Identity-header spoofing | Blocked by TRUSTED_AUTH_PROXY_CIDRS; only the proxy network may set identity headers |
Troubleshooting
| Issue | Solution |
|---|---|
| Redirect loop after login | Check OAUTH2_PROXY_COOKIE_SECRET is exactly 32 bytes base64-encoded |
| 502 Bad Gateway | Verify sso-proxy and public-proxy containers are running |
| Tunnel not connecting | Check CLOUDFLARE_TUNNEL_TOKEN is correct; verify tunnel is active in CF dashboard |
| CORS errors in browser | Verify Nginx config allows the public hostname as an origin |
| "Access Denied" after login | User may not be in the assigned Entra group; check app assignment |
| 403 immediately after Microsoft login | oauth2-proxy --email-domain mismatch — the UPN suffix is not NETOS_ALLOWED_EMAIL_DOMAIN. Set it to * to confirm, then restore the real domain |
| API returns 401/403 for a logged-in user | Confirm TRUST_PROXY_AUTH_HEADERS=true, that nginx forwards X-Auth-Request-Email, and that the request reaches the API from a TRUSTED_AUTH_PROXY_CIDRS address. A 403 can also mean the user has no active internal users record with app_access — provision them (or add to RBAC_BOOTSTRAP_SUPER_ADMIN_EMAILS) per Authorization & RBAC |
