Blog
Lab Notes16 min read

sub2api on a Mac mini: ChatGPT Pro + Claude Max with Cloudflare Tunnel

A practical home-lab guide for running sub2api on a Mac mini, adding secure remote access, and wiring local AI tools to OpenAI-compatible endpoints.

Last verified May 8, 2026

A practical, end-to-end guide for running Wei-Shaw/sub2api on a Mac mini home server, brokering both a ChatGPT Pro $100 subscription and a Claude Max $100 subscription into OpenAI-compatible API endpoints, and exposing the whole thing remotely through Cloudflare Tunnel + Cloudflare Access.

Audience: developers / DevOps folks comfortable with Docker, basic networking, and the command line. Roughly 60–90 minutes end-to-end the first time.


TL;DR

  1. Install OrbStack (or Docker Desktop) on the Mac mini.
  2. One command bootstraps sub2api + Postgres + Redis.
  3. Add your ChatGPT Pro and Claude Max accounts via OAuth in the dashboard (browser interaction required — no shortcut for this).
  4. (Optional) Add LiteLLM in front for automatic OpenAI → Claude failover.
  5. (Optional) Install Tailscale for the simplest secure remote access.
  6. (Optional) Install Cloudflare Tunnel + Access for a public hostname with zero-trust auth.
  7. Point Cursor / Aider / Continue / Claude Code / your scripts at the gateway.

Important caveats (read these first)

  • OpenAI and Anthropic Terms of Service do not authorize this. Both prohibit sharing account credentials or programmatically extracting from subscription products. A single user proxying their own subscriptions for personal local use has historically gone unenforced, but it is technically out of policy. Anthropic revoked third-party Claude OAuth tokens in April 2026 when wrapper tools surged in popularity; some access was restored, some wasn't. Treat this as a personal-use convenience layer, not infrastructure.
  • Don't share the gateway URL with other people. That crosses the explicit "make your account available to anyone else" line.
  • Keep fallback API keys. Get a sk-proj-... (OpenAI Platform) and sk-ant-... (Anthropic Console) key, store them in your password manager, and be ready to point your tools back at the official endpoints if the proxy path breaks.
  • The $100 tiers are tight for heavy agent workloads. Cursor + Claude Code can chew through 5-hour windows fast. Don't budget around the proxy giving you "unlimited" anything.

Prerequisites

  • A Mac mini (Apple Silicon recommended; M1/M2/M3/M4 all fine — sub2api images are multi-arch).
  • macOS Sonoma or newer.
  • Active ChatGPT Pro ($100/mo) subscription.
  • Active Claude Max ($100/mo) subscription.
  • Homebrew installed (brew --version).
  • (Optional, for Cloudflare Tunnel) A domain on Cloudflare DNS — Cloudflare must be the authoritative nameserver for the zone.

Part 1 — Mac mini Preparation

1.1 Install a Docker runtime

OrbStack is recommended for personal home servers — fastest startup, lowest battery cost, free for personal use.

brew install orbstack
open -a OrbStack

Alternatives:

# Docker Desktop
brew install --cask docker && open -a Docker

# Colima (headless, no GUI)
brew install colima docker docker-compose
colima start --cpu 2 --memory 4 --vm-type vz

Verify:

docker version
docker compose version
docker info | grep -i arch    # expect aarch64 / arm64

1.2 Configure for always-on operation

System Settings → Energy (or Battery on newer macOS):

  • Prevent automatic sleep when display is off: On
  • Start up automatically after a power failure: On
  • Wake for network access: On

System Settings → General → Login Items:

  • Add OrbStack (or Docker Desktop) so it boots with the Mac.

Verify:

pmset -g | grep -E "(sleep|womp|autorestart)"
# Want: sleep 0, womp 1, autorestart 1

1.3 Set a stable hostname

sudo scutil --set ComputerName "macmini-lab"
sudo scutil --set HostName "macmini-lab"
sudo scutil --set LocalHostName "macmini-lab"

After this, macmini-lab.local resolves over Bonjour from any Mac/iOS device on your LAN.


Part 2 — Install sub2api

2.1 The one-liner

mkdir -p ~/services/sub2api && cd ~/services/sub2api && \
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash

This pulls the deploy script, generates .env with random POSTGRES_PASSWORD / JWT_SECRET / ADMIN_PASSWORD, writes docker-compose.local.yml, pulls images, and runs docker compose up -d. When it finishes it prints the admin URL, email, and password — save these immediately.

