My Self-Hosted ChatGPT Lives on My Tailnet Now

Brett Ridenour Brett Ridenour · June 21, 2026

I had three problems with paying ChatGPT $20 a month. First, it’s one model. I want to pit Claude against GPT-5 against DeepSeek for the same prompt and see who wins, not lock myself into one vendor’s flavor of the week. Second, the per-seat pricing scales linearly even when I don’t use it linearly — some weeks I burn it down, some weeks I touch it once. Third, I was paying separately for the API anyway, which is the same model behind the same wall with a different payment portal.

So one evening I sat down at my Omarchy box, opened a Claude Code session, and said: fire up an OpenWebUI with an OpenRouter API key that works and make it accessible to Tailscale on my MacBook Pro.

Forty-five minutes later it was live. The first thirty of those minutes went exactly to plan. The last fifteen were the interesting part.

The shape of the stack

Three pieces, none of them new:

  • OpenWebUI — the chat interface. It’s a faithful clone of the ChatGPT UI, multi-model, with conversation history, file uploads, and a Knowledge/RAG feature. Runs in a single Docker container.
  • OpenRouter — one API key, every frontier model. GPT-5, Claude Opus 4.7, Gemini 2.5 Pro, DeepSeek, Qwen, Llama, dozens more. Pay per token. No subscription. The container points its OpenAI-compatible env vars at https://openrouter.ai/api/v1 and every model OpenRouter ships shows up in the dropdown automatically.
  • Tailscale — the wire. My Omarchy desktop and my MacBook are on the same tailnet. No port forwarding, no public IP, no Cloudflare tunnel.

The compose file is fourteen lines.

services:
  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    container_name: open-webui
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:8080"
    environment:
      - OPENAI_API_BASE_URL=https://openrouter.ai/api/v1
      - OPENAI_API_KEY=${OPENROUTER_API_KEY}
      - WEBUI_NAME=Brett's OpenWebUI
    volumes:
      - open-webui:/app/backend/data
volumes:
  open-webui:

docker-compose.yml for OpenWebUI

The .env next to it holds the OpenRouter key with chmod 600. The container’s data — accounts, chat history, uploaded files — lives in a named Docker volume that survives compose down and image upgrades.

The first attempt that didn’t work

The obvious thing to do is bind the container directly to the Tailscale IP. Something like ports: "100.102.139.5:3000:8080". Then your MacBook hits http://100.102.139.5:3000 and you’re done. That’s what I did first.

The container came up healthy. curl against 100.102.139.5:3000 from the Linux box itself returned 200. curl against localhost:3000 correctly refused — proof the binding was scoped to the tailnet only and not exposed to the LAN.

Then I tried it from the MacBook. Ten-second hang. http_code=000. Silent drop. Not a connection refused — a packet that left and never came back.

This is where I learned a thing about UFW and Docker that I should have known. My Omarchy box runs UFW with a default-DROP policy. Tailscale slots in its own iptables chain (ts-input) ahead of UFW, and that chain has an unconditional ACCEPT all on tailscale0 rule — so in theory, every tailnet packet sails through before UFW gets a vote.

The packet counters confirmed traffic was hitting that ACCEPT rule. Tens of thousands of packets, accepted. And yet the MacBook still timed out on port 3000.

The problem is that Docker’s published-port machinery doesn’t just bind a socket. It rewrites packets through its own NAT and FORWARD chains, and those chains do get filtered by UFW’s default-DROP forward policy. The packet reaches the host, gets accepted at INPUT by ts-input, then dies later in the FORWARD path on its way into the container’s network namespace. The “accepted” counter goes up. The connection still times out. There is no log line. There is no error. There is the absence of a reply.

You can fix this with surgical UFW rules. I didn’t want surgical UFW rules. I wanted to never think about this again.

The detour that actually shipped

Tailscale ships a feature called tailscale serve that I had never used. It reverse-proxies a localhost port and exposes it on the tailnet over real HTTPS, with a real cert, at a real hostname. The tailscaled daemon itself terminates the TLS connection and forwards plaintext to your local service. It bypasses Docker’s NAT entirely because there’s no Docker NAT in the path — tailscaled is the one talking to the listening socket.

So I rebound the container to 127.0.0.1:3000 and ran one line:

tailscale serve --bg --https=9000 http://127.0.0.1:3000

tailscale serve and the resulting URL

That gave me https://brett.tailfa0379.ts.net:9000 — valid HTTPS, no browser warning, reachable from anything on my tailnet. The MacBook test came back 200 in 0.57 seconds. The serve config persists across reboots independently of Docker’s restart: unless-stopped, so the whole thing survives a power cycle without intervention.

The fix wasn’t to argue with UFW. It was to use a tunnel that doesn’t go through UFW.

— Brett Ridenour

The privacy boundary

Right after it went live, I asked the agent that built it: does this thing have access to my vault? The answer mattered because the next thing I wanted to do was copy a vault in for RAG.

It checked. The container has exactly one host mount — its own Docker volume at /app/backend/data. Nothing from my home directory is visible inside. Outbound, it can only talk to OpenRouter. It has no Gmail, no Calendar, no Slack, no Todoist, no Supabase. None of my MCP credentials live inside it — those belong to Claude Code, in a different session, on a different boundary.

That distinction — what the container can touch vs. what I paste into it — is the one most self-hosters wave at and don’t fully think through. It’s worth being precise about.

What it cost

OpenRouter charges per token. The same models I would have paid $20/month flat for I can now sample for cents per query, with no minimum. I topped up $5 to get started and I’m watching it drain at a rate that suggests I’ll spend maybe $4 a month casually, or $15 if I really hammer it. ChatGPT Plus was $240 a year. Even at the high end, this is a third of that, with every model I want instead of one.

The lesson, if there is one

The interesting work here wasn’t picking the stack. OpenWebUI, OpenRouter, and Tailscale are all well-known. The interesting work was the detour — the moment when the obvious binding strategy silently failed, and the right answer wasn’t to harden UFW, it was to take UFW out of the path entirely by changing what terminated the connection.

That kind of pivot is the part agents are now genuinely good at. When the first plan ran into a dropped packet, the next move wasn’t to retry the same thing — it was to reach for a different primitive that sidestepped the problem. That’s what I keep paying for, and it’s not $20 a month.