Remote Access Without a VPN: Tailscale, Headscale, and the Cloudflare Problem - Part 1 of 3

Part 1 of 3: Private Networking for a Personal Media Server

ShowShark works beautifully on a local network. Bonjour discovers the server, the client connects over WebSocket+TLS, and video starts streaming within seconds. But the moment you leave the house, your media library disappears.

The obvious solutions are all bad. Port forwarding punches a hole through the firewall and exposes the server to the entire internet. A commercial relay service (the Plex approach) routes all traffic through someone else's infrastructure, adding latency and capping quality at whatever bandwidth they feel like allocating. A traditional VPN requires every user to install and configure a separate app before they can watch anything.

That last point is the real dealbreaker. ShowShark needs to work for everyone in the household; it needs to work for the family member who calls you when the TV input changes. Telling someone to install WireGuard, import a config file, and toggle a VPN connection before they can watch a movie is not a viable product experience. Remote access has to be invisible.

This series covers how ShowShark achieves that using Tailscale's networking library embedded directly into the app, coordinated by a self-hosted Headscale server. The result: encrypted peer-to-peer connectivity across NATs and firewalls, with zero setup from the user's perspective. No VPN app, no port forwarding, no configuration screens. The client connects to the server the same way whether it is on the couch or across the country.

The Idea: Embed the Network Layer

Tailscale builds encrypted WireGuard mesh networks. Normally, you install the Tailscale app on each device, sign in, and your devices can reach each other by IP address regardless of what network they are on. It handles NAT traversal, key exchange, and peer discovery automatically.

The critical insight is that Tailscale publishes libtailscale, a library that lets any application create and join a tailnet programmatically. Instead of requiring users to install Tailscale separately, ShowShark embeds it. The server starts a tailnet node on launch; the client joins that same tailnet when it first connects. From that point forward, the two can always reach each other.

Traditional VPN approach:

  User installs          User installs           User configures
  ShowShark              WireGuard/Tailscale      VPN connection
      │                       │                       │
      ▼                       ▼                       ▼
  "I just want         "What is this?"          "Which server
   to watch TV"                                  do I pick?"

                        ✗  Unacceptable UX


ShowShark's embedded approach:

  User installs
  ShowShark
      │
      ▼
  Everything works.

                        ✓  Invisible to the user

Architecture: Three Components

The system has three parts: a coordination server, a control plane API, and the embedded tailnet nodes in the server and client apps.

                    ┌───────────────────────────┐
                    │    Coordination Server    │
                    │       (Headscale)         │
                    │                           │
                    │  Manages device identity, │
                    │  key exchange, peer       │
                    │  discovery                │
                    └─────────┬─────────────────┘
                              │
                    Noise protocol (TS2021)
                              │
              ┌───────────────┼───────────────┐
              │               │               │
              ▼               │               ▼
  ┌────────────────────┐      │    ┌────────────────────┐
  │  ShowShark Server  │      │    │  ShowShark Client  │
  │                    │      │    │                    │
  │  Tailscale node    │      │    │  Tailscale node    │
  │  (userspace)       │      │    │  (userspace)       │
  │  100.64.0.x        │◄─────┘    │  100.64.0.y        │
  └────────┬───────────┘ WireGuard └────────┬───────────┘
           │               tunnel           │
           └──────── encrypted p2p ─────────┘


  ┌──────────────────────────┐
  │   Control Plane API      │
  │   (Cloudflare Workers)   │
  │                          │
  │   Namespace provisioning,│
  │   pre-auth key minting,  │
  │   device deduplication   │
  └──────────────────────────┘

The coordination server runs Headscale, an open-source implementation of Tailscale's coordination service. It handles device registration, public key exchange, and NAT traversal coordination. Each ShowShark user account maps to an isolated Headscale namespace, so devices belonging to different users never see each other.

The control plane API runs on Cloudflare Workers. It sits between the native apps and the Headscale API, handling identity verification, namespace provisioning, and pre-auth key lifecycle. It holds the Headscale API key so that native apps never need direct access to the coordination server's admin interface.