Pin the image version and set timezone:

cd ~/services/sub2api
sed -i '' 's/weishaw\/sub2api:latest/weishaw\/sub2api:0.1.123/' docker-compose.local.yml
echo "TZ=America/Los_Angeles" >> .env
docker compose -f docker-compose.local.yml up -d

Pinning matters: the Claude OAuth handler has had silent regressions in 2026. Subscribe to the Wei-Shaw/sub2api releases feed and bump deliberately.

2.3 Verify

curl -fsS http://localhost:8080/health
open http://localhost:8080

Log in with the credentials from .env:

grep -E "ADMIN_(EMAIL|PASSWORD)" .env

Part 3 — Connect your subscription accounts

Both flows require browser interaction with the provider (chatgpt.com / claude.ai). There is no automated path — and any tool that claims one is asking you to paste your account credentials into a third-party server.

3.1 Add ChatGPT Pro (primary)

In the dashboard:

  1. Accounts → Add Account
    • Platform: OpenAI
    • Type: OAuth
    • Name: chatgpt-pro-primary
  2. Click Generate Authorization URL.
  3. Open the URL in a browser already logged into your ChatGPT Pro account.
  4. Approve. The browser will redirect to a localhost URL that won't load — that's expected.
  5. Copy the entire redirect URL from the address bar (or just the code=... parameter value).
  6. Paste it back into sub2api's "Authorization Code" field. Submit.
  7. Click Test Account. A successful test means a real gpt-5.5 request went through to OpenAI's Codex backend.

3.2 Add Claude Max (backup)

Same flow, different platform:

  1. Accounts → Add Account
    • Platform: Anthropic
    • Type: OAuth
    • Name: claude-max-backup
  2. Generate URL → open in a browser logged into your Claude Max account → approve.
  3. Copy the redirect URL or code= param, paste back into sub2api.
  4. Test with claude-sonnet-4-5.

3.3 Set up groups and a user

  1. Groups → Create
    • openai-pool — platform OpenAI, contains chatgpt-pro-primary
    • claude-pool — platform Anthropic, contains claude-max-backup
  2. Users → Create (or use the admin user)
    • Grant access to both groups.
  3. API Keys → Create
    • Copy the sk-... value. This is what every downstream client will use.

Part 4 — Verify both providers

export SUB2API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxx
export BASE=http://localhost:8080

# OpenAI Chat Completions surface (Cursor, Aider, openai SDK)
curl -sS "$BASE/v1/chat/completions" \
  -H "Authorization: Bearer $SUB2API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-5.5","messages":[{"role":"user","content":"say OK"}]}'

# Anthropic Messages surface (Claude Code, Anthropic SDK)
curl -sS "$BASE/v1/messages" \
  -H "x-api-key: $SUB2API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-4-5","max_tokens":50,"messages":[{"role":"user","content":"say OK"}]}'

Both should return OK. The dashboard's account view will show the request hitting the right account with rate-limit bars updating.


Part 5 — (Optional) LiteLLM for automatic failover

Skip this if you're happy switching models manually when one provider hits its rate limit. Add it if you want "if OpenAI 429s, transparently retry on Claude."

mkdir -p ~/services/litellm && cd ~/services/litellm

cat > config.yaml <<'EOF'
model_list:
  - model_name: smart
    litellm_params:
      model: openai/gpt-5.5
      api_base: http://host.docker.internal:8080/v1
      api_key: os.environ/SUB2API_KEY
  - model_name: smart
    litellm_params:
      model: anthropic/claude-sonnet-4-5
      api_base: http://host.docker.internal:8080
      api_key: os.environ/SUB2API_KEY

router_settings:
  routing_strategy: simple-shuffle
  fallbacks:
    - smart: ["smart"]
  num_retries: 2
  allowed_fails: 1
  cooldown_time: 300

litellm_settings:
  drop_params: true
EOF

cat > docker-compose.yml <<'EOF'
services:
  litellm:
    image: ghcr.io/berriai/litellm:main-stable
    container_name: litellm
    ports:
      - "4000:4000"
    environment:
      - SUB2API_KEY=${SUB2API_KEY}
      - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY}
    volumes:
      - ./config.yaml:/app/config.yaml:ro
    command: ["--config", "/app/config.yaml", "--port", "4000"]
    restart: unless-stopped
EOF

