Nitin Kumar SinghSolutions Architect

Type to search. to move, Enter to open.

    move open esc close
    Deep DiveDevOps

    Cloudflare Tunnel: Expose localhost for Webhooks and OAuth

    Use Cloudflare Tunnel to expose localhost with a stable custom subdomain. Free ngrok alternative — no session limits, works behind corporate firewalls.

    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.

    1Browser or external clientHTTPS request2Cloudflare edgeterminates TLS3cloudflared daemonon your machine, over QUIC orHTTP/24Local servicelocalhost:5000
    Four hops, and only the last one is on your machine. No inbound port is opened anywhere: the daemon dials out to the edge and the traffic comes back down that connection.

    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)
    • cloudflared CLI installed

    Installing cloudflared

    macOS (Homebrew)

    Terminal window
    brew install cloudflare/cloudflare/cloudflared

    Linux (Debian/Ubuntu)

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

    Linux (RPM-based)

    Terminal window
    curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-x86_64.rpm -o cloudflared.rpm
    sudo rpm -i cloudflared.rpm

    Windows (winget)

    Terminal window
    winget install --id Cloudflare.cloudflared

    Verify the installation:

    Terminal window
    cloudflared --version

    Quick 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:

    Terminal window
    cloudflared tunnel --url http://localhost:5000

    cloudflared 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

    Terminal window
    cloudflared tunnel login

    This opens a browser window. Select the domain you want to use. cloudflared writes credentials to ~/.cloudflared/cert.pem.

    Step 2: Create the Tunnel

    Terminal window
    cloudflared tunnel create my-dev-tunnel

    This 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:

    Terminal window
    cloudflared tunnel list

    Step 3: Configure DNS

    Map a subdomain on your Cloudflare-managed domain to the tunnel. This creates a CNAME record pointing to <UUID>.cfargotunnel.com:

    Terminal window
    cloudflared tunnel route dns my-dev-tunnel dev.yourdomain.com

    dev.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:404

    The 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

    Terminal window
    cloudflared tunnel run my-dev-tunnel

    Your 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:

    Terminal window
    # macOS / Linux (installs as launchd/systemd service)
    sudo cloudflared service install
    sudo systemctl enable cloudflared
    sudo systemctl start cloudflared

    On Windows:

    Terminal window
    cloudflared service install

    Practical 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/github

    Update your local app’s webhook handler. From this point, every push to GitHub triggers your local code directly, with 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 exercises the real network path end to end, including TLS and any edge behaviour, which the Stripe CLI’s event forwarding skips.

    OAuth Redirect URIs (Azure AD, Google, GitHub OAuth)

    OAuth providers require redirect URIs to be pre-registered and publicly reachable. With a named tunnel:

    1. Register https://dev.yourdomain.com/auth/callback as an allowed redirect URI in Azure AD, Google Cloud Console, or GitHub OAuth app settings.
    2. Point your local app’s OAuth configuration at this callback URL.
    3. 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:404

    Each subdomain needs a corresponding DNS route:

    Terminal window
    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.com

    This is the setup I keep running day to day: an api. and an app. subdomain on one tunnel, so the frontend and backend are both reachable, and a grafana. route when I want to share a 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: 30s

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

    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)

    FeatureCloudflare Tunnel (Free)ngrok (Free)
    Custom subdomainYes, on your own domainNo (random URL)
    Stable URL across restartsYesNo
    Session time limitsNone2-hour session limit
    Concurrent tunnelsMultiple (config-based)1
    WebSocket supportYesYes
    TCP tunnelingYes (with config)No (paid only)
    Requires accountOnly for named tunnelsYes
    Requires domain on providerYes (for named tunnels)No
    TLS terminationCloudflare edgengrok edge
    Traffic inspection UINo (use Cloudflare logs)Yes (local dashboard)
    Price for stable URLFree$8/month minimum

    ngrok’s local traffic inspection dashboard (http://localhost:4040) is genuinely useful for debugging webhook payloads, and it 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.

    Where I’d start

    Set up one named tunnel today and leave it running as a system service: cloudflared tunnel create, route a dev. subdomain to it, then drop the ingress rules into ~/.cloudflared/config.yml. That’s the ten minutes that pays off every time you test an OAuth redirect or wire up a webhook. For anything you’ll do once and throw away, cloudflared tunnel --url http://localhost:5000 is always there with no setup.

    Comments

    Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.