Cloudflare Tunnels Changed How I Test Local Integrations
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.