cat > .env <<EOF
SUB2API_KEY=sk-paste-your-sub2api-key-here
LITELLM_MASTER_KEY=sk-$(openssl rand -hex 24)
EOF

docker compose up -d

Clients now point at http://macmini-lab.local:4000 with LITELLM_MASTER_KEY and model name smart. First listed entry wins; second catches failures.


Part 6 — Remote access option A: Tailscale (simplest)

The lowest-friction way to reach your Mac mini from anywhere. Free for personal use, no public exposure, WireGuard under the hood.

brew install --cask tailscale
open -a Tailscale
# Sign in with the same account on the Mac mini AND your laptop
tailscale ip -4   # note the 100.x.y.z address

After this the Mac mini is reachable as macmini-lab.<your-tailnet>.ts.net from anywhere. No tunnel, no public hostname, no Cloudflare Access policy to maintain.

If Tailscale is enough for your use case, skip Part 7. Cloudflare Tunnel is for cases where you genuinely need a public URL — webhooks, sharing with external services, mobile apps that don't run a Tailscale client, etc.


Part 7 — Remote access option B: Cloudflare Tunnel + Access

This gives you https://sub2api.userdomain.com reachable from anywhere with TLS handled automatically and zero-trust auth in front. Requires a domain on Cloudflare DNS.

7.1 Install cloudflared

brew install cloudflared
cloudflared --version

7.2 Authenticate and create the tunnel

cloudflared tunnel login
# Browser opens. Pick the zone (userdomain.com).
# Writes ~/.cloudflared/cert.pem.

cloudflared tunnel create sub2api
# Output:
#   Tunnel credentials written to /Users/you/.cloudflared/<UUID>.json
#   Created tunnel sub2api with id <UUID>

# Bind a hostname to it via Cloudflare DNS
cloudflared tunnel route dns sub2api sub2api.userdomain.com

7.3 Write the tunnel config

TUNNEL_UUID=$(cloudflared tunnel list | awk '/sub2api/ {print $1}')

cat > ~/.cloudflared/config.yml <<EOF
tunnel: ${TUNNEL_UUID}
credentials-file: ${HOME}/.cloudflared/${TUNNEL_UUID}.json

ingress:
  - hostname: sub2api.userdomain.com
    service: http://localhost:8080
    originRequest:
      connectTimeout: 30s
      disableChunkedEncoding: false
      noHappyEyeballs: false
  # If you also expose LiteLLM:
  # - hostname: llm.userdomain.com
  #   service: http://localhost:4000
  - service: http_status:404
EOF

Validate and test in the foreground first:

cloudflared tunnel ingress validate
cloudflared tunnel run sub2api
# In another terminal:
curl -fsS https://sub2api.userdomain.com/health
# Ctrl+C the foreground tunnel once you've confirmed

7.4 Install as a system service

sudo cloudflared service install
sudo launchctl kickstart -k system/com.cloudflare.cloudflared

# Logs
tail -f /Library/Logs/com.cloudflare.cloudflared.{out,err}.log

The tunnel now starts on boot.

7.5 Lock it down with Cloudflare Access

Without Access, your tunnel hostname is public. Anyone who finds it (Certificate Transparency logs are public — this takes minutes) can hit it. Always put Access in front.

You'll create two policies on one application because browser auth (OTP) and SDK auth (service tokens) are mutually exclusive.

Step 1 — Enable login methods

Cloudflare Zero Trust dashboard → Settings → Authentication → Login methods:

  • Add One-time PIN (free, just an email code).

Step 2 — Create the Access application

Access → Applications → Add an application → Self-hosted:

  • Application name: sub2api
  • Session duration: 24 hours
  • Application domain: sub2api.userdomain.com (whole hostname, no path)
  • Click Next.

Step 3 — Policy 1: browser access (you)

  • Policy name: Email OTP — admin browser
  • Action: Allow
  • Configure rules → Include:
  • Require:
    • Login Methods → One-time PIN
  • Save.

Step 4 — Create a service token

Access → Service Auth → Service Tokens → Create Service Token:

  • Name: sub2api-clients
  • Duration: 1 year
  • Save the Client ID and Client Secret immediately — the secret is shown only once. Store both in your password manager.

Step 5 — Policy 2: SDK / CLI access (your tools)

Edit the Access application, Add a policy:

  • Policy name: Service token — SDK clients
  • Action: Service Auth
  • Configure rules → Include:
    • Selector: Service Token
    • Value: sub2api-clients
  • Save.

