Recipe 03 · intermediate · 8 min

Receive real webhooks straight onto your dev box

A stable public HTTPS endpoint on hardware you own, with a publicly trusted certificate, from one command.

Position in the recipes track 01 Make ev. 02 Stop ty. 03 Receive. 04 Replace. 05 Put you. 06 Give a . 07 Put a t.

What you get

Webhook development carries a standing tax. The sender needs a public HTTPS endpoint. Your handler runs on 127.0.0.1. So you sign up for a tunnel service, accept whatever URL it hands you, paste that URL into the sender’s configuration, and repaste it every time the tunnel restarts and the name changes. You have also quietly added a third party to the path of your production integration secrets.

Tailscale Funnel deletes the middle party. Funnel “lets you route traffic from the broader internet to a local service running on a device in your Tailscale network,” using DNS names in your own tailnet domain, tailnet-name.ts.net (kb-funnel). Your machine already has a name there and, once you enable HTTPS, a publicly trusted certificate issued through Let’s Encrypt (kb-https). One command turns that name into a public endpoint that a webhook sender can post to. The name is stable because it is your node’s name, not a lease from a service. When you are done, one more command turns it off.

How it works

The distinction that matters is Serve versus Funnel. Both are the same last hop: tailscale serve shares “a local service securely within your Tailscale network,” and the docs are explicit that if you want tailnet only sharing you should use Serve instead of Funnel (kb-serve, docs-funnel). Funnel adds a public front door in front of that same proxy. Serve is reachable by your tailnet peers. Funnel is reachable by anyone on the internet who knows the name.

The public front door is more interesting than it sounds. DNS for your MagicDNS name points at Tailscale Funnel frontends that are “georeplicated around the world, similar to how we run DERP servers around the world” (blog-funnel). Those frontends “look at the SNI name in the TLS ClientHello, and then proxy those encrypted TCP connections to your Tailscale node over Tailscale itself.” Tailscale states plainly that “Tailscale Funnel is not doing any TLS termination.” The certificate and its private key live on your node, generated and stored locally, and Tailscale “never sees them” (kb-https).

Where a webhook actually travels when you run tailscale funnel webhook sender somewhere on the public internet HTTPS to node-a.tailnet-name.ts.net:443 Tailscale Funnel frontend reads the SNI name in the TLS ClientHello proxies the encrypted TCP stream, no TLS termination same TCP stream, now carried over the tailnet your tailnet node-a runs tailscaled holds the cert and key, terminates TLS here 127.0.0.1:3000 your development server

Funnel is off by default and, as the announcement puts it, “all off by default and double opt-in” (blog-funnel). Opt-in one is a tailnet policy attribute. Opt-in two is the command you run on the node. Neither alone is enough, which is why you cannot accidentally publish a machine by typing a wrong flag.

Build it

  1. Enable HTTPS certificates for the tailnet. In the admin console DNS page, enable MagicDNS if it is not already on, then select “Enable HTTPS” under HTTPS Certificates and acknowledge the warning that machine names will be published publicly (kb-https). Nothing about Funnel works without this: the requirements list is “Tailscale v1.38.3 or later,” MagicDNS enabled, “HTTPS enabled and valid HTTPS certificates for your tailnet,” and the Funnel node attribute (kb-funnel).

  2. Grant the Funnel attribute in the tailnet policy file. The documented snippet is exactly this (kb-funnel):

    "nodeAttrs": [
      {
        "target": ["autogroup:member"],
        "attr":   ["funnel"],
      },
    ],

    Scope target down if you can. nodeAttrs targets can select devices “via tag, user, group, or *” (kb-acl-syntax), so ["tag:funnel-dev"] is a tighter grant than every member of the tailnet, and it makes the audit question “which machines may publish to the internet” answerable by reading one line.

  3. Start your handler bound to loopback. Nothing special. Funnel proxies to a local target, and the target “can be a file, directory, text, or most commonly the location to a service running on the local machine” (cli-funnel).

  4. Turn on Funnel for that port. On Linux you will typically need sudo (kb-funnel-usecases):

    tailscale funnel 3000

    The documented output has this shape, with your own node and tailnet name in place of the example one (kb-funnel):

    Available on the internet:
    https://node-a.tailnet-name.ts.net
    
    |-- / proxy http://127.0.0.1:3000
    
    Press Ctrl+C to exit.

    That URL is what you paste into the sender’s configuration, and it does not change when you restart.

  5. Scope it to one path instead of the whole origin. Publishing / publishes every route your development server has, including the debug endpoints you forgot about. The --set-path flag “appends the specified path to the base URL for accessing the underlying service” (cli-funnel), and --bg “determines whether the command should run as a background process”:

    tailscale funnel --bg --set-path=/hooks/inbound 3000

    Background mode also survives reboots and tailscale down followed by tailscale up, whereas a foreground invocation needs restarting by hand (kb-serve).

  6. Confirm what is published.

    tailscale funnel status

    status “gets the status” of what is currently being served, and status --json gives the machine readable form (cli-funnel). Read it as an inventory, not a formality.

  7. Turn it off when you are done. Two documented ways. Append off to the original command, keeping the flags: tailscale funnel --https=443 --set-path=/hooks/inbound 3000 off. The docs describe this as adding [off] to the end of the original command, where the target becomes optional but the flags remain required (cli-funnel). Or clear everything with tailscale funnel reset.

