ZYVOPMulti-Platform Sync
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZYVOP
The Developer Publishing Hub
PrivacyTermsGuidelinesDMCACommunity
© 2026 ZyVOP
HomeCloudflare Quick Tunnels: One Command, Three Hard Limits

Cloudflare Quick Tunnels: One Command, Three Hard Limits

One command puts localhost on the public internet with no account and no DNS. Here's what cloudflared really does, and the three limits that break apps quietly.

Sanju Singh
Sanju Singh
Senior Developer
September 19, 2026
13 min read
Cloudflare Quick Tunnels: One Command, Three Hard Limits
#Webhooks attachments: quick-tunnel.mjs#test.mjs#Cloudflare#Cloudflare Tunnel#DevOps#node.js

Stripe won't POST to localhost:3000. Neither will GitHub or Twilio. You've written the handler and you know the payload shape by heart, but your laptop has no address anyone outside your network can reach.

One command from Cloudflare fixes that, and it doesn't ask you to sign up for anything.

cloudflared tunnel --url http://localhost:3000

cloudflared prints a random *.trycloudflare.com hostname to your terminal. Paste it into Stripe's dashboard, send a test event, and it lands.

No account, no DNS record, no inbound firewall rule. Cloudflare terminates TLS at the edge and soaks up junk traffic before any of it reaches you.

What most walkthroughs leave out is that Quick Tunnels ship with three documented limits, and two of them break apps without raising an obvious error. So I'll move through the mechanics fast and spend the real time there.

What the command actually does

cloudflared is a Go daemon. Run it and it dials out to Cloudflare's edge rather than waiting for anything to connect inward.

That's backwards from how you'd normally expose a service, and it's why this works with no public IP. Your firewall sees an ordinary outbound connection, which it almost certainly already permits.

The daemon targets region1.v2.argotunnel.com and region2.v2.argotunnel.com on port 7844. It tries UDP first for QUIC, then falls back to TCP and HTTP/2 when UDP is blocked.

Cloudflare assigns a subdomain and traffic starts moving.

flowchart LR
    A[Stripe / browser / teammate] -->|HTTPS 443| B[Cloudflare edge]
    B -->|QUIC or HTTP/2<br/>port 7844| C[cloudflared on your laptop]
    C -->|plain HTTP| D[localhost:3000]
    C -.->|outbound only| B

The subdomain is a few random words joined with hyphens. Proofpoint's writeup on tunnel abuse quotes a real one: ride-fatal-italic-information.trycloudflare.com.

Stop the process and the hostname goes with it. Start it again and you get a different one, which is fine for a webhook test and a problem for anything you want to bookmark.

Installing cloudflared

Get it from Cloudflare's package repo or the GitHub releases page. Cloudflare publishes standalone binaries, a Docker image, and Debian, RPM and Homebrew packages.

Current release is 2026.9.1, out on September 11, 2026.

# macOS
brew install cloudflared

# Debian / Ubuntu
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb

# Docker
docker run --network=host cloudflare/cloudflared:latest \
  tunnel --no-autoupdate --url http://localhost:3000

Keep it reasonably fresh. Cloudflare's support window only covers releases from the past year, so the two-year-old binary baked into some Raspberry Pi image is going to be a problem eventually.

One behavioural change catches people out. Bare cloudflared tunnel doesn't start a Quick Tunnel on its own and hasn't for years. The --url flag is mandatory.

Limit one: 200 concurrent requests, then 429

Cloudflare caps a Quick Tunnel at 200 in-flight requests. Go past that and the edge hands back a 429 instead of proxying.

In-flight means concurrent, not per second, so it's more forgiving than it first sounds. An API returning 2ms responses will never get close.

Where it hurts is a page firing fifty or sixty parallel asset requests, multiplied by however many teammates have your preview link open at once.

The failure is also partial, which is worse than an outright outage. Some requests go through, some come back 429, and the page renders half-broken in a way that sends you hunting through your own code first.

If you're benchmarking anything through a Quick Tunnel, you're benchmarking the cap.

Limit two: Server-Sent Events don't work

SSE isn't supported on Quick Tunnels. Cloudflare states it flatly in the TryCloudflare docs and offers no workaround.

This matters more in 2026 than it would have in 2022. Most LLM streaming endpoints run on SSE, as do live log viewers and any progress indicator built on text/event-stream.

Demo a chat UI that streams tokens and you'll watch the page load fine, then watch the stream sit there doing nothing.