Step 6 — Verify

Browser test (should prompt for OTP):

open https://sub2api.userdomain.com

SDK test (should pass through with the two CF headers):

curl -fsS https://sub2api.userdomain.com/v1/chat/completions \
  -H "CF-Access-Client-Id:     <CLIENT_ID>.access" \
  -H "CF-Access-Client-Secret: <CLIENT_SECRET>" \
  -H "Authorization: Bearer $SUB2API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-5.5","messages":[{"role":"user","content":"say OK"}]}'

Without the CF-Access headers, the SDK request returns a Cloudflare login page (HTML) — that's the policy doing its job.


Part 8 — Configure your clients

8.1 Shell environment (laptop)

In ~/.zshrc or ~/.bashrc:

# Pick ONE base URL strategy:

# (a) Local LAN
export SUB2API_BASE_LAN=http://macmini-lab.local:8080

# (b) Tailscale (anywhere)
export SUB2API_BASE_TAILNET=http://macmini-lab.<tailnet>.ts.net:8080

# (c) Cloudflare Tunnel (public + Access)
export SUB2API_BASE_PUBLIC=https://sub2api.userdomain.com

# Default — set this to whichever you want as primary
export OPENAI_BASE_URL="$SUB2API_BASE_TAILNET/v1"
export OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxx

export ANTHROPIC_BASE_URL="$SUB2API_BASE_TAILNET"
export ANTHROPIC_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxx

# Cloudflare Access service token (only if using public URL)
export CF_ACCESS_CLIENT_ID="<CLIENT_ID>.access"
export CF_ACCESS_CLIENT_SECRET="<CLIENT_SECRET>"

8.2 Python openai SDK

from openai import OpenAI

client = OpenAI(
    base_url="https://sub2api.userdomain.com/v1",
    api_key="sk-xxxxxxxxxxxxxxxxxxxxxx",
    default_headers={
        "CF-Access-Client-Id":     "<CLIENT_ID>.access",
        "CF-Access-Client-Secret": "<CLIENT_SECRET>",
    },
)

resp = client.chat.completions.create(
    model="gpt-5.5",
    messages=[{"role": "user", "content": "Hello"}],
)
print(resp.choices[0].message.content)

8.3 Anthropic Python SDK

import anthropic

client = anthropic.Anthropic(
    base_url="https://sub2api.userdomain.com",
    api_key="sk-xxxxxxxxxxxxxxxxxxxxxx",
    default_headers={
        "CF-Access-Client-Id":     "<CLIENT_ID>.access",
        "CF-Access-Client-Secret": "<CLIENT_SECRET>",
    },
)

resp = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=200,
    messages=[{"role": "user", "content": "Hello"}],
)
print(resp.content[0].text)

8.4 Cursor

Settings → Models → OpenAI:

  • Enable Override OpenAI Base URL.
  • URL: http://macmini-lab.<tailnet>.ts.net:8080/v1 (Tailscale recommended — Cursor can't easily inject the CF-Access headers).
  • API Key: your sub2api sk-....
  • In Models, enable only the custom models you want (gpt-5.5, claude-sonnet-4-5). Disable everything else.
  • Click Verify.

If you must route Cursor through Cloudflare Tunnel, run a tiny local Caddy/Python relay on 127.0.0.1:8081 that injects the CF headers and forwards to the tunnel, then point Cursor at the relay.

8.5 Aider

# Via OpenAI surface
aider --model openai/gpt-5.5 \
      --openai-api-base "$OPENAI_BASE_URL" \
      --openai-api-key "$OPENAI_API_KEY"

# Via Anthropic surface
aider --model anthropic/claude-sonnet-4-5 \
      --anthropic-api-base "$ANTHROPIC_BASE_URL" \
      --anthropic-api-key "$ANTHROPIC_API_KEY"

8.6 Continue.dev

~/.continue/config.yaml:

name: sub2api
version: 0.0.1
schema: v1
models:
  - name: GPT-5.5 via sub2api
    provider: openai
    model: gpt-5.5
    apiBase: http://macmini-lab.local:8080/v1
    apiKey: sk-xxxxxxxxxxxxxxxxxxxxxx
    roles: [chat, edit, apply]
    useResponsesApi: false
  - name: Claude Sonnet 4.5 via sub2api
    provider: anthropic
    model: claude-sonnet-4-5
    apiBase: http://macmini-lab.local:8080
    apiKey: sk-xxxxxxxxxxxxxxxxxxxxxx
    roles: [chat, edit, apply]

