Skip to content

Latest commit

 

History

History

README.md

expo-cloudflared

npm TypeScript

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.local if you own a domain on Cloudflare.
  • No ngrok account, no authtoken, no rate limits — quick tunnels are free with no sign-up.

How it works

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.


Setup for expo start --tunnel

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 --tunnel

That'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/ngrok never gets created and Expo falls back to real ngrok. Declaring the alias as a direct dependency is what guarantees the path exists right after install.

Monorepos / workspaces

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:

  • resolutions is honored only at the workspace root; a resolutions block in a child workspace's package.json is silently ignored.
  • Even at the root, resolutions alone won't create node_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.


Stable URLs with named tunnels

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 setup

The 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.com

Expo CLI loads .env.local automatically — the next expo start --tunnel serves your dev server at https://dev.yourdomain.com, with zero code changes.

Env vars reference

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_NAMECLOUDFLARED_TUNNEL_TOKEN → quick tunnel.

Where the binary lives

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.


Direct usage (without Expo CLI)

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()

CLI

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

API Reference

connect(options?)

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).

Other exports

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

Two tunnel modes

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

Versioning

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.

Requirements

Node.js ≥ 18.17. macOS (x64/arm64), Linux (x64/arm64/arm), Windows (x64/arm64).