If you’ve ever worked with webhooks during local development, you know the pain. That special kind of frustration when you’re deep in debugging a Stripe payment flow or an AWS SNS notification, and you have to restart ngrok, or Expose. Again. And now the URL has changed. Again.
Time to update the webhook URL in Stripe’s dashboard. Then in your AWS console. Then in that third-party service you integrated last week. By the time you’re done updating URLs across five different platforms, you’ve forgotten what you were actually debugging.
This was my life for years.
The Webhook Development Nightmare
I work with Laravel and WordPress projects daily, running everything locally on macOS with Laravel Valet. It’s a beautiful setup — fast, simple, .test domains that just work. But the moment I need to test webhooks, that simplicity evaporates.
ngrok has been the go-to solution forever. Fire it up, get a tunnel URL, and you’re golden. Except you’re not. Because:
- Every time you restart your machine, new URL
- Every time the tunnel times out, new URL
- Free tier gives you random subdomains
- Each URL change means logging into multiple services to update webhook endpoints
Enter Cloudflare Tunnel
Here’s what changed everything: I was already using Cloudflare for DNS on my domains. Turns out, Cloudflare Tunnel isn’t just for production deployments, it’s perfect for local development. And unlike ngrok, your tunnel URLs never change.
Let me say that again: your URLs never change.
Same domain. Same endpoint. Whether you restart your Mac, close your terminal, or take a three-day weekend. Your webhook URLs stay exactly the same. You configure them once in Stripe, AWS, or wherever, and you’re done.
How It Actually Works
The magic is in how Cloudflare Tunnel routes traffic. You create a subdomain (or several) pointing to your tunnel, and then configure exactly which local Valet site should handle requests for each domain.
Here’s my current setup:
tunnel: local-dev
credentials-file: /Users/yourname/.cloudflared/[tunnel-id].json
ingress:
- hostname: payments-dev.yourdomain.com
service: http://127.0.0.1:80
originRequest:
httpHostHeader: shop.test
- hostname: notifications-dev.yourdomain.com
service: http://127.0.0.1:80
originRequest:
httpHostHeader: api.test
- hostname: staging-dev.yourdomain.com
service: http://127.0.0.1:80
originRequest:
httpHostHeader: staging.test
- service: http_status:404
Code language: YAML (yaml)
Breaking this down:
- Each
hostnameis a subdomain on my domain managed by Cloudflare - All traffic goes to
127.0.0.1:80(where Valet is listening) - The
httpHostHeadertells Valet which local site should handle the request - The last rule catches anything else and returns a 404
So when Stripe sends a webhook to payments-dev.yourdomain.com/webhook/stripe, Cloudflare routes it through the tunnel to my Mac, and Valet sees it as a request to shop.test/webhook/stripe. Seamless.
The Real-World Workflow
Let me paint you a picture of how this actually works day-to-day.
I’m building a SaaS app that processes payments with Stripe and sends notifications via AWS SNS. In the old ngrok days, I’d:
- Start ngrok:
ngrok http 80 - Copy the random URL:
https://a3f9-123-45-67.ngrok.io - Log into Stripe, update webhook URL
- Log into AWS, update SNS subscription
- Start testing
- Restart my Mac the next morning
- Repeat steps 1-5
With Cloudflare Tunnel:
- Start the tunnel once:
cloudflared tunnel run local-dev - Configure webhooks with permanent URLs:
- Stripe:
https://payments-dev.yourdomain.com/webhook/stripe - AWS:
https://notifications-dev.yourdomain.com/webhook/sns
- Stripe:
- Never touch those URLs again
The tunnel reconnects automatically. The URLs never change. I can restart, sleep my Mac, whatever. When I come back and start the tunnel, everything just works.
Setting It Up on macOS
The initial setup takes maybe 10 minutes. Here’s what you do:
1. Install cloudflared
brew install cloudflaredCode language: Bash (bash)
2. Authenticate with Cloudflare
cloudflared tunnel loginCode language: Bash (bash)
This opens your browser and connects cloudflared to your Cloudflare account.
3. Create a tunnel
cloudflared tunnel create local-devCode language: Bash (bash)
This generates a credentials file and gives you a tunnel ID, it’ll be something like aaafc451-1eea-47a4-81e2-3c02b3907c37. Cloudflared will save credentials in ~/.cloudflared/.
4. Create your config file
Create ~/.cloudflared/config.yml with your routing rules. Start simple:
tunnel: local-dev
credentials-file: /Users/yourname/.cloudflared/[your-tunnel-id].json
ingress:
- hostname: dev.yourdomain.com
service: http://127.0.0.1:80
originRequest:
httpHostHeader: myproject.test
- service: http_status:404Code language: YAML (yaml)
Replace yourdomain.com with your actual Cloudflare-managed domain, and myproject.test with your Valet site.
5. Route DNS to your tunnel
cloudflared tunnel route dns local-dev dev.yourdomain.comCode language: Bash (bash)
This creates a CNAME record in Cloudflare pointing your subdomain to the tunnel.
6. Run the tunnel
cloudflared tunnel run local-dev
# Or simply
cloudflared tunnel run # it'll run the default config.yamlCode language: Bash (bash)
That’s it. Visit https://dev.yourdomain.com and you’ll hit your local Valet site.
Pro Tips:
Multiple projects? Just add more hostname entries in your config. I have separate subdomains for each major project I’m working on. No more mental overhead remembering which port goes with which project.
Run it as a service: On macOS, you can set up cloudflared as a launch agent so it starts automatically:
cloudflared service install
Though honestly, I prefer running it manually in a terminal tab so I can see the logs when debugging webhooks.
SSL is automatic: Everything is HTTPS by default. No certificates to manage, no warnings about insecure connections. Cloudflare handles it all.
The Bottom Line
If you’re already using Cloudflare for DNS (and you should be), there’s zero reason to keep fighting with ephemeral tunnel URLs. Set it up once, configure your webhooks with permanent URLs, and never think about it again.
Your future self will thank you. Probably around 2am when you’re debugging a webhook issue and realize you don’t have to update URLs anywhere.
That’s the kind of peace of mind that’s worth writing about.