8.7 Claude Code

Claude Code prefers its own subscription OAuth and doesn't always honor ANTHROPIC_BASE_URL for OAuth users. Easiest path: let Claude Code consume your Claude Max subscription directly (login with the CLI), and route other tools through sub2api. If you want to force Claude Code through sub2api, use Claude Code Router.


Part 9 — Make sub2api survive reboots

Docker's restart: unless-stopped plus OrbStack's "Open at Login" handles 90% of cases. For the rest, add a launchd watchdog.

mkdir -p ~/Library/LaunchAgents

# Find your docker binary path
DOCKER_BIN=$(which docker)
echo "Docker binary: $DOCKER_BIN"
# Common paths:
#   OrbStack:       ~/.orbstack/bin/docker
#   Docker Desktop: /usr/local/bin/docker

cat > ~/Library/LaunchAgents/com.local.sub2api.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.local.sub2api</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>-c</string>
    <string>cd $HOME/services/sub2api && ${DOCKER_BIN} compose -f docker-compose.local.yml up -d</string>
  </array>
  <key>RunAtLoad</key><true/>
  <key>StartInterval</key><integer>300</integer>
  <key>StandardOutPath</key><string>$HOME/services/sub2api/launchd.out.log</string>
  <key>StandardErrorPath</key><string>$HOME/services/sub2api/launchd.err.log</string>
</dict>
</plist>
EOF

launchctl load ~/Library/LaunchAgents/com.local.sub2api.plist
launchctl list | grep sub2api

The 5-minute interval re-runs compose up -d — a no-op if everything's running, recovery if anything crashed.

If you also installed LiteLLM, repeat with a second plist (com.local.litellm.plist) pointing at ~/services/litellm.


Part 10 — Maintenance

Update sub2api

cd ~/services/sub2api

# Read the release notes first
open "https://github.com/Wei-Shaw/sub2api/releases"

# Bump pinned version, then:
docker compose -f docker-compose.local.yml pull
docker compose -f docker-compose.local.yml up -d
docker compose -f docker-compose.local.yml logs -f sub2api

Backup the database

Account credentials live in Postgres. Periodic dump:

mkdir -p ~/Backups/sub2api
docker compose -f ~/services/sub2api/docker-compose.local.yml exec -T postgres \
  pg_dumpall -U postgres | gzip > ~/Backups/sub2api/$(date +%Y%m%d).sql.gz

Add to crontab for weekly:

crontab -e
# 0 3 * * 0  /bin/bash -lc 'docker compose -f $HOME/services/sub2api/docker-compose.local.yml exec -T postgres pg_dumpall -U postgres | gzip > $HOME/Backups/sub2api/$(date +\%Y\%m\%d).sql.gz'

Rotate keys

  • sub2api API key: dashboard → Users → API Keys → Revoke + Create new. Update clients.
  • Cloudflare service token: dashboard → Service Auth → Refresh. Update clients.
  • Admin password: dashboard → User Settings → Change Password.

Re-OAuth a provider account

Tokens occasionally fail (provider invalidates, you change subscription tier, etc.):

  1. Dashboard → Accounts → click the account → Re-authenticate.
  2. Same browser flow as initial setup.

Part 11 — Troubleshooting

sub2api container won't start

cd ~/services/sub2api
docker compose -f docker-compose.local.yml logs sub2api
docker compose -f docker-compose.local.yml ps

Common causes: port 8080 already in use (lsof -iTCP:8080 -sTCP:LISTEN), Postgres not healthy yet (wait 30s and retry), corrupted volume (docker compose down -v and re-init — destroys data).

Tests pass but Cursor / Aider returns errors

  • Confirm the model name your client sends matches what sub2api expects. The dashboard logs show inbound model names.
  • Check the rate-limit bars in the dashboard. If the 5h window is red, you're capped — wait for the reset.
  • For OpenAI surface: ensure the client uses /v1/chat/completions (not /v1/responses unless the client supports it).

Cloudflare Tunnel returns 502

sudo launchctl print system/com.cloudflare.cloudflared | head -40
tail -50 /Library/Logs/com.cloudflare.cloudflared.err.log

Usually means sub2api is down on the origin side. Verify curl http://localhost:8080/health works on the Mac mini itself.

