Skip to content

Backend over Cloudflare Tunnel

The mobile app needs a backend it can reach from anywhere: TestFlight testers are not on your Wi-Fi, so a LAN address will not do. A Cloudflare Tunnel gives the backend running on your laptop a public HTTPS hostname, with no ports opened on your router and no cloud hosting bill.

That hostname is what goes into EXPO_PUBLIC_API_URL for preview and production builds.

Use a named tunnel, not a quick tunnel

cloudflared tunnel --url http://localhost:8000 works with no account and prints a *.trycloudflare.com URL. It is useful for a one-off check, but it is the wrong tool here: the URL is regenerated every time cloudflared restarts.

Expo inlines EXPO_PUBLIC_API_URL into the JavaScript bundle at build time, so a build carrying a quick-tunnel URL stops working the moment you restart the tunnel, and fixing it means a new build and a new TestFlight release.

A named tunnel bound to a hostname you control survives restarts, reboots, and IP changes. Use that.

Requirements

  • A Cloudflare account.
  • A domain in that account (the tunnel routes a subdomain of it).
  • cloudflared installed: brew install cloudflared.

One-time setup

# 1. Authorize cloudflared and pick your domain in the browser window.
#    Writes ~/.cloudflared/cert.pem.
cloudflared tunnel login

# 2. Create the tunnel. Prints a UUID and writes
#    ~/.cloudflared/<UUID>.json, which is the tunnel's credential.
cloudflared tunnel create senior-health-api

# 3. Point a hostname at it. This creates the DNS record for you.
cloudflared tunnel route dns senior-health-api api.example.com

Then write ~/.cloudflared/config.yml, using backend/tunnel.example.yml as the template:

tunnel: senior-health-api
credentials-file: /Users/YOUR_USER/.cloudflared/YOUR_TUNNEL_UUID.json

ingress:
  - hostname: api.example.com
    service: http://localhost:8000
  - service: http_status:404

The trailing http_status:404 catch-all is required; cloudflared refuses to start without one.

Running it

pnpm dev:backend:tunnel   # backend on :8000 and the tunnel, together

Or separately, which is easier to debug:

pnpm dev:backend          # terminal 1
pnpm tunnel               # terminal 2

Verify from a machine that is not yours (or from your phone on cellular, with Wi-Fi off, which is the case that actually matters):

curl https://api.example.com/health     # => {"status":"ok"}

Pointing the app at it

cd apps/mobile
eas env:create --environment production \
  --name EXPO_PUBLIC_API_URL --value https://api.example.com --visibility plaintext

No trailing path: the app appends /health and /transcribe itself. Trailing slashes are stripped, so https://api.example.com/ is also fine.

CORS_ORIGINS does not need changing for TestFlight. CORS is a browser rule and native iOS ignores it; it only matters for the Expo web build.

DEEPGRAM_API_KEY does need to be set wherever the backend runs, or /transcribe answers 502 and voice answering stays broken. It is read from backend/.env in local development.

What this costs you

The laptop is the server. That means:

  • It must be awake, online, and running both processes whenever a tester opens the app. A closed lid means voice answers fail. Everything else in the app keeps working, because music previews, video, and photos are fetched directly or bundled.
  • Cloudflare Tunnel itself is free on any plan, including Free.

For a handful of known testers this is a reasonable trade. For anything longer-lived, deploy the container instead: backend/Dockerfile is self-contained and runs on any container host.

Security

The backend has no authentication. Once tunneled, anyone who learns the hostname can reach every endpoint, which means they can spend your Deepgram credits and run ffmpeg on your laptop.

Mitigations worth applying:

  • Keep the hostname unguessable, and do not publish it.
  • Run the tunnel only during test sessions rather than leaving it up.
  • MAX_UPLOAD_BYTES caps upload size (default 50 MB); lower it if you like.
  • Add a Cloudflare WAF rate-limiting rule on the hostname.

Cloudflare Access is the usual answer for locking a tunnel down, but it expects a browser login or a service token, and the app sends neither, so turning it on would block the app as well.