If you've ever needed to let a third-party service talk to your local dev environment, you've probably reached for ngrok.
I've been moving some of that over to Tailscale Funnel instead — here's what works, what broke, and how it stacks up against Cloudflare Tunnel.
Key takeaways Tailscale Funnel exposes a local dev server to the public internet, similar to ngrok or Cloudflare Tunnel — useful for testing webhooks, payment callbacks, or anything a third party needs to reach on your machine.
It's free for solo/small use, with no device limit on the Personal plan (up to 6 users). is tailnet-private; is what actually makes something public — easy to mix up.
Getting it working behind Caddy + Symfony in Docker means fixing two separate redirect loops (one from Symfony, one from Caddy's own auto-HTTPS) and explicitly telling Symfony to trust the proxy.
The one real trade-off versus Cloudflare Tunnel: no custom domain with a valid cert — you're stuck with the hostname unless you accept a browser cert warning.
This isn't a "ngrok / local tunnel / Cloudflare tunnel is bad" post.
It's a "here's another tool that does the same job, and here's exactly what broke when I set it up" post.
Why bother?
A few reasons I looked at Tailscale instead of just defaulting to ngrok again: Stable hostname. ngrok's free tier gives you a random URL that changes every time you restart it.
Tailscale Funnel gives you a fixed hostname tied to your machine () that doesn't change.
Genuinely free for solo/small use.
Tailscale's free Personal plan covers up to 6 users with no device limit at all — plenty for a solo dev juggling a laptop, a desktop, and a couple of test machines.
No "3 device" ceiling to bump into.
Already in my toolchain.
I use Tailscale for other things, so there's no new account, no new pricing tier to think about.
Automatic HTTPS.
Tailscale handles certificate provisioning for you — no separate cert step.
The trade-off: it took more fiddling to get working with a Dockerised Symfony app than I expected.
Here's the full path, mistakes included, because I suspect the mistakes are the more useful part.
Tailscale Serve vs Tailscale Funnel — know the difference This is the bit that cost me the most time.
Tailscale has two related but distinct commands: — exposes a local service to other devices on your own tailnet only.
Private, useful for sharing a dev server with your own laptop/phone, not useful for a third-party webhook sender. — exposes a local service to the public internet.
This is the actual ngrok-equivalent.
If a webhook provider can't reach your tunnel, the first thing to check is whether you've configured (tailnet-only) when you meant (public).
Also worth knowing: the CLI syntax changed in Tailscale v1.52.
If you're following an older blog post or Stack Overflow answer, you might see syntax like — that's deprecated.
The current syntax merges scheme and target into a single command: How does this compare to Cloudflare Tunnel?
If you've used Cloudflare Tunnel before, the mental model is similar: both create an outbound-only connection from your machine that exposes a local service to the public internet, without opening any inbound ports on your router.
Both handle TLS termination for you.
Both give you a stable hostname rather than ngrok's free-tier random URL.
The real difference is custom domains.
Cloudflare Tunnel can map a domain you actually own — — and issue it a fully valid, browser-trusted certificate, because Cloudflare is managing certs for your domain already.
Tailscale Funnel only issues certs for its own namespace.
You can't CNAME your own domain at a Tailscale Funnel endpoint and get a matching cert; the best you'll get is a hostname mismatch warning in the browser.
If a branded custom domain matters for what you're building, that's a genuine point in Cloudflare's favour, not just a minor footnote.
For my use case — testing webhooks against a local dev environment, not something I'm sharing externally — the hostname is a non-issue.
But it's worth knowing which one you're trading away before you pick a tool.
Setting it up Assuming you've got a local dev server (in my case, a Dockerised Symfony app behind Caddy) running on : The flag matters — without it, the command runs in the foreground and ties up your terminal until you Ctrl+C it, which also tears down the tunnel.
Check it's actually public (not just tailnet-only) with: You want to see , not : If you see when switching from to , clear the old state first: The redirect loop (this one took a while) Once Funnel was live, I hit in the browser.
The cause, once I traced it, made sense in hindsight: Tailscale terminates HTTPS for you, then forwards the request to your local server as plain HTTP.
If your app doesn't know the original request was HTTPS, it sees an insecure request and redirects to HTTPS — which Tailscale presents over HTTP again — and you loop forever.
For a Symfony app behind Caddy in Docker, here's the project setup I was working from.
A like this: This works fine for normal local dev ( in the browser, no tunnel involved).
To add a Tailscale-facing site block, you can't just reuse the same block — you need one specifically for the hostname, with two extra pieces: Two things doing the work here: — Tailscale presents its own hostname as the header, not the one your app's routing expects.
This overrides what gets passed to PHP-FPM, so Symfony sees the request as if it came in on your normal local dev host, regardless of what Tailscale sent. — tells PHP-FPM (and therefore Symfony's ) that the original request was HTTPS, even though the Caddy→PHP-FPM hop itself is plain HTTP internally.
This is what actually breaks the redirect loop.
Caddy's own auto-HTTPS redirect (the second loop) Fixing the Symfony-side redirect surfaced a second, different redirect: Caddy itself was redirecting HTTP→HTTPS, because by default it assumes any site block with a bare hostname (no explicit scheme) should be served over HTTPS.
The fix is the same pattern already used in the first site block — be explicit that this is HTTP-only: Adding in front of the hostname tells Caddy's logic not to touch this site.
Makes sense once you think about it: Tailscale is the one doing TLS termination here, so Caddy never needs to speak HTTPS at all in this chain.
Symfony needs to trust the proxy The last piece: Symfony, by default, doesn't trust forwarded headers from anywhere.
If your doesn't have set, none of the headers Caddy sends get respected — which matters for things like Symfony correctly logging client IPs, and for any logic that distinguishes secure/insecure requests beyond just the env var trick above.
One caveat if you're running this in Docker with Caddy and PHP-FPM as separate containers: might not be the actual address Symfony sees as "the proxy," depending on your Docker networking setup.
Worth checking what actually looks like inside the PHP container before assuming loopback is correct.
The end result Once all three pieces were in place — Tailscale Funnel running, Caddy's scheme explicitly set to with / overrides, and Symfony's trusted proxies configured — the whole thing worked cleanly.
A webhook sender hitting gets routed all the way through to my local Symfony app exactly as if it had hit my normal local dev domain.
Worth knowing before you commit to this A few things I'd flag if you're considering doing the same: No custom domain with a valid cert.
Tailscale Funnel issues a cert for your hostname only — you can't CNAME a domain you own (like ) to it and get a matching, browser-trusted cert.
If that matters to you, Cloudflare Tunnel handles custom domains properly; Tailscale doesn't, at least not without accepting a cert mismatch warning.
CLI syntax churn.
Tailscale's / commands have changed more than once.
If you're following any tutorial (including this one, eventually), check against what's documented, since the syntax has shifted across versions.
It's still ultimately a debugging exercise the first time.