Every developer hits the same wall eventually. You are building a webhook integration, testing an OAuth flow that demands a public redirect URI, or trying to show a client a work-in-progress without deploying it. Your app is running fine on localhost:5000, but the outside world cannot reach it.
The usual workarounds all have friction:
- ngrok free tier: random URL that changes on every restart, session time limits, no custom domains without a paid plan.
- Port forwarding on your router: requires admin access, exposes your home IP, does not work in most corporate networks or when you are on a hotspot.
- Dynamic DNS: still needs an open port and a static-ish IP, and usually involves configuring a router you do not own.
- Deploying to staging: breaks your tight feedback loop. You are now maintaining two environments to test a single webhook.
Cloudflare Tunnel solves all of this. It is free for the core functionality, works behind NAT and firewalls, and gives you a stable subdomain tied to your own domain.
What Cloudflare Tunnel Is#
Cloudflare Tunnel (previously Argo Tunnel) creates an outbound-only encrypted connection from a daemon running on your machine (cloudflared) to Cloudflare’s edge network. Traffic from the public internet hits Cloudflare’s edge, gets routed through that persistent connection, and arrives at your local service — without any inbound port needing to be open.
The key point: no inbound firewall rules, no port forwarding, no exposed IP address. The tunnel is initiated from inside your network outward, so it works from a laptop on a hotel WiFi the same as it does from your corporate dev machine behind a proxy.
Prerequisites#
- A Cloudflare account (free tier is sufficient for everything in this post)
- A domain managed by Cloudflare DNS (you need this for persistent named tunnels; the quick-start temporary URL needs no account at all)
cloudflaredCLI installed
Installing cloudflared#
macOS (Homebrew)
brew install cloudflare/cloudflare/cloudflaredLinux (Debian/Ubuntu)
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.debLinux (RPM-based)
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-x86_64.rpm -o cloudflared.rpm
sudo rpm -i cloudflared.rpmWindows (winget)
winget install --id Cloudflare.cloudflaredVerify the installation:
cloudflared --versionQuick Start: Temporary Tunnel#
If you just need a public URL right now and do not care about stability or a custom domain, this requires zero setup:
cloudflared tunnel --url http://localhost:5000cloudflared connects to Cloudflare’s edge and prints a randomly assigned *.trycloudflare.com URL. Share it, test your webhook, done. The URL disappears when you kill the process. No account needed.
This is fine for a quick demo, but the URL changes every time. For anything repeatable — OAuth redirect URIs, GitHub webhook configurations, client access — you want a persistent named tunnel.
Persistent Named Tunnel with a Custom Subdomain#
Step 1: Authenticate#
cloudflared tunnel loginThis opens a browser window. Select the domain you want to use. cloudflared writes credentials to ~/.cloudflared/cert.pem.
Step 2: Create the Tunnel#
cloudflared tunnel create my-dev-tunnelThis creates a tunnel with a stable UUID and writes a credentials file to ~/.cloudflared/<UUID>.json. The tunnel ID does not change regardless of restarts or machine reboots.
List your tunnels to confirm:
cloudflared tunnel listStep 3: Configure DNS#
Map a subdomain on your Cloudflare-managed domain to the tunnel. This creates a CNAME record pointing to <UUID>.cfargotunnel.com:
cloudflared tunnel route dns my-dev-tunnel dev.yourdomain.comdev.yourdomain.com is now permanently routed to your tunnel, regardless of whether the tunnel is running. When the tunnel is down, Cloudflare returns a 503 — nothing leaks your local machine’s address.
Step 4: Create the Config File#
Create ~/.cloudflared/config.yml:
tunnel: my-dev-tunnel
credentials-file: /Users/yourname/.cloudflared/<UUID>.json
ingress:
- hostname: dev.yourdomain.com
service: http://localhost:5000
- service: http_status:404The last service: http_status:404 is a catch-all required by cloudflared — any hostname not matched by a rule returns 404.
Step 5: Run the Tunnel#
cloudflared tunnel run my-dev-tunnelYour local service on port 5000 is now accessible at https://dev.yourdomain.com, with a valid TLS certificate managed by Cloudflare.
Running as a System Service#
For a tunnel you want running automatically on login or boot:
# macOS / Linux (installs as launchd/systemd service)
sudo cloudflared service install
sudo systemctl enable cloudflared
sudo systemctl start cloudflaredOn Windows:
cloudflared service installPractical Use Cases#
Webhook Testing (GitHub, Stripe, Twilio)#
Configure the webhook endpoint in the provider’s dashboard using your stable subdomain:
https://dev.yourdomain.com/api/webhooks/githubUpdate your local app’s webhook handler. From this point, every push to GitHub triggers your local code directly — no need to manually replay events or maintain a separate staging endpoint.
For Stripe, the flow is identical. Set the webhook URL in the Stripe dashboard, run your local API, and receive real payment events against your development database. This is significantly faster than the Stripe CLI event forwarding approach when you need the full network path tested.
OAuth Redirect URIs (Azure AD, Google, GitHub OAuth)#
OAuth providers require redirect URIs to be pre-registered and publicly reachable. With a named tunnel:
- Register
https://dev.yourdomain.com/auth/callbackas an allowed redirect URI in Azure AD, Google Cloud Console, or GitHub OAuth app settings. - Point your local app’s OAuth configuration at this callback URL.
- Run the tunnel. OAuth flows now complete end-to-end against your local code.
This is particularly useful when validating token claims, testing MSAL flows, or debugging refresh token handling — scenarios where mocking the OAuth response does not give you full confidence.
Sharing a Dev Build with Stakeholders#
Send the URL directly. The stakeholder opens https://dev.yourdomain.com in their browser and sees your local build running with live data. No staging deployment, no build pipeline, no environment parity issues. When the meeting ends, kill the tunnel.
Testing Mobile Apps Against a Local API#
Mobile simulators can usually reach localhost, but physical devices on the same WiFi sometimes cannot — and devices on a cellular connection definitely cannot. With a tunnel, point your mobile app’s API base URL at https://dev.yourdomain.com and test against your local backend from any device, anywhere.
Tunneling Multiple Services#
The config file supports multiple ingress rules, so one cloudflared process can expose multiple local services under different subdomains:
tunnel: my-dev-tunnel
credentials-file: /Users/yourname/.cloudflared/<UUID>.json
ingress:
- hostname: api.yourdomain.com
service: http://localhost:5000
- hostname: app.yourdomain.com
service: http://localhost:3000
- hostname: grafana.yourdomain.com
service: http://localhost:3001
- service: http_status:404Each subdomain needs a corresponding DNS route:
cloudflared tunnel route dns my-dev-tunnel api.yourdomain.com
cloudflared tunnel route dns my-dev-tunnel app.yourdomain.com
cloudflared tunnel route dns my-dev-tunnel grafana.yourdomain.comThis is useful when testing a frontend + backend together, or when you want to share both the application and its monitoring dashboard with a colleague.
Gotchas#
WebSocket support requires explicit config. If your local service uses WebSockets, add originRequest settings in your ingress rule:
ingress:
- hostname: dev.yourdomain.com
service: http://localhost:5000
originRequest:
noTLSVerify: false
connectTimeout: 30scloudflared supports WebSockets by default, but proxies sometimes strip Upgrade headers. If WebSocket connections drop immediately, check your Cloudflare zone settings — make sure the WebSocket toggle is on under Network settings in the Cloudflare dashboard.
The domain must be on Cloudflare DNS. You cannot use a domain registered elsewhere with a different nameserver setup. The domain must be fully delegated to Cloudflare’s nameservers.
Cloudflare Access can block your tunnel. If your Cloudflare account has Access policies applied to the zone, they will apply to your tunnel traffic too. This is useful for security (you can require Google SSO before anyone reaches your dev environment), but it will break automated webhook delivery from services like GitHub or Stripe since those cannot authenticate through Access. Either exclude the webhook path from Access policies or use a separate subdomain for webhooks.
HTTPS is enforced. Cloudflare terminates TLS at the edge and communicates to cloudflared over an encrypted tunnel. Your local service can run plain HTTP on localhost — Cloudflare handles the public-facing certificate. Do not configure your local app to expect HTTPS on the internal leg.
Free tier bandwidth limits apply. Cloudflare Tunnel itself is free with no session limits or URL randomization for named tunnels. However, Cloudflare’s free zone plan has no documented bandwidth cap for tunnel traffic as of writing, but Cloudflare reserves the right to throttle abuse. For sustained high-volume traffic (e.g., load testing through the tunnel), use a local tool instead.
Credentials files are sensitive. The JSON credentials file in ~/.cloudflared/ grants full control over the tunnel. Do not commit it to source control, do not put it in a shared directory, and rotate it if you suspect it has been exposed (cloudflared tunnel credentials rotate <tunnel-name>).
Cloudflare Tunnel vs ngrok (Free Tier)#
| Feature | Cloudflare Tunnel (Free) | ngrok (Free) |
|---|---|---|
| Custom subdomain | Yes, on your own domain | No (random URL) |
| Stable URL across restarts | Yes | No |
| Session time limits | None | 2-hour session limit |
| Concurrent tunnels | Multiple (config-based) | 1 |
| WebSocket support | Yes | Yes |
| TCP tunneling | Yes (with config) | No (paid only) |
| Requires account | Only for named tunnels | Yes |
| Requires domain on provider | Yes (for named tunnels) | No |
| TLS termination | Cloudflare edge | ngrok edge |
| Traffic inspection UI | No (use Cloudflare logs) | Yes (local dashboard) |
| Price for stable URL | Free | $8/month minimum |
ngrok’s local traffic inspection dashboard (http://localhost:4040) is genuinely useful for debugging webhook payloads — that is the one feature Cloudflare Tunnel does not replicate. If you are debugging a webhook payload format and want to inspect raw requests without touching your application code, ngrok’s inspector is faster for that specific task. For everything else — stable URLs, custom domains, multiple services, production-grade reliability — Cloudflare Tunnel is the better choice, especially at zero cost.
Summary#
Cloudflare Tunnel removes the infrastructure friction from local development scenarios that genuinely require a public URL. The quick-start mode (cloudflared tunnel --url) covers ad-hoc needs with no account required. The named tunnel approach gives you a permanent, stable subdomain under your own domain, running as a system service, with no port exposure and no ongoing cost.
For teams that regularly test OAuth flows, receive webhook events, or share in-progress work with external stakeholders, setting up a named tunnel once and leaving it running is worth the ten minutes of initial configuration.