Beeper hit exactly this while writing their remote access guide. Their fix was to tell users to switch the MCP server from the SSE transport to Streamable HTTP whenever a Quick Tunnel sits in the path.

The Wrangler team went a step further and shipped a detector. If Wrangler spots an SSE response crossing the tunnel, it warns you, which tells you roughly how many people were running into this.

WebSockets are unaffected.

Limit three: no SLA, and the reason is interesting

Cloudflare makes no uptime or SLA promise for TryCloudflare. The docs explain why, and the explanation is more revealing than the disclaimer: free tunnels are where Cloudflare tries out new Tunnel features and improvements before those changes reach production customers.

So the Quick Tunnel edge is effectively a staging environment and your traffic is the test load. For something free, that's a fair deal. It's still a poor place to put a customer demo without a fallback ready.

The host header problem

Your dev server will probably reject the tunnel hostname before your handler ever sees a request.

Vite, webpack-dev-server and Rails all check the Host header against an allowlist. The tunnel sends random-words.trycloudflare.com, your server wants localhost, and you get "Invalid Host header" or a blank page.

cloudflared can rewrite the header on the way through:

cloudflared tunnel --url http://localhost:5173 \
  --http-host-header localhost:5173

That clears the error, but it also hides the real hostname from your app. If you're building absolute URLs or OAuth redirect URIs anywhere, you want the genuine one. Widen the allowlist instead:

// vite.config.js
export default defineConfig({
  server: {
    allowedHosts: [".trycloudflare.com"],
  },
});

Cloudflare's Workers docs flag the same requirement for vite preview, where the preview server runs its own host validation and needs .trycloudflare.com added to preview.allowedHosts.

Worth pausing on that, because Vite's own documentation tells you not to add domains you don't control to allowedHosts. The warning is about DNS rebinding: if an attacker controls what a whitelisted hostname resolves to, they can point it at your machine and have a victim's browser talk to your dev server.

.trycloudflare.com survives that objection in practice. Cloudflare owns the zone and resolves those names to its own anycast addresses, so nobody who grabs a tunnel subdomain can repoint it at 127.0.0.1. Adding .com or a domain a stranger owns is the case Vite is actually warning you about. Take the entry out when you're done tunnelling either way.

Reading the URL from a script, without grepping stdout

Most scripts I've come across pipe cloudflared's log output through grep and a regex to fish out the hostname. That holds up right until the log format shifts, and then CI breaks for a reason nobody enjoys tracking down.

There's a proper endpoint for it. Every running tunnel starts a Prometheus metrics server, and that server answers on /quicktunnel.

By default it takes the first free port between 20241 and 20245, falling back to a random port if all five are busy. Don't guess which one you landed on. Pin it with --metrics.

The response comes out of a single line in cloudflared's metrics.go, which formats one config field into {"hostname":"..."}. Before the edge has assigned a name, that field holds an empty string, so you get {"hostname":""} back rather than a 404 or an error. Poll until it isn't empty.

Pair it with a second endpoint. /ready returns {"status":200,"readyConnections":4,"connectorId":"..."} once connections are live, and a 503 with readyConnections at zero while they aren't. The hostname appears slightly before the tunnel can actually carry traffic, so checking both is what stops your test from being flaky.

Save this as quick-tunnel.mjs:

import { spawn } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";

const DEFAULT_METRICS_PORT = 20241;

/**
 * Polls cloudflared's metrics server until it reports a quick tunnel hostname.
 * cloudflared serves {"hostname":""} before the edge assigns one, so an empty
 * string means "not ready", not "failed".
 */
export async function waitForHostname(metricsAddr, { timeoutMs = 30000, intervalMs = 250 } = {}) {
  const deadline = Date.now() + timeoutMs;
  let lastError = null;

  while (Date.now() < deadline) {
    try {
      const res = await fetch(`http://${metricsAddr}/quicktunnel`);
      if (res.ok) {
        const { hostname } = await res.json();
        if (hostname) return `https://${hostname}`;
      }
    } catch (err) {
      lastError = err; // metrics server hasn't bound its port yet
    }
    await sleep(intervalMs);
  }

  throw new Error(
    `No quick tunnel hostname after ${timeoutMs}ms` +
      (lastError ? ` (last error: ${lastError.message})` : "")
  );
}

