Callbacks from your tailnet, delivered to localhost
Deliver HTTP callbacks from your tailnet to apps running locally, without letting production connect to developer machines.
$ tsrelay http 8080
tsrelay forwarding to http://localhost:8080
https://tsrelay.example.ts.net/~moritz/amber-otter-lamp
hook h_3k2 ephemeral channel default
connected
14:02:11 POST /payments/done → 200 12ms tag:prod-worker
14:02:19 POST /payments/refunded → 200 9ms tag:prod-worker
14:03:02 POST /invoices/sent → 404 4ms tag:billing
14:03:40 POST /payments/done → 200 15ms tag:prod-worker
Features
-
Access from your tailnet policy
Grants decide who may send and who may receive. Tailnet callers need no tokens, and production never gets a route to a laptop.
-
Hooks you create, nothing else
The relay forwards only to hooks their owners made. It never intercepts a service's traffic.
-
Inspect recent calls
Watch calls arrive live, read their headers, or copy one as curl. Hooks that capture bodies show them too, and can replay calls.
-
Held while you reconnect
A call waits for your tunnel to come back instead of failing, within limits you set per hook.
-
Public only when you say so
Stripe or GitHub can reach a hook through Tailscale Funnel, once both the operator and the hook's owner opt in.
-
Audited and traced
An audit log, Prometheus metrics and OpenTelemetry traces that span sender, relay and your app.
How a call travels
Production never connects to a developer machine. Both sides connect to the relay, and the relay only forwards to hooks that someone created.
-
Your production service
tag:prod-workerCalls the hook's URL like any other webhook. Its grant says which channels it may send to.
-
The relay
tag:tsrelayIts own node on your tailnet. It checks the sender's grant, then hands the call down the tunnel.
-
Your laptop
tsrelay http 8080Dials out to the relay, so nothing ever connects to it. Your app answers on localhost.
Why not a reverse grant, or a public tunnel?
Calls from a laptop to a tailnet service take one grant. Calls the other way have had a few answers, each with a catch for callbacks from production.
A reverse grant
- Production gets a standing route to every developer device it's granted.
- Your policy has to name developer machines, and keep up as they change.
- Calls belong to no hook and no owner, so there is nothing to inspect or replay.
A public tunnel such as ngrok or Cloudflare Tunnel
- Internal callbacks leave your tailnet and cross the internet.
- You can restrict who may call, but in the provider's account, not in your tailnet policy.
- Each developer runs and secures their own.
Funnel on the laptop Tailscale Funnel, run on each laptop
- A local port becomes reachable from the whole internet.
- Production calls it like any public URL, so the call carries no tailnet identity.
- Your policy has to allow Funnel on developer devices.
A webhook forwarder such as the Stripe CLI, the GitHub CLI or smee.io
- Delivers one provider's events to localhost, through that provider or a hosted service.
- Works well for that provider, but not for callbacks from your own services.
tsrelay
- Laptops dial out, and production reaches only the hooks their owners made.
- Traffic stays on your tailnet; a hook is public only when its owner and the operator opt in.
- Your grants decide who sends and receives, and calls are written to an audit log.
When you don't need tsrelay
- You only receive webhooks from one provider that has a forwarder, such as Stripe or GitHub: use its forwarder.
- You work alone and a public URL on your laptop is acceptable: Tailscale Funnel on your laptop needs no relay.
- You need calls queued and retried until your app takes them: tsrelay holds a call only briefly while your tunnel reconnects and never retries one. Use a queue.
The security model says what the relay guarantees, and what it leaves to you.
Where to start
Your tailnet already runs a relay
Install the CLI from it, and forward your first callback.
$ tsrelay http 8080