The embedded tailnet nodes live inside ShowShark Server and ShowShark Client. Both use TailscaleKit, an xcframework built from libtailscale (Tailscale's Go library compiled for Apple platforms). The server creates a persistent node on startup; clients join the same tailnet when they receive credentials from the server.

Why Headscale Instead of Tailscale's Servers?

Tailscale offers a hosted coordination service, and it works well. But using it means every ShowShark installation depends on Tailscale Inc. remaining available, maintaining their free tier, and not changing their API in breaking ways. For a personal media server that people rely on daily, that is an uncomfortable dependency.

Headscale eliminates it. The coordination server is just another piece of infrastructure we control. If Tailscale changes their pricing or shuts down their free tier, ShowShark is unaffected. The tailnet nodes in the apps speak the same Noise protocol regardless of whether the coordination server is run by Tailscale or by Headscale.

ShowShark Remote is a dedicated macOS app that wraps the Headscale binary. It manages the subprocess lifecycle, generates configuration, streams logs, and provides a dashboard for monitoring connected nodes. We will cover its implementation in Part 2.

The Cloudflare Tunnel Problem

The original deployment plan was straightforward: run Headscale behind a Cloudflare Tunnel. Cloudflare Tunnels let you expose a service to the internet without a public IP address or port forwarding. The cloudflared daemon creates an outbound connection to Cloudflare's edge, and Cloudflare routes incoming requests back through that tunnel. Free TLS, free DDoS protection, no firewall configuration.

It does not work with Headscale. The reason is a fascinating collision between Tailscale's protocol design and Cloudflare's HTTP processing.

The TS2021 Protocol

Tailscale's current control protocol, TS2021, establishes its connection using an HTTP upgrade request. This is superficially similar to how WebSockets work: the client sends an HTTP request with an Upgrade header, the server responds with 101 Switching Protocols, and the connection transitions from HTTP to a bidirectional binary channel.

But Tailscale deviates from the WebSocket standard in three ways, and each deviation independently breaks Cloudflare Tunnel compatibility.

Deviation 1: POST instead of GET. RFC 6455 Section 4.1 is explicit: "The method of the request MUST be GET." Tailscale uses POST. This is not an oversight; it is a deliberate optimization. The TS2021 handshake uses the Noise IK pattern, which requires the client to send an initial cryptographic message to the server. Tailscale embeds this message in the upgrade request itself via a custom header:

Standard WebSocket upgrade (RFC 6455):

  GET /ws HTTP/1.1
  Host: example.com
  Upgrade: websocket
  Connection: upgrade
  Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==


Tailscale TS2021 upgrade:

  POST /ts2021 HTTP/1.1
  Host: coordination.example.com
  Upgrade: tailscale-control-protocol
  Connection: upgrade
  X-Tailscale-Handshake: <base64 Noise IK initiator message>

By piggybacking the Noise initiation on the upgrade request, Tailscale saves one full round trip. The server can complete the Noise handshake immediately in its 101 response, and the encrypted channel is ready without any additional back-and-forth. Clever engineering; terrible for proxies that enforce RFC 6455.

Deviation 2: Non-standard Upgrade header value. Even if cloudflared accepted POST upgrades, it only recognizes the value websocket in the Upgrade header. Tailscale uses tailscale-control-protocol. Any other value gets stripped before the request is forwarded to the origin.

Deviation 3: HTTP/2 inside the upgraded connection. After the Noise handshake completes, Tailscale runs HTTP/2 (h2c) inside the encrypted channel for the actual control API traffic. This is essentially gRPC-style multiplexing tunneled within the upgraded connection, and it falls outside what cloudflared expects from an upgraded connection.

What Happens in Practice

When a Tailscale client tries to connect to Headscale through a Cloudflare Tunnel, cloudflared receives the POST request, sees that the Upgrade header value is not websocket, strips it, and forwards a plain POST to Headscale. Headscale receives an ordinary HTTP POST with no upgrade indication and logs:

No Upgrade header in TS2021 request. If headscale is behind a reverse proxy, make sure it is configured to pass WebSockets through.

The connection fails. There is no workaround on the Headscale side; the protocol is defined by the Tailscale clients, and Headscale must remain compatible with them.

The Solution: Nginx

The fix is straightforward but less convenient: run Nginx (or Caddy) as a TLS-terminating reverse proxy on a server with a public IP address. Unlike cloudflared, Nginx does not inspect or filter Upgrade header values. It passes them through verbatim, regardless of the HTTP method:

location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_set_header Host $host;
    proxy_buffering off;
}

This requires a server with a real IP address and a DNS A record pointed at it (not proxied through Cloudflare; just DNS-only). It is less elegant than a Cloudflare Tunnel, but it works reliably and gives us full control over the TLS configuration.

What we wanted:

  Headscale ◄── cloudflared tunnel ──► Cloudflare Edge ◄── internet
                                          (free TLS)

  ✗ cloudflared strips non-standard Upgrade headers


What we got:

  Headscale ◄── Nginx (TLS termination) ◄── internet
                    (Let's Encrypt)

  ✓ Nginx passes all Upgrade headers through

The Enrollment Flow

With the infrastructure in place, the enrollment process from the user's perspective is simple. Here is what happens behind the scenes when a new client first connects to a server:

  ShowShark Server                      ShowShark Client
  ──────────────                        ────────────────
        │                                      │
    On first launch:                           │
    1. Sign in with Apple                      │
    2. Server provisions itself                │
       onto tailnet via Workers API            │
    3. TailscaleKit node starts,               │
       gets assigned 100.64.x.x                │
    4. TailnetBridge begins                    │
       accepting connections                   │
        │                                      │
        │  ◄──── local login (Bonjour) ─────── │
        │                                      │
    5. Server mints a pre-auth key             │
       for this client device                  │
        │                                      │
        │  ──── LoginResponse ────────────────►│
        │       (remote address,               │
        │        coordination URL,             │
        │        pre-auth key)                 │
        │                                      │
        │                           6. Client stores credentials
        │                              and joins tailnet
        │                                      │
        │                           7. On future connections,
        │                              client connects via
        │                              tailnet automatically
        │  ◄═══ encrypted WireGuard tunnel ═══►│
        │                                      │

The user sees none of this. From the user's perspective: the server appeared on the local network, the client connected to it, and now it also works from anywhere. The tailnet enrollment, key exchange, and peer discovery all happen automatically during that first local login.

What is Next

Part 2 covers the server-side implementation: ShowShark Remote (the Headscale management app), the TailscaleKit node lifecycle, and the TailnetBridge that forwards tailnet connections to the local WebSocket server.

Part 3 covers the client side: how credentials flow through the login response, the SOCKS5 proxy trick for routing NWConnection through the tailnet, and the platform abstraction layer that makes this work across iOS, macOS, tvOS, and visionOS.