I used to treat local integration testing as a special mode. Run the app on localhost, add a forwarding URL when a provider needed to call back, update a dashboard, test the thing, then undo half of it later.

Cloudflare Tunnel changed that for me. My local apps now have stable HTTPS dev domains, so OAuth callbacks, webhooks, mobile callbacks, and browser testing go through the same public boundary every time.

The mental model

Before tunnels, local testing looked like this:

browser -> http://localhost:5173
backend -> http://localhost:4000
provider dashboard -> random forwarding URL, if I remembered to update it
phone -> maybe my LAN IP, maybe nothing

That works for testing screens. It is awkward for integrations because the other service is not on my laptop.

Now I try to make local development look like a small public deployment:

browser
  -> https://dev-example.com
  -> Cloudflare Tunnel
  -> http://localhost:5173

Stripe or GitHub
  -> https://dev-example-backend.com/webhooks/provider
  -> Cloudflare Tunnel
  -> http://localhost:4000/webhooks/provider

phone
  -> https://dev-example.com
  -> Cloudflare Tunnel
  -> http://localhost:5173

It is still local. The processes are still on my machine. But the URLs look like real URLs, with HTTPS, stable hostnames, and provider dashboards that do not need to change every time I restart a terminal.

Stable hostnames remove a lot of dashboard work

Cloudflare has Quick Tunnels for development. You can run:

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

It prints a random trycloudflare.com hostname and proxies requests back to your local server. Cloudflare describes quick tunnels as useful for testing and development. I would not put a production site behind one.

I like quick tunnels for one-off sharing. I do not like them for normal product development.

Provider dashboards, OAuth clients, and mobile apps all remember URLs. If the hostname changes, the test setup changes.

For my own projects I use a named tunnel with hostnames I control. The Cloudflare setup is a little more work once, then the same URLs keep working:

cloudflared tunnel create dev
cloudflared tunnel route dns dev dev-example.com
cloudflared tunnel route dns dev dev-example-backend.com

Cloudflare documents that a tunnel gets a UUID-backed cfargotunnel.com target, and DNS records point your hostname at that tunnel target. The CLI route command creates the CNAME for locally-managed tunnels.

After that, my provider dashboard can keep using:

https://dev-example-backend.com/webhooks/stripe
https://dev-example-backend.com/oauth/google/callback

No random URL pasted into five places. No “which tunnel URL is current?” problem.

The config becomes the test map

My ~/.cloudflared/config.yml is the map:

tunnel: <tunnel-id>
credentials-file: /Users/me/.cloudflared/<tunnel-id>.json

ingress:
  - hostname: dev-example.com
    service: http://localhost:5173
  - hostname: dev-example-backend.com
    service: http://localhost:4000
  - service: http_status:404

Cloudflare’s configuration docs say ingress rules are matched from top to bottom, and configs with ingress rules need a final catch-all rule. I use http_status:404 because unmatched hostnames should fail clearly.

This file answers the question I used to keep in my head:

public hostname -> local port

When an integration breaks, I start with that mapping. I do not start in the provider dashboard.

I prove the route before changing provider settings

When a callback or webhook fails, I check the path from outside in.

First, DNS:

dig +short dev-example-backend.com

Then HTTPS reachability:

curl -i https://dev-example-backend.com/health

Then the actual route:

curl -i \
  -X POST \
  -H 'Content-Type: application/json' \
  --data '{"ping":true}' \
  https://dev-example-backend.com/webhooks/provider

For webhook endpoints, I do not care if the fake request returns 400, 401, or signature verification failure. I care that the request reaches the local process and the log shows the route I expected.

Then I ask Cloudflare which ingress rule it would use:

cloudflared tunnel ingress rule https://dev-example-backend.com/webhooks/provider

And I validate the config:

cloudflared tunnel ingress validate

Those two commands are more useful than staring at dashboard settings. One tells me which rule matches the URL. The other catches config mistakes before I blame Stripe, GitHub, Google, or my app.

The app sees a real host

This also changed how I debug app config.

Many local bugs only show up when the app uses a public origin:

  • OAuth redirect URLs must match the registered value. Google’s OAuth docs say the redirect URI must exactly match an authorized redirect URI, or you get redirect_uri_mismatch.
  • Webhook providers send requests to an HTTPS endpoint you register. Stripe’s webhook docs describe registering an HTTPS webhook endpoint that receives event data.
  • Cookies, CORS, callback URLs, generated absolute URLs, and backend allowlists often depend on the request host.

If I test everything on raw localhost, I can accidentally test a different app than the one I configured.

With tunneled dev domains, I can set local env vars to the same kind of values I use elsewhere:

APP_URL=https://dev-example.com
API_URL=https://dev-example-backend.com
GOOGLE_REDIRECT_URI=https://dev-example-backend.com/oauth/google/callback
STRIPE_WEBHOOK_URL=https://dev-example-backend.com/webhooks/stripe

The values are still development values. But they have the same scheme, hostname behavior, and callback path behavior as the deployed app.

That catches more useful bugs.

Once I pick a domain, I use it

I still use localhost as the tunnel target. I avoid using it as the app URL once I settle on a domain name.

The local process still runs on a port:

http://localhost:5173

But I open the app through the tunnel subdomain:

https://dev-example.com

I usually put the project domain name into the tunnel subdomain. If the product is example.com, the local URLs become something like:

https://dev-example.com
https://dev-example-backend.com

That keeps the browser, backend config, OAuth callback URLs, webhook endpoints, cookies, and mobile testing on the same dev origin. I do not have one path through localhost and another path through the domain.

My rule is:

  • before the project has a domain: local ports are fine
  • after the tunnel subdomain exists: use the domain as the app URL
  • provider calls my machine: use the tunnel domain
  • phone or tablet testing: use the tunnel domain
  • one-off share link: quick tunnel is fine

The tunnel config still points to localhost. I just stop treating localhost as the URL I develop against.

What changed in practice

Before, an integration failure felt spread across too many places: local server, forwarding tool, provider dashboard, callback config, browser console, phone, and logs.

Now I start with the public route:

  • Does the hostname resolve?
  • Does HTTPS work?
  • Does Cloudflare route the hostname to the local port I expect?
  • Does the app log the request?
  • Does the provider dashboard use the stable dev URL?
  • Is the app configured with the same dev origin?

Only after that do I debug provider-specific behavior: signatures, event types, OAuth scopes, redirect URI registration, retries, and handler code.

I wrote the setup steps in Configuring Cloudflare Tunnel to Expose Servers for Local Development, Webhooks etc. Later I wrote about treating ~/.cloudflared/config.yml as my local dev directory.

This is the reason behind both posts: stable HTTPS dev domains make local integrations feel less like a pile of exceptions.