Verify it

Verification means proving the path works from outside your tailnet, because inside it Serve would also succeed and you would learn nothing.

From a machine that is not on your tailnet, and ideally on a different network entirely, request the exact path you published:

curl -i https://node-a.tailnet-name.ts.net/hooks/inbound

A success is your own application’s response, with a certificate your client accepted without a flag. If you want to see the issuer rather than trust the absence of an error, inspect the chain directly:

openssl s_client -connect node-a.tailnet-name.ts.net:443 -servername node-a.tailnet-name.ts.net </dev/null

Then fire a real delivery from the sender, retry it from their dashboard, and watch it land in your handler’s log. That is the only test that matters.

Failures sort into four buckets, and each has a different fix. If the name does not resolve at all, HTTPS certificates or MagicDNS are not enabled for the tailnet. If the CLI refuses to enable Funnel, the funnel node attribute is missing or does not target this device. If TLS fails, the certificate has not been provisioned yet on this node. If TLS succeeds but you get a 404, the path you published and the path the sender is calling do not match, which is what tailscale funnel status exists to settle.

Gotchas

  1. Three ports, no exceptions. “Funnel can only listen on ports 443, 8443, and 10000” (kb-funnel). If your plan involved a fourth port, change the plan.

  2. Serve and Funnel cannot share a port. “The same port number cannot be used for Serve and Funnel at the same time” (kb-funnel). If a Serve configuration is already holding 443, clear it before you expect Funnel to bind there.

  3. Your machine names become public. Certificates land in public Certificate Transparency logs, so anyone can enumerate the names. The documentation is blunt: “Do not enable the HTTPS feature if any of your machine names contain sensitive information” (kb-https).

  4. Path scoping is routing, not authentication. --set-path limits what you publish. It does not decide who may call it. Every webhook sender worth using signs its deliveries, so verify the signature in your handler and reject unsigned requests. Treat the endpoint as production exposure from the first minute, because it is.

  5. Bandwidth is limited and the limit is not yours to set. “Traffic sent over a Funnel is subject to non-configurable bandwidth limits” (kb-funnel). Funnel is for control plane traffic like webhooks and demos, not for shipping bulk data.

  6. Certificate churn has a hard wall. “It is possible to frequently request a new certificate and exceed Let’s Encrypt’s rate limits,” with the documented consequence of waiting 34 hours (kb-funnel, kb-https). Do not script anything that provisions certificates in a loop.

  7. Platform and plan constraints. Funnel “only works on platforms that can run the Tailscale CLI,” and macOS requires one of the open source variants of the application (kb-funnel). The Funnel documentation states Funnel is available for all plans (kb-funnel), while the pricing page groups it with the paid tiers (ts-pricing). Check the pricing page against your own tailnet before you build a workflow on the assumption.

Where to take it next

Run the tailnet only version of the same setup with tailscale serve for the services that never need public reach, such as a staging UI or a metrics page. Same syntax, same proxy, no front door, and no certificate transparency exposure beyond what HTTPS already published.

Use the second and third allowed ports deliberately. Port 443 for the signed webhook path, 8443 for an inspection UI you only enable while debugging, then turn 8443 off the moment you are finished. Two published surfaces with different lifetimes is easier to reason about than one surface with a growing route table.

Move the receiver off your laptop once the integration is real. As of 2026 Tailscale Services, Peer Relays, and workload identity federation are all generally available (ts-changelog), and Tailscale Services in particular exist “to decouple applications and services from the devices that host them,” which is the correct answer once a webhook endpoint outlives the machine that first received one.

Embed the same capability in your application with tsnet so the process joins the tailnet itself and no host level daemon configuration is involved. That turns a development trick into a deployable pattern.

Sources

  1. Tailscale Funnel checked 2026-08-11
  2. Tailscale Funnel (Tailscale Docs) checked 2026-08-11
  3. tailscale funnel command checked 2026-08-11
  4. Tailscale Serve checked 2026-08-11
  5. Enabling HTTPS checked 2026-08-11
  6. Tailnet policy file syntax checked 2026-08-11
  7. Tailscale Funnel: Securely Expose Local Services to the Internet checked 2026-08-11
  8. Funnel and Serve use cases checked 2026-08-11
  9. Tailscale changelog checked 2026-08-11
  10. Tailscale Acceptable Use Policy checked 2026-08-11
  11. Tailscale pricing and plans checked 2026-08-11

All recipes