Nuxt module that starts a Cloudflare Quick Tunnel to the dev server on startup, with the URL available at runtime.
- Starts a Cloudflare Quick Tunnel automatically on
nuxt dev, no Cloudflare account orcloudflaredlogin required. - Tunnel URL exposed at runtime via
useRuntimeConfig()and a$tunnelUrl/$isTunnelplugin. - Automatically allows the tunnel host through Vite's dev server host check.
- Tunnel additional local services (e.g. Storybook, an API) over their own Quick Tunnels via the
storybookshorthand or a generictunnelsarray. - No-op outside
nuxt dev. Nothing added to production builds. - Configurable port, and can be disabled entirely (e.g. in CI).
This module requires:
- Nuxt
^4.0.0 - A network path to Cloudflare's edge (Quick Tunnels are created over the open internet, so they won't work fully offline or behind an egress-restricted proxy)
Install the module to your Nuxt application with one command:
npx nuxt module add nuxt-cloudflared-tunnelOr install manually:
# pnpm
pnpm add -D nuxt-cloudflared-tunnel
# yarn
yarn add -D nuxt-cloudflared-tunnel
# npm
npm install -D nuxt-cloudflared-tunnelThen add it to the modules section of nuxt.config.ts:
export default defineNuxtConfig({
modules: ['nuxt-cloudflared-tunnel'],
})That's it. Run nuxt dev and the tunnel starts automatically once the dev server is listening:
π Starting Cloudflare tunnel for Nuxt (:3000)...
π Nuxt tunnel ready at: https://<random-words>.trycloudflare.com
π Allowed tunnel host: <random-words>.trycloudflare.com
The tunnel URL is exposed to the app at runtime via useRuntimeConfig().public.cloudflaredTunnelUrl, and $tunnelUrl / $isTunnel from the runtime plugin (src/runtime/plugin.ts).
Nuxt has decent support for tunneling via nuxi dev --tunnel (also backed by Cloudflare Quick Tunnels), but using it as a one-off CLI flag has the same limitations as running cloudflared in a second terminal by hand:
- The tunnel isn't tied to the Nuxt config or committed to the repo, so every contributor has to know to pass the flag (or run
cloudflaredmanually) themselves. - The URL isn't exposed anywhere the app can read it. Building callback URLs (OAuth redirects, Stripe/GitHub webhooks) for dynamic Quick Tunnel URLs means manually copying the printed URL around.
- There's no way to disable it per-environment (e.g. CI) via config, only by remembering to drop the flag.
This module makes the tunnel a first-class part of the Nuxt config: modules: ['nuxt-cloudflared-tunnel'], committed once, and the URL is available at runtime via useRuntimeConfig() and $tunnelUrl/$isTunnel, for exactly the callback-URL use case above.
Configure the module at the cloudflaredTunnel key in nuxt.config.ts:
export default defineNuxtConfig({
cloudflaredTunnel: {
enabled: true, // set false to disable the tunnel entirely
port: undefined, // override the port; defaults to the dev server's detected port (falls back to 3000)
log: true, // set false to silence the module's console output
storybook: false, // set true to also tunnel a Storybook dev server on :6006
tunnels: [], // arbitrary extra tunnel targets
},
})| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Disable to skip starting a tunnel (e.g. in CI or restricted networks). |
port |
number |
undefined |
Force a specific local port instead of auto-detecting the dev server's. |
log |
boolean |
true |
Log the tunnel URL and allowed host to the console. |
storybook |
boolean |
false |
Shorthand to tunnel a Storybook dev server on port 6006 (5s startup delay). |
tunnels |
TunnelTarget[] |
[] |
Extra services to tunnel. Each { port, label, delay? } opens a separate Quick Tunnel. |
The storybook option is a convenience shorthand for the most common case. For anything else (API servers, Storybook on a non-default port, etc.), use the generic tunnels array:
export default defineNuxtConfig({
cloudflaredTunnel: {
tunnels: [
{ port: 6006, label: 'Storybook', delay: 5_000 },
{ port: 8080, label: 'API' },
],
},
})Each target opens a separate Quick Tunnel with its own *.trycloudflare.com URL. The delay field (in ms) defers the tunnel start - useful for secondary services that take longer to boot.
In Nuxt 3/4, Storybook runs as a completely separate process (via @storybook-vue/nuxt), not inside Nuxt. The module simply opens a tunnel to whatever is listening on the configured port - it doesn't manage Storybook's lifecycle.
Cloudflare Quick Tunnels use random *.trycloudflare.com hostnames. Vite (which powers Storybook's dev server) blocks requests from unrecognized hosts by default, so you must configure Storybook's Vite to allow all hosts:
// .storybook/main.ts
viteFinal: (config) => {
config.server ??= {}
config.server.allowedHosts = true
return config
},The module handles the Nuxt side automatically (vite.server.allowedHosts = true), but Storybook's Vite config is separate.
The playground ships a working Storybook setup (config under .storybook/, sample components and stories in playground/components/) wired up with cloudflaredTunnel: { storybook: true } - run pnpm dev:all to see both services tunneled.
On the Nuxt listen hook (fired once the dev server is accepting connections), the module:
- Resolves the port to tunnel:
options.portif set, otherwise the dev server's listening port, falling back to3000if the address can't be read (e.g. a Unix socket). - Calls
startTunnel()fromuntun, accepting Cloudflare's terms non-interactively. - Awaits
tunnel.getURL()for the publichttps://*.trycloudflare.comURL. - Sets
vite.server.allowedHosts = trueso Vite's dev server accepts requests with the tunnel'sHostheader (Vite blocks unrecognized hosts by default). - Exposes the URL via
runtimeConfig.public.cloudflaredTunnelUrl.
For each extra tunnel target (including storybook: true), steps 2-3 repeat with the target's port. Targets with a delay are started via setTimeout so the primary tunnel isn't blocked.
If untun fails to start a tunnel (no tunnel returned, or the call rejects, e.g. no network access to Cloudflare's edge), the error is logged with console.error and dev server startup continues unaffected.
Q: Does this work in production?
A: No, by design. The module only hooks the listen event, which only fires in nuxt dev. There is no code path that starts a tunnel in a built/production app.
Q: Why does the tunnel URL change every time I restart the dev server?
A: It's a Cloudflare Quick Tunnel, which is ephemeral by design: free, anonymous, and reassigned on every connection. See Roadmap for plans around stable, named tunnels.
Q: Can I use this with nuxi dev --tunnel at the same time?
A: No, both would try to tunnel the same port. Use one or the other; this module exists so the tunnel doesn't depend on remembering a CLI flag.
Today this module only wraps Cloudflare Quick Tunnels: ephemeral, free, no Cloudflare account config required, but the URL changes every time the dev server restarts. That's fine for ad-hoc sharing (phone testing, a one-off webhook test) but not for anything needing a stable URL across restarts (OAuth app settings, third-party webhook configs that don't support easy URL updates).
Possible future scope, not yet implemented:
- Named Tunnel support: a fixed hostname across restarts, via Cloudflare's authenticated tunnels (requires
cloudflaredlogin plus tunnel/DNS setup the module would need to manage or document). - Custom hostname config (
cloudflaredTunnel: { hostname: 'dev.example.com' }) on top of named tunnel support. - DevTools panel integration, QR code output for mobile testing.
- Tunnel health monitoring / automatic reconnection.
None of this is built. The module is intentionally a small, focused wrapper around untun's Quick Tunnel support today.
Unit tests live in test/module.test.ts and run with pnpm test. @nuxt/kit and untun are mocked so the tests exercise the module's setup() logic directly: port resolution, the listen hook, runtime config/Vite mutation, storybook shorthand, tunnels array handling, and tunnel-start failure handling, without booting a real Nuxt instance or network tunnel.
Local development
# Install dependencies
pnpm install
# Generate type stubs
pnpm dev:prepare
# Develop with the playground
pnpm dev
# Build the playground
pnpm dev:build
# Run Storybook (on :6006) - tunneled via the module's `storybook` option
pnpm storybook
# Build Storybook as a static site
pnpm build-storybook
# Run ESLint
pnpm lint
# Run Vitest
pnpm test
pnpm test:watchBug reports and feature requests are welcome via GitHub Issues.