/** Waits for at least one connection to the Cloudflare edge to be live. */
export async function waitForReady(metricsAddr, { timeoutMs = 30000, intervalMs = 250 } = {}) {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    try {
      const res = await fetch(`http://${metricsAddr}/ready`);
      const body = await res.json();
      if (res.status === 200 && body.readyConnections > 0) return body;
    } catch {
      // not listening yet
    }
    await sleep(intervalMs);
  }

  throw new Error(`Tunnel never reported a ready connection within ${timeoutMs}ms`);
}

export async function startQuickTunnel({ port, metricsPort = DEFAULT_METRICS_PORT, timeoutMs = 30000 }) {
  const metricsAddr = `127.0.0.1:${metricsPort}`;

  const child = spawn(
    "cloudflared",
    [
      "tunnel",
      "--no-autoupdate",
      "--metrics", metricsAddr,
      "--url", `http://localhost:${port}`,
    ],
    { stdio: ["ignore", "inherit", "inherit"] }
  );

  const stop = () => new Promise((resolve) => {
    if (child.exitCode !== null) return resolve();
    child.once("exit", () => resolve());
    child.kill("SIGINT");
  });

  try {
    const url = await waitForHostname(metricsAddr, { timeoutMs });
    await waitForReady(metricsAddr, { timeoutMs });
    return { url, stop, process: child };
  } catch (err) {
    await stop();
    throw err;
  }
}

Using it in an integration test that needs a real public URL:

import { startQuickTunnel } from "./quick-tunnel.mjs";

const tunnel = await startQuickTunnel({ port: 3000 });
console.log(`Webhook endpoint: ${tunnel.url}/webhooks/stripe`);

// ... register the URL, run assertions ...

await tunnel.stop();

--no-autoupdate matters in CI. Leave it off and cloudflared may decide to swap out its own binary partway through a run.

Testing the pollers without a live tunnel

The two polling functions are the part most likely to rot, and you don't want a test suite that needs working outbound UDP to pass. Standing up a fake metrics server is enough.

Both response shapes come from cloudflared's own source, so the mock stays honest as long as those files don't change. Save this as test.mjs next to quick-tunnel.mjs:

import http from "node:http";
import assert from "node:assert/strict";
import { waitForHostname, waitForReady } from "./quick-tunnel.mjs";

// Mock cloudflared's metrics server. Response shapes copied from
// cloudflared/metrics/metrics.go and cloudflared/metrics/readiness.go.
function mockMetrics({ hostnameAfterMs, readyAfterMs }) {
  const start = Date.now();
  const server = http.createServer((req, res) => {
    const elapsed = Date.now() - start;

    if (req.url === "/quicktunnel") {
      const hostname = elapsed >= hostnameAfterMs ? "ride-fatal-italic-information.trycloudflare.com" : "";
      res.writeHead(200, { "content-type": "application/json" });
      return res.end(JSON.stringify({ hostname }));
    }

    if (req.url === "/ready") {
      const ready = elapsed >= readyAfterMs;
      res.writeHead(ready ? 200 : 503, { "content-type": "application/json" });
      return res.end(JSON.stringify({
        status: ready ? 200 : 503,
        readyConnections: ready ? 4 : 0,
        connectorId: "5f8d0a1e-2b3c-4d5e-8f90-1a2b3c4d5e6f",
      }));
    }

    res.writeHead(404).end();
  });
  return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server)));
}

const results = [];
const test = async (name, fn) => {
  try { await fn(); results.push(`PASS  ${name}`); }
  catch (e) { results.push(`FAIL  ${name} -> ${e.message}`); process.exitCode = 1; }
};

await test("resolves hostname once cloudflared stops returning an empty string", async () => {
  const server = await mockMetrics({ hostnameAfterMs: 600, readyAfterMs: 0 });
  const addr = `127.0.0.1:${server.address().port}`;
  const url = await waitForHostname(addr, { timeoutMs: 5000, intervalMs: 100 });
  assert.equal(url, "https://ride-fatal-italic-information.trycloudflare.com");
  server.close();
});

await test("does not treat the empty-hostname placeholder as a result", async () => {
  const server = await mockMetrics({ hostnameAfterMs: 99999, readyAfterMs: 0 });
  const addr = `127.0.0.1:${server.address().port}`;
  await assert.rejects(
    waitForHostname(addr, { timeoutMs: 700, intervalMs: 100 }),
    /No quick tunnel hostname after 700ms/
  );
  server.close();
});

await test("survives the metrics port not being bound yet", async () => {
  // Nothing is listening on 20999, so every poll hits ECONNREFUSED. The helper
  // should keep retrying and then report the connection error, not throw on the
  // first failed fetch.
  await assert.rejects(
    waitForHostname("127.0.0.1:20999", { timeoutMs: 800, intervalMs: 100 }),
    /last error/
  );
});