Cloudflare Access blocks my SDK calls

  • Verify you sent both CF-Access-Client-Id and CF-Access-Client-Secret.
  • Verify the Client ID has the .access suffix.
  • Verify the Service Auth policy is on the same application as your hostname.
  • The OTP policy and Service Auth policy must coexist; if Service Auth is missing, SDK calls bounce to the login page.

Provider OAuth gets revoked

You'll see test failures in the dashboard with 401s. Steps:

  1. Re-authenticate the account in the dashboard.
  2. If repeat failures within hours, the provider may have flagged the client ID. Pause that account's group, route everything through the other provider until things stabilize, and check the Wei-Shaw/sub2api issues for similar reports.
  3. As last resort, swap clients to the official API endpoint with a real API key.

Part 12 — Security checklist

  • sub2api admin password changed from auto-generated default.
  • sub2api sk-... API keys are not in git, screenshots, or pasted into LLM prompts.
  • Cloudflare Access policies cover both browser (OTP) and SDK (service token) paths.
  • Cloudflare service token secret stored in password manager (it's only displayed once).
  • Mac mini full-disk encryption (FileVault) on.
  • Postgres port not exposed outside Docker bridge network.
  • sub2api port 8080 not exposed to the public internet directly (LAN, Tailscale, or behind Cloudflare Tunnel only).
  • Backup cron job confirmed running.
  • Pinned sub2api Docker tag (not :latest).
  • Fallback API keys (sk-proj-..., sk-ant-...) saved in password manager.

Appendix A — File and directory layout

~/services/
├── sub2api/
│   ├── docker-compose.local.yml
│   ├── .env
│   ├── docker-deploy.sh
│   └── launchd.out.log / launchd.err.log
└── litellm/
    ├── docker-compose.yml
    ├── config.yaml
    └── .env

~/.cloudflared/
├── cert.pem
├── <TUNNEL_UUID>.json
└── config.yml

~/Library/LaunchAgents/
├── com.local.sub2api.plist
└── com.local.litellm.plist     # if using LiteLLM

~/Backups/sub2api/
└── YYYYMMDD.sql.gz

Appendix B — Quick command reference

# Status
docker compose -f ~/services/sub2api/docker-compose.local.yml ps
docker compose -f ~/services/sub2api/docker-compose.local.yml logs -f sub2api

# Restart
docker compose -f ~/services/sub2api/docker-compose.local.yml restart sub2api

# Stop/start everything
docker compose -f ~/services/sub2api/docker-compose.local.yml down
docker compose -f ~/services/sub2api/docker-compose.local.yml up -d

# Cloudflare Tunnel status
sudo launchctl print system/com.cloudflare.cloudflared | head -20
cloudflared tunnel info sub2api

# Tailscale status
tailscale status
tailscale ip -4

# Health checks
curl -fsS http://localhost:8080/health                                    # local
curl -fsS http://macmini-lab.local:8080/health                            # LAN
curl -fsS http://macmini-lab.<tailnet>.ts.net:8080/health                 # Tailscale
curl -fsS https://sub2api.userdomain.com/health \
  -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
  -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET"                  # public

Appendix C — Cost & limits at a glance

Component Cost Notes
Mac mini (electricity) ~$3–5/mo M-series idles at 5–7W
ChatGPT Pro $100/mo ~10× Plus Codex usage on 5h window through May 2026
Claude Max $100/mo ~5× Pro on Sonnet, 1× Pro on Opus per 5h
Cloudflare Tunnel Free Including Access for up to 50 users
Tailscale Free Personal use, up to 100 devices
Domain (if needed for Cloudflare) ~$10/yr Any registrar that supports Cloudflare nameservers
Total monthly ~$200–205 Versus ~$400–800/mo for equivalent API-billed usage

Numbers above are subject to provider policy changes — re-check before relying on them.


Appendix D — When to abandon this setup

  • Provider sends you any email containing "unusual activity," "policy review," "automated tooling," or "service abuse" → stop, file an appeal, do not re-OAuth.
  • Provider quietly drops the third-party OAuth client ID → all wrapper tools (sub2api, auth2api, etc.) break the same hour. Switch to per-token API keys.
  • You want to share with a second person → buy a second subscription or use real API keys for the second user. The "personal use" framing stops applying.
  • You find yourself spending more time fixing this than coding → switch to API-key billing and pay per token. ~$50–150/mo for moderate use is often less stressful than maintaining a proxy stack.

Last updated: 2026. Pin against the Wei-Shaw/sub2api releases page for current information.