Cloudflare Tunnel for Expo — a drop-in replacement for @expo/ngrok.
Powers expo start --tunnel using Cloudflare Tunnel instead of ngrok.
- Faster than ngrok — ngrok caps you around 1 MB/s, while Cloudflare Tunnel isn't rate-limited; on my connection it used my full 5 MB/s of bandwidth.
- Stable URLs (named tunnels) — opt in with two env vars in
.env.localif you own a domain on Cloudflare. - No ngrok account, no authtoken, no rate limits — quick tunnels are free with no sign-up.
Expo CLI's --tunnel flag looks for @expo/ngrok in your project's node_modules.
You install expo-cloudflared at that path by declaring @expo/ngrok as an
alias dependency that points at this package. Expo CLI finds it there, calls
connect() and kill() — which this package implements with the same API shape —
and gets a Cloudflare Tunnel URL back. Expo CLI never knows it isn't ngrok.
You do not need the real @expo/ngrok package — the alias takes its place. If
@expo/ngrok is installed globally, the local alias still wins.
Add one alias dependency: @expo/ngrok pointing at expo-cloudflared. That's the
entire install, and it's identical on npm, yarn (v1 classic and Berry), pnpm, and bun:
{
"dependencies": {
"@expo/ngrok": "npm:expo-cloudflared@^4.2.1"
}
}Then install and start:
npm install # or: yarn / pnpm install / bun install
npx expo start --tunnelThat's it — expo start --tunnel now prints an https://xxxx.trycloudflare.com URL.
Why an alias dependency and not
overrides/resolutions? Those fields only rewrite an existing dependency on@expo/ngrok. A normal Expo app doesn't depend on@expo/ngrok(Expo fetches it on demand at runtime), so there is no edge to rewrite —node_modules/@expo/ngroknever gets created and Expo falls back to real ngrok. Declaring the alias as a direct dependency is what guarantees the path exists right afterinstall.
Put the alias in the workspace package you run expo start from (e.g.
apps/mobile/package.json) — not only the repo root. Two footguns specific to yarn
Berry workspaces:
resolutionsis honored only at the workspace root; aresolutionsblock in a child workspace'spackage.jsonis silently ignored.- Even at the root,
resolutionsalone won't createnode_modules/@expo/ngrok(see the note above), so it can't fix this on its own.
The alias-dependency approach sidesteps both — it works the same in a monorepo as in a single-package app.
Quick tunnels get a random URL each session. If you own a domain on Cloudflare (free plan is fine), a named tunnel gives you the same URL every time — great for OAuth callbacks, webhooks, or sharing a dev build with teammates.
One-time setup:
npx expo-cloudflared setupThe wizard logs you into Cloudflare, creates the tunnel, routes a DNS hostname,
and prints the two env vars to put in your Expo project's .env.local:
CLOUDFLARED_TUNNEL_NAME=my-expo-tunnel
CLOUDFLARED_TUNNEL_HOSTNAME=dev.yourdomain.comExpo CLI loads .env.local automatically — the next expo start --tunnel serves
your dev server at https://dev.yourdomain.com, with zero code changes.
| Variable | Effect |
|---|---|
CLOUDFLARED_TUNNEL_NAME |
Run this named tunnel (name mode — needs the one-time setup/cloudflared tunnel login). Routes to the actual dev-server port automatically. |
CLOUDFLARED_TUNNEL_HOSTNAME |
The public hostname to report as the tunnel URL (e.g. dev.yourdomain.com). |
CLOUDFLARED_TUNNEL_TOKEN |
Run a dashboard-managed tunnel by token (token mode). Ingress is fixed in the Cloudflare dashboard — prefer name mode for Expo, where the port can change. |
CLOUDFLARED_VERSION |
Pin the cloudflared binary release to download. Pinned versions are cached side by side, so projects pinning different versions never conflict. |
CLOUDFLARED_BIN |
Use an existing cloudflared binary at this path instead of downloading. |
CLOUDFLARED_METRICS_URL |
Override the cloudflared metrics server URL used by getApi(). |
Precedence: explicit connect() options → CLOUDFLARED_TUNNEL_NAME → CLOUDFLARED_TUNNEL_TOKEN → quick tunnel.
The cloudflared binary (~40 MB) is cached once per device in Expo's
user-level settings directory — ~/.expo/expo-cloudflared/ — not inside
node_modules. Every project on the machine shares it, and it survives
node_modules wipes, rm -rf .expo, and fresh installs. Pinned versions get
version-suffixed filenames (cloudflared-2026.5.0) alongside the shared
unpinned one.
const cloudflared = require('expo-cloudflared')
// Quick tunnel — no Cloudflare account needed
const url = await cloudflared.connect(8081)
// https://xxxx.trycloudflare.com
// With options
const url2 = await cloudflared.connect({
addr: 8081,
proto: 'http',
onStatusChange: (status) => console.log('Tunnel:', status), // "connected" | "closed"
onLogEvent: (line) => console.log('[cf]', line),
})
// Named tunnel — stable URL
const url3 = await cloudflared.connect({
tunnelName: 'my-expo-tunnel',
hostname: 'dev.yourdomain.com',
})
// Named tunnel via dashboard token
const url4 = await cloudflared.connect({ token: process.env.CLOUDFLARED_TUNNEL_TOKEN })
await cloudflared.kill()npx expo-cloudflared <command>
Commands:
install Download the cloudflared binary (happens lazily on first tunnel otherwise)
install --force Re-download even if already installed
version Print the installed cloudflared version
setup Guided named-tunnel setup (login → create → route DNS → env vars)
tunnel [port] Start a quick tunnel on [port] (default: 3000)
tunnel --token <token> Start a token-based named tunnel
tunnel [port] --name <name> [--hostname <host>]
Start a locally-managed named tunnel
Returns Promise<string> — the public HTTPS URL.
| Option | Type | Default | Description |
|---|---|---|---|
addr |
number | string |
3000 |
Local port or address to expose |
port |
number |
— | Alias for addr (used by Expo CLI internally) |
proto |
"http" | "https" | "tcp" | "ssh" |
"http" |
Protocol of the local service |
token |
string |
— | Cloudflare Tunnel token (token mode) |
tunnelName |
string |
— | Named tunnel to run (name mode) |
hostname |
string |
— | Public hostname reported as the URL (named modes) |
logLevel |
"debug" | "info" | "warn" | "error" | "fatal" |
— | cloudflared log verbosity |
metricsUrl |
string |
auto | cloudflared metrics server URL (passed as --metrics) |
startTimeoutMs |
number |
30000 |
How long to wait for the tunnel URL |
onLogEvent |
(line: string) => void |
— | Called for every cloudflared log line |
onStatusChange |
(status) => void |
— | "connected" | "closed" (closed only fires on unexpected crashes) |
@expo/ngrok fields that don't apply to Cloudflare Tunnel are accepted but ignored:
authtoken, configPath, subdomain, region (and Expo's *.exp.direct hostname).
| Export | Description |
|---|---|
disconnect(url?) / kill() |
Stop the tunnel (the url arg is ngrok-compat, ignored) |
getUrl() |
Active public URL, or null |
getVersion() |
Installed cloudflared binary version string |
getActiveProcess() |
The cloudflared ChildProcess, or null |
getApi() |
CloudflaredClient for the local metrics server (auto-discovered), or null |
ensureBinary(options?) / isInstalled() |
Manage the binary programmatically |
authtoken() |
No-op ngrok-compat stub |
CloudflaredError |
Error class with a code (e.g. ERR_CERT_MISSING, ERR_NO_HOSTNAME) |
const api = cloudflared.getApi()
await api.healthcheck() // boolean
await api.ready() // boolean
await api.getMetrics() // Prometheus-format string| Quick Tunnel | Named Tunnel | |
|---|---|---|
| Cloudflare account required | No | Yes (free) + a domain |
| URL stability | Random each session | Permanent |
| Setup | None | One-time npx expo-cloudflared setup |
| How to use with Expo | expo start --tunnel |
+ two env vars in .env.local |
| Example URL | https://xxxx.trycloudflare.com |
https://dev.yourdomain.com |
Expo CLI only accepts an @expo/ngrok replacement whose version satisfies
^4.1.0. This package therefore stays on 4.x forever — breaking changes
ship as minor bumps within 4.x, never as a 5.0.0.
Node.js ≥ 18.17. macOS (x64/arm64), Linux (x64/arm64/arm), Windows (x64/arm64).