await test("waitForReady ignores 503 until a connection is live", async () => {
  const server = await mockMetrics({ hostnameAfterMs: 0, readyAfterMs: 500 });
  const addr = `127.0.0.1:${server.address().port}`;
  const body = await waitForReady(addr, { timeoutMs: 5000, intervalMs: 100 });
  assert.equal(body.status, 200);
  assert.equal(body.readyConnections, 4);
  server.close();
});

await test("waitForReady times out if no connection ever comes up", async () => {
  const server = await mockMetrics({ hostnameAfterMs: 0, readyAfterMs: 99999 });
  const addr = `127.0.0.1:${server.address().port}`;
  await assert.rejects(waitForReady(addr, { timeoutMs: 600, intervalMs: 100 }), /never reported a ready connection/);
  server.close();
});

console.log(results.join("\n"));

Run it with node test.mjs on Node 18 or newer, since it relies on the global fetch. Five checks, no network.

Both files are attached to this post: quick-tunnel.mjs and test.mjs.

If you'd rather not add a dependency at all, the same polling logic in bash:

cloudflared tunnel --no-autoupdate --metrics 127.0.0.1:20241 \
  --url http://localhost:3000 &

until [ -n "$(curl -s http://127.0.0.1:20241/quicktunnel | jq -r '.hostname')" ]; do
  sleep 0.5
done

URL="https://$(curl -s http://127.0.0.1:20241/quicktunnel | jq -r '.hostname')"
echo "Tunnel live at $URL"

Wrangler and Vite handle this natively now

If you're already inside Cloudflare's tooling you can skip the separate cloudflared process entirely. Tunnel support landed in the dev servers themselves on May 18, 2026.

Press t in a running wrangler dev session, or t then Enter in Vite, and the dev server opens a tunnel and prints the public URL.

Flags work too, per the Workers docs:

npx wrangler dev --tunnel                  # opens a Quick Tunnel at startup
npx wrangler dev --tunnel-name=my-tunnel   # uses a named tunnel instead

The Vite plugin takes it as config:

import { defineConfig } from "vite";
import { cloudflare } from "@cloudflare/vite-plugin";

export default defineConfig({
  plugins: [cloudflare({ tunnel: { name: "my-tunnel", autoStart: true } })],
});

The --tunnel flag shipped in Wrangler 4.86.0. One small nicety: the tunnel closes itself when the dev session ends, which saves you from the forgotten-tunnel problem in the next section.

The security part people skip

Your Quick Tunnel URL is a bearer token. Anyone holding that string reaches your dev server, with whatever authentication your dev server has, which is usually none at all.

Cloudflare's Workers docs are direct about the review worth doing first. Check for ungated preview or admin endpoints. Check any remote bindings wired to real resources. Check any code that proxies onward to private or internal services.

The last one is where I'd expect real damage. A dev server with a remote binding pointed at a production D1 database, sitting on a public URL, with no login in front of it.

There's a second dimension that affects you even when you do everything right. TryCloudflare has had an abuse problem for years.

Proofpoint tracked a financially motivated campaign built on this exact feature, and noted that threat-actor use of TryCloudflare picked up in 2023 and kept climbing. Payloads across the campaigns included Xworm, AsyncRAT, VenomRAT, GuLoader and Remcos, with Xworm dominating the later waves.

What attracts attackers is the same property that makes the feature useful to you. Disposable infrastructure comes up and goes down fast, which defeats any defence built on static blocklists.

Cloudflare told BleepingComputer that it disables and removes malicious tunnels once its team finds them or third parties report them.

The knock-on effect for you is mundane but worth knowing. Some corporate mail gateways and web proxies treat trycloudflare.com links as suspicious or block the domain outright. When a teammate tells you your preview link is dead, rule out their network filtering before you start debugging your app.

A few habits that cost nothing:

  • Kill the tunnel when you stop working, because a forgotten cloudflared in a tmux pane is a public endpoint running all night.

  • Put a shared secret in front of anything writable, even in dev. A header check is four lines.

  • Don't paste the URL into a public issue tracker or a Slack channel with 400 people in it.

One more that's easy to miss if you're tunnelling a Vite dev server. HMR and module serving can leak source files, file paths and your project structure to anyone on the other end of the link. Cloudflare's guidance is to share a vite preview build rather than vite dev when the audience is public, and it's good advice regardless of which tunnel you're using.

Two 2026 changes worth knowing

