expo-air

Remote Development

Tunnels, fly command, and developing from anywhere

Why tunnels?

By default, Metro serves your app over the local network. When your device isn't on the same WiFi — or you're developing from a coffee shop, testing on a remote device, or sharing a dev build — you need a tunnel to bridge the connection.

expo-air uses Cloudflare tunnels by default, with automatic fallback providers.

Getting started

expo-air fly

The easiest way to develop remotely. Builds, installs, and starts everything with tunnels:

npx expo-air fly

This command handles the full flow:

  1. Finds free ports for all three servers
  2. Creates Cloudflare tunnels
  3. Writes tunnel URLs to .expo-air.local.json
  4. Builds and installs the app on your connected iOS device
  5. Starts the prompt server and both Metro instances

expo-air start

start also creates tunnels by default:

npx expo-air start

To skip tunnels for local-only development:

npx expo-air start --no-tunnel

Tunnel providers

ProviderStatusNotes
CloudflareDefaultFree, fast, recommended
localtunnelFallbackUsed if Cloudflare is rate-limited
boreFallbackAlternative fallback

expo-air automatically falls back to the next provider if the primary one fails or is rate-limited.

Tunnel configuration

.expo-air.local.json

When tunnels are created, expo-air writes the URLs to .expo-air.local.json:

{
  "serverUrl": "wss://random-name.trycloudflare.com",
  "widgetMetroUrl": "https://random-name.trycloudflare.com",
  "appMetroUrl": "https://random-name.trycloudflare.com"
}

This file is:

  • Gitignored — never committed
  • Regenerated every time you run start or fly
  • Read by the config plugin which writes the URLs into Info.plist during prebuild

ATS exceptions

The config plugin automatically adds App Transport Security exceptions for all tunnel domains:

  • trycloudflare.com
  • loca.lt
  • bore.pub

No manual Info.plist editing needed.

Extra tunnels

If your app connects to local services (API server, backend, etc.), you can tunnel those too. Add extraTunnels and envFile to your .expo-air.json:

{
  "autoShow": true,
  "ui": {
    "bubbleSize": 60,
    "bubbleColor": "#007AFF"
  },
  "envFile": ".env.local",
  "extraTunnels": [
    {
      "port": 3000,
      "name": "API Server",
      "envVar": "EXPO_PUBLIC_API_URL"
    },
    {
      "port": 5000,
      "name": "Backend",
      "envVar": "EXPO_PUBLIC_BACKEND_URL"
    }
  ]
}

Each entry in extraTunnels creates a Cloudflare tunnel for the specified local port. The tunnel URL is written to the env file under the given envVar name.

Extra tunnel config

FieldTypeDescription
portnumberLocal port to tunnel
namestringFriendly name shown in logs
envVarstringEnvironment variable to write the tunnel URL to

Result

When you run expo-air start or fly, the env file is auto-generated:

# .env.local (auto-generated by expo-air)
EXPO_PUBLIC_API_URL=https://xyz.trycloudflare.com
EXPO_PUBLIC_BACKEND_URL=https://abc.trycloudflare.com

Your app can then read these URLs via process.env.EXPO_PUBLIC_API_URL to connect to your local services through the tunnel.

Connection resilience

The HMR auto-reconnect module handles tunnel instability automatically:

  • Reconnects with exponential backoff when the connection drops
  • Replays Metro registration messages to restore the HMR session
  • Notifies the prompt server to re-trigger file updates

This means tunnel drops, WiFi transitions, and app backgrounding are handled transparently — your development session resumes without manual intervention.

Troubleshooting tunnels

IssueSolution
Cloudflare rate limitWait a few minutes, or use --no-tunnel for local dev
Tunnel won't connectCheck your internet connection; try restarting with expo-air start
Slow tunnel performanceCloudflare tunnels are fastest; ensure you're not falling back to localtunnel
URLs expiredTunnel URLs change every session — just restart start or fly