Skip to content

Networking

Two separate overlay networks do two separate jobs here — easy to conflate, worth keeping distinct:

  • Tailscale connects the three hosts to each other.
  • Cloudflare (Tunnel, Access, DNS, Pages) connects the outside world to Lana — nothing else.

All three hosts share one tailnet:

Tailscale name Host Tailscale IP
vps1 Lana 100.115.215.23
krieger Krieger 100.90.162.24
pam Pam 100.109.83.48

vps1 (Lana) also offers itself as a Tailscale exit node. This is what lets Lana’s wake-proxy containers reach Krieger’s Ollama/ComfyUI over a private, encrypted path with no home-network port forwarding — see Krieger.

There are a couple of other tailnet members (a phone, this Mac) not relevant to the server-side stack.

Nothing on Lana has a port directly exposed to the internet. Public traffic reaches Docker-hosted apps through one path only:

Browser → Cloudflare (DNS + Access) → Cloudflare Tunnel → cloudflared on Lana → app container

DNS. The spelvin.dev zone lives in Cloudflare. Every app hostname (git.spelvin.dev, roadmap.spelvin.dev, etc.) is a proxied CNAME to <tunnel-id>.cfargotunnel.com — never an A record pointing at Lana’s real IP.

Tunnel. One named Cloudflare Tunnel (confusingly named flowviz in the dashboard, after whichever app got tunneled first — it now carries every migrated app, not just FlowViz) runs as a cloudflared container on Lana, outbound-only. Its ingress config is a flat list of hostname → internal service:port rules, e.g. git.spelvin.dev → http://gitea:3000, terminated by a catch-all 404. This config lives in Cloudflare’s dashboard/API, not as a file in docker-infra.

Access. Sits in front of the Tunnel (and, separately, in front of Cloudflare Pages sites when needed) as the actual login gate. Two patterns are in use:

  • self_hosted Access Application — gates a hostname outright; nothing reaches the app without passing Access’s login page first. Used for apps with no native SSO of their own (FlowViz, Windmill).
  • saas Access Application — Access acts as an OIDC identity provider that the app itself talks to, so the app has its own login page but delegates identity to Access rather than maintaining its own user database. Used for apps with native OIDC support (Gitea, LiteLLM, Open WebUI, Leantime).

Access policies used to be inline, per-app, single-email-match rules — that model is retired. As of today, every app is gated by one of two reusable Access policies instead:

  • “Only Me” — for personal-admin-only apps (FlowViz, Windmill, and the rest of the single-user apps).
  • A household policy — for apps shared with other household members.

Both match via an OIDC Claims rule against Authentik group membership (a groups claim) rather than by email address, so adding or removing someone is a group-membership change in Authentik, not an edit to every app’s Access policy. Whoever is authenticating can do so through either of two Identity Providers registered in Access, shown side by side on every login screen since no app restricts allowed_idps:

  • Cloudflare — Access’s built-in email one-time-PIN provider. The original/default option.
  • Authentik (Passkey) — a generic OIDC provider pointing at Authentik, self-hosted specifically to add passkey (WebAuthn) login. See authentik/README.md in docker-infra for the non-obvious provider settings (signing key, scope mappings, grant types) that had to be right before Cloudflare would accept it — the failure mode for all three was an identical, unhelpful “Failed to fetch user/group information from the identity provider.”

Two hostnames deliberately skip step 4 and have no Access Application at all: ntfy.spelvin.dev (token auth, for scripts) and mcp.spelvin.dev (knowledge-mcp, which runs its own MCP OAuth and sends the login to Authentik — Anthropic’s servers can’t pass an Access login page). Both are reachable straight through the Tunnel, so their own auth is the only gate. See Services.

Authentik itself is not behind Access — it can’t be, since Access redirects unauthenticated users to it to log in. Its own login page (passkey-only) is the actual boundary there.

Pages. spelvin.dev and nothingwilts.com bypass all of the above entirely — they’re static Astro sites deployed straight to Cloudflare Pages by a Gitea Actions workflow (wrangler pages deploy) in each site’s own repo, with a custom domain attached via the same API token. No container, no Tunnel entry, no origin on Lana at all.

The recipe, in order:

  1. Deploy the container on Lana (docker-infra/<app>/docker-compose.yml), on the shared web Docker network so cloudflared can reach it by container name.
  2. Add a DNS CNAME: <hostname>.spelvin.dev → <tunnel-id>.cfargotunnel.com, proxied.
  3. Add an ingress rule to the Tunnel config: <hostname> → http://<container>:<port>.
  4. Create a self_hosted Access Application for the hostname, and attach the appropriate reusable policy (“Only Me” or the household policy — or a saas Application instead, if the app supports native OIDC and you’d rather avoid a double login).

Static sites skip steps 2–4 in favor of the Pages custom-domain flow, plus an Access Application on the Pages hostname if it needs gating too.