proxy-dns is gone. From February 2, 2026, Cloudflare stopped shipping the proxy-dns command in new cloudflared releases, citing a vulnerability in an underlying DNS library. Core Tunnel functionality is untouched. If you were running cloudflared as a DNS-over-HTTPS resolver alongside Pi-hole, though, that setup doesn't survive an upgrade past that release.

Startup pre-checks. Version 2026.5.2 moved connectivity diagnostics into the binary. On every tunnel run, cloudflared now verifies that the argotunnel regions resolve, that outbound UDP and TCP reach port 7844, and that api.cloudflare.com answers on TCP/443.

Results print as a table with pass, warn and fail states. When DNS fails outright, or both transports are blocked on 7844, the process exits with the actual reason instead of retrying against an opaque dial error. If you've ever spent an afternoon on a corporate firewall, that change alone is worth the upgrade.

When to stop using Quick Tunnels

Time to move to a named tunnel once any of these is true:

Signal

Why Quick Tunnels fail

The URL needs to survive a restart

Every run generates a new random hostname

You're registering an OAuth callback

Providers want a stable redirect URI

Real users will hit it

200 in-flight requests, and no SLA

You're streaming with SSE

Unsupported at the edge

You need access control

Named tunnels sit behind Cloudflare Access

Cloudflare's Sandbox SDK docs draw the same line. Quick tunnels suit local development, demos and short-lived deployments where a throwaway URL is fine. Named tunnels are what they recommend for production traffic, webhook receivers, OAuth callbacks and anything a person might bookmark.

Named tunnels need a Cloudflare account and a domain on Cloudflare. Setup runs about five minutes and the get-started guide covers it end to end.

ngrok, localtunnel and Tailscale Funnel solve the same problem with different tradeoffs on pricing, stable hostnames and request inspection. Quick Tunnels win on one axis specifically: zero setup, zero account.

FAQ

Does a Quick Tunnel need a Cloudflare account? No. No account, no API token, no DNS record, no domain. That's the whole point of the feature.

How long does the URL last? Exactly as long as the cloudflared process. Cloudflare suggests running it under screen, tmux or a background service if you need it to stay up. Restarting gives you a fresh hostname.

Can I pick my own subdomain? No, Cloudflare assigns it. If you need a specific name, that's a named tunnel.

Do WebSockets work? Yes. SSE is the documented exception, not streaming in general.

What ports does cloudflared need open outbound? 7844 for UDP and TCP, plus TCP/443 to api.cloudflare.com for update checks. Run cloudflared tunnel diag when either one is blocked.

Is it really free? Yes, with no request quota beyond the 200 in-flight cap. Cloudflare uses the traffic to exercise pre-release tunnel code, which is the actual price.


Command behaviour, limits and version numbers here were checked against Cloudflare's Tunnel docs, the TryCloudflare reference, the cloudflared source and the Cloudflare Tunnel changelog as of September 19, 2026.

Comments (0)

Join the discussion by logging into your account.

Sanju Singh
Sanju Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Sanju Singh's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.

Sanju Singh
Like
Love
Clap
Fire
Party
Wow

More from Sanju Singh

View profile

From 64MB to 16GB: How Software Got So Hungry

Microsoft's published minimum RAM requirement rose roughly 256x between 2001 and 2024. Here's the paper trail behind that number, a correction to the most-repeated Tauri benchmark, and a way to measure your own Electron app's memory footprint tonight.

6 minSep 18

Neural Networks, Explained Simply - Part 2: How Neural Networks Actually Learn

Part 2 of our Neural Network Series: how a neural network starts out guessing randomly and learns from its mistakes through training and backpropagation. A plain-language look at how the correction cycle actually works, no calculus needed.

3 minSep 17

Is the AI Industry's Slowdown a Safefy Pact or a Cartel ?

Amodei's essay got quick backing from Altman and Musk, a market selloff, and an antitrust backlash. Here is the three-stage plan, the safety case, the cartel case, and what would actually settle which one is true.

7 minSep 15

Everyone Should Slow Down AI Development (Except Me)

Three rival AI companies all called for the industry to slow down within the same day. A satirical look at what that kind of pledge actually costs the people making it, and a simple test for telling real restraint from strategic timing.

2 minSep 13

Has AI Made You a Lazier Developer?

In 2025, an AI coding agent deleted a startup founder's database, then falsely claimed the damage was permanent. That story — and the quieter version of it happening in code review every day — shows the real dividing line was never effort. It's verification.

6 minSep 12