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_NAME → CLOUDFLARED_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).