Skip to content

Network model (Tailscale)

Carabase Host is designed around zero public ports. The desktop client reaches the host across a private mesh network that only your devices can see — by default, that mesh is Tailscale.

This page covers:

  1. Why Tailscale (and what happens if you skip it)
  2. Installing it on the machine running the host
  3. Publishing the tailnet-only HTTPS origin with Tailscale Serve
  4. Connecting the desktop client
  5. The localhost-only fallback for local-loop development

The security model assumes the host is never reachable from the public internet. The HTTP listener doesn’t speak TLS, doesn’t require auth on /api/v1/health, and isn’t designed to fend off the kind of background scanning every IPv4 address gets. Putting the host directly on a public IP is not a supported configuration — credentials would leak quickly.

The network boundary is the primary trust boundary. Everything inside it (credential encryption, PostgreSQL row-level security, rate limiting, the inbound MCP access gate) is defense-in-depth layered behind the mesh, not a substitute for it. See the threat model for those layers.

Tailscale gives us a WireGuard-based overlay network where:

  • Every device joins the same “tailnet” with a single login (Google/GitHub/Microsoft/email)
  • Devices get stable hostnames (MagicDNS) and IPs that don’t change
  • Traffic is end-to-end encrypted between devices, not just to a relay
  • No port-forwarding, no DDNS, no public DNS records

It’s free for personal use and runs as a small daemon. Carabase is single-tenant — one workspace per host, one operator — so a personal tailnet is all you need.

On macOS:

Terminal window
brew install --cask tailscale
open -a Tailscale

The Tailscale app prompts you to sign in — pick whichever identity provider you want this tailnet to live under. After login, the menubar icon turns blue.

In a terminal:

Terminal window
tailscale status

You’ll see a line like:

100.123.45.67 my-mac-mini tailnet-name@ macOS -

The my-mac-mini part is your MagicDNS hostname. Combined with your tailnet’s domain (shown in the Tailscale admin console), it becomes a fully-qualified name like my-mac-mini.tailnet-name.ts.net.

That fully-qualified name, published over HTTPS (next section), becomes the durable address everything else uses — the desktop client, and owner sign-in itself:

https://my-mac-mini.tailnet-name.ts.net

Owner sign-in uses passkeys (WebAuthn), which bind to one exact origin for good — don’t register a passkey against the short MagicDNS name, a raw 100.x IP, or localhost. If you ever change which hostname you use, you’ll need to re-register.

The host defaults to HOST=:: in every .env.<env>.example, which is dual-stack (IPv4 + IPv6) and accepts connections on every interface — including the Tailscale virtual interface. Don’t change this to 127.0.0.1 unless you genuinely want a local-loop-only host (see the fallback section below).

Verify the host is listening on the Tailscale interface:

Terminal window
lsof -i :3000 | grep LISTEN
# Look for entries on the tailscale0 interface or a wildcard (*) bind

4. Publish the tailnet-only HTTPS origin with Tailscale Serve

Section titled “4. Publish the tailnet-only HTTPS origin with Tailscale Serve”

The host speaks plain HTTP on port 3000 — Tailscale Serve is what turns that into the stable HTTPS origin from the previous section, without opening anything to the public internet (Serve stays tailnet-only; that’s different from Tailscale Funnel, which is public — don’t use Funnel here).

Confirm the host answers locally, then publish it:

Terminal window
curl -fsS http://127.0.0.1:3000/api/v1/health
tailscale serve --bg --https=443 http://127.0.0.1:3000
tailscale serve status

The first tailscale serve call may prompt you to enable HTTPS certificates in the Tailscale admin console if you haven’t already. Once it’s up, https://my-mac-mini.tailnet-name.ts.net is live — that’s the address you’ll use for owner sign-in and for connecting the desktop client next.

Advanced: real per-client IPs via the PROXY protocol

Section titled “Advanced: real per-client IPs via the PROXY protocol”

By default, Serve is a full HTTP reverse proxy — it terminates TLS and speaks plain HTTP to the host, so every request the host sees arrives from 127.0.0.1, not the real device that sent it. Harmless for most setups, but it does mean per-device rate limiting and the audit log’s IP column can’t tell devices apart.

Serve can instead forward the raw, TLS-terminated byte stream with a small PROXY protocol header identifying the real source. The host understands this out of the box — flip Serve into that mode and real per-device IPs start showing up automatically, no host config needed:

Terminal window
tailscale serve --https=<port> off
tailscale serve --bg --proxy-protocol=2 --tls-terminated-tcp=<port> tcp://127.0.0.1:3000

Roll back the same way if anything looks off:

Terminal window
tailscale serve --tls-terminated-tcp=<port> off
tailscale serve --bg --https=<port> http://127.0.0.1:3000

This is a mode swap on the port you’re already using, not an additive step — do it somewhere you can watch the reconnect.

On the device that will run the desktop client:

  1. Install Tailscale and sign in with the same identity you used on the host
  2. tailscale status should now list both devices
  3. Open the desktop app → Settings → Host Connection → enter the exact HTTPS origin (e.g. https://my-mac-mini.tailnet-name.ts.net)
  4. The connection indicator should go green within a few seconds

If you see a timeout, fix MagicDNS or Serve rather than switching to a raw IP or a different hostname — a different hostname is a different WebAuthn origin, and you’d need to re-register your passkey.

The same origin also serves the host’s inbound MCP surface (/mcp) to other tailnet AI clients such as Claude Desktop, Cursor, and Codex. That surface has its own optional access gate — see the threat model for how per-client MCP tokens work.

Tailscale supports ACLs (access control lists) at the tailnet level. For a single-user setup with one host + a few clients, the default “anyone in this tailnet can reach anyone else” is fine. If you start sharing the tailnet with collaborators or family, write an ACL that restricts port 3000 on the host to specific tagged users.

See the Tailscale ACL docs — out of scope here.

If you don’t want Tailscale, you can run Carabase in local-loop-only mode where the host is only reachable from the same machine:

Terminal window
# In .env.dev:
HOST=127.0.0.1
PORT=3000

This works for the Admin SPA at http://localhost:3000/admin/ and for any local development.

It does not work for the desktop client — the desktop is a separate macOS process that needs to reach the host across a network boundary. With localhost-only binding, the only way the desktop can reach the host is if it’s running on the same machine, which defeats most of the point.

If you go this route:

  • The “no public ports” guarantee is satisfied trivially (the listener is unreachable from anywhere except 127.0.0.1)
  • The Admin SPA is still your full configuration surface — every connector, OAuth app, and sync rule is reachable
  • Background workers (harvest, sync, dreams, curation) run normally — they’re in-process, not network-bound

This is a perfectly valid mode for headless installations, server deployments where you SSH-tunnel into the box, or developers who never use the desktop client.

You probably don’t, but: put a reverse proxy (Caddy, nginx, Cloudflare Tunnel) in front of the host with TLS + a separate auth layer. Carabase’s own routes do not implement public-internet, per-user authentication — they assume the network boundary is the auth layer. Adding TLS termination is necessary but not sufficient.

This is unsupported. The single-tenant assumption (one workspace per host, one user per host) doesn’t translate to a public deployment. Carabase does ship defense-in-depth inside the mesh — per-IP rate limiting on every route, AES-256-GCM credential encryption, PostgreSQL row-level security, and an append-only audit log for agent-initiated external writes — but none of that is a substitute for a real public-facing auth tier, and there is no per-user authentication on the routes themselves.

If you’re trying to share access with another person, the right answer is to add their device to your tailnet — not to expose the host publicly.