Family Media via Cloudflare Zero Trust#
Books, music, audiobooks, and video for friends & family, in the browser, from anywhere — authenticated at the Cloudflare edge, with Tailscale staying the admin-only backhaul.
Architecture#
family browser ──▶ Cloudflare Access (sc2in team, Entra IdP)
│ policy: per-origin Entra group (family / observability)
▼
cloudflared tunnel "ereshkigal-media" (in-flake, ereshkigal)
│ verifies step-ca origin cert against falseblue root
▼
nginx origin vhosts, loopback-only (ports 9101–9104)
│ optional: CF email → app identity header
▼
Jellyfin / Navidrome / Audiobookshelf / Kavita (127.0.0.1)
- Flake side:
tsunaminoai.cloudflare(modules/nixos/cloudflare/), wired inhosts/x86_64-nixos/ereshkigal/default.nix. Theoriginsattrset is the entire public surface. Each origin’saccessGroupselects which Entra group its Access policy allows:family(sg-voile-personal, the default — the media tiles below) orobservability(sg-voile-personal-admins— the admin panes Grafana + ntfy; see status page). Raw servarr/admin services that have no origin stay Tailscale/LAN-only. - The tunnel is locally managed: ingress lives in Nix, not the dashboard. The ZT dashboard shows the tunnel healthy but without hostname routes — that is expected.
- Every tile is single-sign-on to the same Entra identity — Navidrome via a trusted header, the rest via OIDC with CF Access as the IdP (see Single Sign-On).
- Each origin also carries a
logo+tags(e.g.family,entertainment) that terranix applies to the App Launcher tiles (icons + the tag filter).
| Tile | Hostname | Service | Backend | Port | SSO |
|---|---|---|---|---|---|
| Watch | watch.inaba.network | Jellyfin | 127.0.0.1:8096 | 9101 | OIDC (jellyfin-plugin-sso) |
| Music | music.inaba.network | Navidrome | 127.0.0.1:4533 | 9102 | header (Remote-User) |
| Audiobooks | audiobooks.inaba.network | Audiobookshelf | 127.0.0.1:8000 | 9103 | OIDC (native) |
| Books | books.inaba.network | Kavita | 127.0.0.1:5000 | 9104 | OIDC (native) |
| Ask | ask.inaba.network | paperless-ask | 127.0.0.1:8015 | 9105 | header (X-Remote-User) |
| Status | status.inaba.network | Gatus (family tenant) | 127.0.0.1:8430 | 9108 | CF Access only |
| Chat | chat.inaba.network | Open-WebUI | 127.0.0.1:3000 | 9109 | header (X-Remote-User) |
Observability panes — same mechanism, but accessGroup = "observability"
(sg-voile-personal-admins) instead of the family group:
| Tile | Hostname | Service | Backend | Port | SSO |
|---|---|---|---|---|---|
| Grafana | grafana.inaba.network | Grafana | 127.0.0.1:3080 | 9106 | OIDC (native, role-mapped) |
| Alerts | alerts.inaba.network | ntfy (web UI) | 127.0.0.1:2586 | 9107 | CF Access only (topic + credentials) |
One-time setup runbook#
Prerequisites: access to the sc2in Cloudflare account and the Entra tenant.
Gather the Object ID of the sg-voile-personal group (Entra portal →
Groups → sg-voile-personal → Overview).
1. Create the tunnel (any machine with cloudflared)#
nix shell nixpkgs#cloudflared
cloudflared tunnel login # pick the inaba.network zone
cloudflared tunnel create ereshkigal-media
# note the UUID it prints; credentials land in ~/.cloudflared/<uuid>.json
2. Store credentials + wire the flake#
# from the repo root, on a host with your sops age key:
nix run nixpkgs#sops -- set secrets.yaml \
'["cloudflare"]["tunnel-credentials"]' "$(jq -Rs . < ~/.cloudflared/<uuid>.json)"
Then in hosts/x86_64-nixos/ereshkigal/default.nix:
tsunaminoai.cloudflare.enable = true; and tunnel.tunnelId = "<uuid>";.
Deploy. Delete ~/.cloudflared/<uuid>.json afterwards.
3 + 4. DNS routes + Access applications (automated via terranix)#
These steps are fully managed in Nix — the origins attrset in
hosts/x86_64-nixos/ereshkigal/default.nix drives both NixOS config and
Cloudflare resources. One-time setup:
The account/zone IDs, the Entra IdP ID, and the sg-voile-personal group ID are
baked into the generator (modules/flake/terraform/default.nix constants — none
secret). The only secret is the Cloudflare API token, which the wrapper reads
from sops (cloudflare/terraform-token; scope Zone:DNS:Edit on inaba.network
plus Account:Zero Trust:Edit on sc2in — distinct from the DNS-only
cloudflare/inaba ACME token). So the whole apply is just:
nix run .#terraform-cloudflare -- plan # review
nix run .#terraform-cloudflare -- apply
Per origin this creates a CNAME + a self-hosted Access app + an Allow policy
(gated to sg-voile-personal), plus a SaaS/OIDC app + policy for any origin with
oidc.enable. Tiles appear in the App Launcher at
https://sc2in.cloudflareaccess.com. Inspect the generated JSON with:
nix build .#terraform-cloudflare-config && jq . result
First run only — adopting hand-made resources
Access tags are referenced by name, not created (CF errors on
create-when-exists), so pre-existing family/entertainment tags are fine.
If Access apps/DNS were made by hand before terranix, adopt them into
state first so apply reconciles instead of colliding:
nix run .#terraform-cloudflare -- import-existing.
5. Single Sign-On#
For the cross-cutting model — how Entra groups become Access policies and how claims/headers reach each app — see Authentication & Access. This section is the app-by-app setup and gotchas.
Every tile signs in with the same Entra identity behind CF Access. Two mechanisms, chosen by what each app supports.
Header SSO — Navidrome (zero app config). CF Access injects
Cf-Access-Authenticated-User-Email; the origin vhost maps it to Remote-User
(the identityHeader option) and Navidrome trusts it (ReverseProxyUserHeader,
whitelist 127.0.0.1/32), auto-creating the user on first visit — no login page,
nothing to configure. Native Subsonic apps can’t use this (they need a Navidrome
password); browser access is seamless.
OIDC with Cloudflare Access as the IdP — Audiobookshelf, Kavita, Jellyfin.
Set oidc.enable = true on the origin (+ its redirectURIs); terranix creates a
CF Access SaaS/OIDC app + Allow policy and emits three outputs. The user
already has a CF Access session at the edge, so the app’s OIDC redirect is
silent. After apply, read the outputs and paste them into the app once — the
app stores OIDC in its own DB (set-once, not a recurring update):
nix run .#terraform-cloudflare -- output <name>_oidc_discovery_url
nix run .#terraform-cloudflare -- output -raw <name>_oidc_client_id
nix run .#terraform-cloudflare -- output -raw <name>_oidc_client_secret # first apply only
(<name> = audiobooks, books, watch, or grafana.) Per-app setup + gotchas:
-
Audiobookshelf (
audiobooks) — Settings → Authentication → OpenID Connect: Authority = the discovery URL, paste client ID/secret, enable match-by-email + auto-register. ABS runs under the/audiobookshelfsubpath, so its callback is…/audiobookshelf/auth/openid/callback— both that and the root form are inredirectURIs, else CF returnsinvalid_request: Invalid redirect_uri. -
Kavita (
books) — Settings → OpenID Connect: Authority = discovery URL, client ID/secret, then restart Kavita (systemctl restart kavita) — Authority/ClientID/Secret changes require it. Gotcha: auto-provisioned users get no roles and are denied (“unauthorized”) without theLoginrole, so set Default Roles to includeLogin(+ default library access) and enable Provision Accounts. Callbacks:/signin-oidc+/signout-callback-oidc. -
Jellyfin (
watch) — requiresjellyfin-plugin-sso(install first). Add an OIDC provider named exactlycloudflare(the callback is/sso/OID/redirect/cloudflare, with a/sso/OID/r/cloudflarevariant — both registered); set the OpenID endpoint to the discovery URL, client ID/secret, enable “Enable Authorization by Plugin” + user creation, and add the SSO login button. Family accounts are created on first SSO login. -
Grafana (
grafana) — config lives in Nix, not the app DB, so instead of pasting into a UI you write the values into a sops EnvironmentFile the Grafana unit reads (telemetry.server.grafana.oauthEnvFile→telemetry/grafana-oidc-env). Grafana needs the endpoint URLs explicitly (it doesn’t auto-discover), so read them out of the discovery doc:disc=$(nix run .#terraform-cloudflare -- output -raw grafana_oidc_discovery_url) curl -s "$disc" | jq -r '.authorization_endpoint,.token_endpoint,.userinfo_endpoint' nix run nixpkgs#sops -- set secrets.yaml '["telemetry"]["grafana-oidc-env"]' "$(printf '%s' \ "GF_AUTH_GENERIC_OAUTH_CLIENT_ID=<id> GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=<secret> GF_AUTH_GENERIC_OAUTH_AUTH_URL=<authorization_endpoint> GF_AUTH_GENERIC_OAUTH_TOKEN_URL=<token_endpoint> GF_AUTH_GENERIC_OAUTH_API_URL=<userinfo_endpoint> " | jq -Rs .)"
Then deploy (or systemctl restart grafana). Role mapping is in Nix
(grafana.oauthRoleAttributePath): sg-voile-personal-admins → Grafana
Admin, everyone else → Viewer. Gotcha: the CF Access SaaS app must
be set to include group memberships in the OIDC token, and the JMESPath must
match the token’s actual claim (groups[*].id by default) — if you land as
Viewer despite being in the admin group, that’s the thing to check.
Verification#
# origin isolation: from a LAN host — must FAIL (loopback-only origins)
curl -k --connect-timeout 3 https://192.168.0.20:9102/ ; echo "expect: refused"
# forged identity header direct to a backend — must show the login page,
# not an authenticated session (run on ereshkigal):
curl -s -H "Remote-User: mallory@example.com" http://127.0.0.1:4533/app/ | head
# tunnel health:
journalctl -u 'cloudflared-tunnel-*' -n 20 # expect 4 registered connections, no TLS errors
Then in a browser as a family member: log in at each hostname (Entra →
service), play a video on Watch (confirm seek works), confirm Music lands
logged-in without a Navidrome login page. Negative test: an Entra account
not in sg-voile-personal must hit the Access block page.
Ongoing#
- Add a family member: add them to
sg-voile-personalin Entra. SSO auto-provisions their app accounts on first login (Kavita needs the defaultLoginrole set once, above). Nothing to deploy, no per-app account to make. - Expose another service: add one entry to
tsunaminoai.cloudflare.origins(with alogo/tags, and anoidcblock if the app speaks OIDC), thendeploy+nix run .#terraform-cloudflare -- apply. terranix creates the DNS record, the self-hosted Access app, and (ifoidc.enable) the SaaS/OIDC app + policy. Admin tools may go inoriginstoo — setaccessGroup = "observability"so their Access policy is gated onsg-voile-personal-adminsinstead of the family group (that is how Grafana + ntfy are exposed). Anything with no origin stays Tailscale/LAN-only.
Related#
- Media — the stack behind these URLs.
- Authentication — the Cloudflare Access model used here.