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 in hosts/x86_64-nixos/ereshkigal/default.nix. The origins attrset is the entire public surface. Each origin’s accessGroup selects which Entra group its Access policy allows: family (sg-voile-personal, the default — the media tiles below) or observability (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 /audiobookshelf subpath, so its callback is …/audiobookshelf/auth/openid/callback — both that and the root form are in redirectURIs, else CF returns invalid_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 the Login role, so set Default Roles to include Login (+ default library access) and enable Provision Accounts. Callbacks: /signin-oidc + /signout-callback-oidc.

  • Jellyfin (watch) — requires jellyfin-plugin-sso (install first). Add an OIDC provider named exactly cloudflare (the callback is /sso/OID/redirect/cloudflare, with a /sso/OID/r/cloudflare variant — 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.oauthEnvFiletelemetry/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-personal in Entra. SSO auto-provisions their app accounts on first login (Kavita needs the default Login role set once, above). Nothing to deploy, no per-app account to make.
  • Expose another service: add one entry to tsunaminoai.cloudflare.origins (with a logo/tags, and an oidc block if the app speaks OIDC), then deploy + nix run .#terraform-cloudflare -- apply. terranix creates the DNS record, the self-hosted Access app, and (if oidc.enable) the SaaS/OIDC app + policy. Admin tools may go in origins too — set accessGroup = "observability" so their Access policy is gated on sg-voile-personal-admins instead of the family group (that is how Grafana + ntfy are exposed). Anything with no origin stays Tailscale/LAN-only.
  • Media — the stack behind these URLs.
  • Authentication — the Cloudflare Access model used here.