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).
tailscale serveis tailnet-private;tailscale funnelis 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
ts.nethostname 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 (
your-machine.your-tailnet.ts.net) 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:
tailscale serve— 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.tailscale funnel— 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 serve (tailnet-only) when you meant funnel (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 tailscale funnel 443 on — that's deprecated. The current syntax merges scheme and target into a single command:
# old syntax (pre-1.52, no longer valid)
tailscale funnel 443 on
# current syntax
tailscale funnel 8080
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 — dev.example.com — 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 ts.net 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 ts.net 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 localhost:8080:
# Expose the port to the public internet, running in the background
sudo tailscale funnel --bg 8080
The --bg 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:
tailscale funnel status
You want to see (Funnel on), not (tailnet only):
https://your-machine.your-tailnet.ts.net (Funnel on)
|-- / proxy http://127.0.0.1:8080
If you see listener already exists for port X when switching from serve to funnel, clear the old state first:
sudo tailscale serve reset
sudo tailscale funnel --bg 8080
The redirect loop (this one took a while)
Once Funnel was live, I hit ERR_TOO_MANY_REDIRECTS 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 Caddyfile like this:
http://scaffold.localhost, http://*.scaffold.localhost {
root * /app/public
php_fastcgi php:9000
file_server
try_files {path} {path}/ /index.php{query}
}
This works fine for normal local dev (scaffold.localhost 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 ts.net hostname, with two extra pieces:
your-machine.your-tailnet.ts.net {
root * /app/public
php_fastcgi php:9000 {
env HTTP_HOST scaffold.localhost
env HTTPS on
}
file_server
try_files {path} {path}/ /index.php{query}
}
Two things doing the work here:
env HTTP_HOST scaffold.localhost— Tailscale presents its ownts.nethostname as theHostheader, 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.env HTTPS on— tells PHP-FPM (and therefore Symfony'sRequest::isSecure()) 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:
http://your-machine.your-tailnet.ts.net {
root * /app/public
php_fastcgi php:9000 {
env HTTP_HOST scaffold.localhost
env HTTPS on
}
file_server
try_files {path} {path}/ /index.php{query}
}
Adding http:// in front of the hostname tells Caddy's auto_https 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 config/packages/framework.yaml doesn't have trusted_proxies set, none of the X-Forwarded-* 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 HTTPS env var trick above.
when@dev:
framework:
trusted_proxies: '127.0.0.1,::1'
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
One caveat if you're running this in Docker with Caddy and PHP-FPM as separate containers: 127.0.0.1 might not be the actual address Symfony sees as "the proxy," depending on your Docker networking setup. Worth checking what REMOTE_ADDR 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 http:// with HTTP_HOST/HTTPS overrides, and Symfony's trusted proxies configured — the whole thing worked cleanly. A webhook sender hitting https://your-machine.your-tailnet.ts.net/your-webhook-path 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
ts.nethostname only — you can't CNAME a domain you own (likedev.example.com) 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
serve/funnelcommands have changed more than once. If you're following any tutorial (including this one, eventually), checktailscale funnel --helpagainst what's documented, since the syntax has shifted across versions. - It's still ultimately a debugging exercise the first time. None of the individual fixes here are exotic — forwarded headers, scheme mismatches, this is all standard reverse-proxy stuff — but the combination of three different layers (Tailscale, Caddy, Symfony) each silently assuming something different about the request meant the actual symptom (a redirect loop, then a connection refusal) told me almost nothing about which layer was wrong. Worth going one layer at a time with
curl -vdirectly against your local port before trying to debug through the full public URL.
If you're already deep in ngrok and it's working, there's no urgent reason to switch. But if you want a stable hostname without a paid tier, and you don't mind a slightly fiddlier one-time setup, Tailscale Funnel is a solid alternative — once you know which three things are going to bite you.
Wiring it into a Makefile
Since I'm already running most of this project through make, it made sense to wrap the Tailscale commands into a couple of targets rather than typing them out each time:
.PHONY: tailscale-up
tailscale-up: ## Terminate any existing tailscale tunnels and initialise a new one
tailscale funnel reset
tailscale funnel --bg 8080
tailscale status
.PHONY: tailscale-down
tailscale-down: ## Terminate any existing tailscale tunnels
tailscale funnel reset
tailscale status
tailscale-up resets any stale serve/funnel state first (avoids the "listener already exists" error mentioned earlier), starts Funnel fresh in the background, then prints status so you can confirm it's live and grab the URL. tailscale-down just tears it back down when you're done — no point leaving a public tunnel open longer than you need it.
This started in noticeboards.info's Makefile, and I'll be backporting it into Scaffold's Makefile too, since this is exactly the kind of small, boring infrastructure helper that belongs in a reusable project skeleton rather than rewritten per-project.
Originally published at https://chrisshennan.com/blog/tailscale-as-an-ngrok-local-tunnel-cloudflare-tunnel-alternative