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 flyThis command handles the full flow:
- Finds free ports for all three servers
- Creates Cloudflare tunnels
- Writes tunnel URLs to
.expo-air.local.json - Builds and installs the app on your connected iOS device
- Starts the prompt server and both Metro instances
expo-air start
start also creates tunnels by default:
npx expo-air startTo skip tunnels for local-only development:
npx expo-air start --no-tunnelTunnel providers
| Provider | Status | Notes |
|---|---|---|
| Cloudflare | Default | Free, fast, recommended |
| localtunnel | Fallback | Used if Cloudflare is rate-limited |
| bore | Fallback | Alternative 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
startorfly - Read by the config plugin which writes the URLs into
Info.plistduring prebuild
ATS exceptions
The config plugin automatically adds App Transport Security exceptions for all tunnel domains:
trycloudflare.comloca.ltbore.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
| Field | Type | Description |
|---|---|---|
port | number | Local port to tunnel |
name | string | Friendly name shown in logs |
envVar | string | Environment 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.comYour 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
| Issue | Solution |
|---|---|
| Cloudflare rate limit | Wait a few minutes, or use --no-tunnel for local dev |
| Tunnel won't connect | Check your internet connection; try restarting with expo-air start |
| Slow tunnel performance | Cloudflare tunnels are fastest; ensure you're not falling back to localtunnel |
| URLs expired | Tunnel URLs change every session — just restart start or fly |