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.
Tailscale — host-to-host
Section titled “Tailscale — host-to-host”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.
Cloudflare — internet-to-Lana
Section titled “Cloudflare — internet-to-Lana”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 containerDNS. 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 vps-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_hostedAccess 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).saasAccess 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, Outline, LiteLLM, Open WebUI, Leantime).
Every Access policy on personal-use apps allows exactly one identity (Sign in with Apple via private relay) — consistent across FlowViz, LiteLLM, Windmill, and the rest of the single-user apps.
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.
Adding a new app
Section titled “Adding a new app”The recipe, in order:
- Deploy the container on Lana (
vps-infra/<app>/docker-compose.yml), on the sharedwebDocker network socloudflaredcan reach it by container name. - Add a DNS CNAME:
<hostname>.spelvin.dev → <tunnel-id>.cfargotunnel.com, proxied. - Add an ingress rule to the Tunnel config:
<hostname> → http://<container>:<port>. - Create a
self_hostedAccess Application for the hostname, with the standard single-identity allow policy (or asaasone 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.