2026-06-21 Tailscale Serve for Open WebUI

What I set out to do

Reach my self-hosted Open WebUI instance from my phone when off the home network. Started as a survey of iOS clients, drifted into “what’s the right way to expose the instance,” and landed on Tailscale Serve. (Sibling to the 2026-06-21 Remote Obsidian MCP Connector work and a continuation of 2026-06-21 Open WebUI Deployment and Auth Cascade.)

What I actually did

Worked it end to end with Claude over one session.

Framing the clients first. Compared the iOS Open WebUI clients: Conduit (9.99, iOS 18+, on-device MLX voice, RAG, automations), and the free PWA (Add to Home Screen). Concluded the PWA is the best free path, which set up the real question: how to reach the instance remotely.

LAN first, then the real fix. The container published only on 127.0.0.1, so the phone couldn’t reach it even on Wi-Fi. Briefly flipped the Docker publish to 0.0.0.0 for LAN access (atlas.local:7878), then reverted it once Tailscale Serve made loopback the correct binding again.

Transport decision: Tailscale over Cloudflare. For a single user hitting their own instance, Tailscale Serve beats a Cloudflare Tunnel + Cloudflare Access setup: no public endpoint exists at all (vs a public hostname gated by an app-layer policy), WireGuard is end-to-end so no third party terminates TLS, and WEBUI_AUTH=False stays acceptable because the network is the auth. Kept Cloudflare for the Obsidian MCP (that one must be public, since Anthropic’s cloud dials in); Tailscale for OWUI (private). The classic “Tailscale for private, Cloudflare for public” split.

Why the daemon isn’t in Nix. macOS transparent tunneling needs Apple’s NetworkExtension entitlement, which requires Apple code-signing a /nix/store path can’t carry. nixpkgs tailscaled only does userspace-networking (a proxy), not a real tunnel. So Tailscale is a Homebrew cask (Tier 1.5), same as BetterTouchTool/Zotero. Nix owns only the declarative serve mapping.

Build (declarative pieces in home-manager).

  • New module tailscale-serve.nix: a Darwin-gated programs.tailscaleServe that applies tailscale serve --bg <port> as a one-shot activation (not a supervised launchd agent like the obsidian bridge/tunnel) because Serve persists its config in the daemon and restores it across reboots. Tolerant of the daemon being logged out so hm switch never fails on it.
  • Reverted the OWUI publish bind 0.0.0.0127.0.0.1; Serve proxies tailnet→loopback, so no LAN/public exposure.
  • Allowlisted Bash(tailscale *); enabled programs.tailscaleServe in home.nix wired to config.programs.open-webui.port.
  • brew install --cask tailscale, logged in on Mac + iPhone (both on tailnet tail0cef90.ts.net).

HTTPS certs and the public-ledger nuance. Enabled HTTPS Certificates in the admin console (needed because iOS gates the secure-context features behind a valid cert). Verified the gates: microphone/getUserMedianavigator.mediaDevices is undefined on plain HTTP non-localhost; service workers + push → HTTPS without exception; but “Add to Home Screen” itself works on HTTP (just a hollow shell). The catch: Tailscale’s Let’s Encrypt cert publishes atlas.tail0cef90.ts.net to public Certificate Transparency logs permanently. Only names leak (machine + the already-randomized tailnet name), not reachability. atlas is non-sensitive, so accepted it.

Verified end to end. tailscale serve status shows https://atlas.tail0cef90.ts.net → 127.0.0.1:7878; cert is Let’s Encrypt CN atlas.tail0cef90.ts.net; HTTPS health returns 200 with tls_verify=0. First request timed out on on-demand cert provisioning, then cached. Phone PWA confirmed working.

What was striking

  • “Wouldn’t a port have to be exposed?” — no. Both Tailscale and Cloudflare are outbound-dial; neither opens a router port. The real difference is whether a publicly reachable endpoint exists. WireGuard silently drops unauthenticated packets, so the socket is invisible to scanners.
  • HTTPS adds nothing to security here — WireGuard already encrypts the wire. The certs are purely to unlock iOS secure-context features (voice, push, service workers). Easy to conflate “HTTPS” with “secure transport” when the actual driver was browser feature-gating.
  • CT logs are permanent and unrevocable. Disabling HTTPS later doesn’t remove the entry. Decide before provisioning; the randomized tailnet name is Tailscale’s built-in mitigation.
  • The macOS entitlement wall is why so many network tools can’t be pure Nix on Darwin: Apple funnels kernel-level networking through signed system extensions, the opposite of Nix’s unsigned relocatable store paths.
  • Serve vs the obsidian agents: Serve config lives in the daemon, so it needed a one-shot apply, not a KeepAlive agent. Right tool per persistence model.

Top 3 next

  1. Consider tailnet ACLs to scope which devices can reach atlas:443 (defense-in-depth beyond device enrollment).
  2. Decide whether to enable Open WebUI’s own WEBUI_AUTH as belt-and-suspenders, or leave it network-gated.
  3. Optionally wrap the serve apply in a RunAtLoad login agent if daemon-state wipes ever become a problem (currently a one-shot activation).