Self-hosting a Tailscale DERP relay
Overview
A DERP relay is a data-plane relay: when two nodes can't punch a direct path, their traffic
is forwarded through it. It is only eligible if netcheck can measure latency to it.
Core principle: a relay that isn't reachable over STUN is never selected — so verify
selection, not just that the process is listening.
Region IDs must be 900–999. (Field-name case is not enforced — the API accepts both
RegionID and regionID; verified experimentally.)
Full command-by-command chain: RUNBOOK.md. Reusable scripts in scripts/.
When to Use
- Standing up a relay on a new server (bare IP or domain), for the official control plane or headscale.
- A custom region shows blank latency in
tailscale netcheck. - Clients never select your relay /
gauge_clients_totalstays 0. - Certificate/TLS errors against a self-signed relay.
- Self-signed cert renewal (check the cert's own expiry; replacing it changes the client fingerprint).
Not for: headscale's embedded DERP (derp.server, runs on the headscale host) — different
config path. Not for exit nodes or subnet routers.
Workflow
- Pick cert mode from the host. Bare IP →
-certmode manual(derper self-signs and prints
thesha256-raw:<hex>fingerprint). Domain →-certmode letsencrypt, needs tcp/80. - Provision:
scripts/install-derper.sh --host <ip> [--verify-client-url <url>]. - Open the firewall — all of it:
| Dir | Proto | Port | Why |
|---|---|---|---|
| IN | TCP | 443 | relay — mandatory |
| IN | UDP | 3478 | STUN — mandatory, see below |
| IN | UDP | 443 | HTTP/3 — recommended |
| IN | TCP | 80 | only for letsencrypt |
- Join the host to the tailnet if using
-verify-clients. - Publish the region: official control plane → ACL
derpMap(custom IDs only;
scripts/merge_derpmap.pymerges while preservinggrants/ssh); headscale →derp.paths
+derp.yaml(lowercase fields), notderp.server. - Verify all four (RUNBOOK Phase 4): region present in
derp-map→ real latency in
netcheck→scripts/verify_derp_relay.gohandshake →tailscale pingsays
via DERP(<code>)and servergauge_clients_total≥ 1.
Common Mistakes
| Symptom / belief | Reality |
|---|---|
| "Only tcp/443 needs opening." | udp/3478 is mandatory. netcheck measures regions over STUN; the HTTPS fallback is gated behind if !rs.anyUDP() (net/netcheck/netcheck.go) and never fires on a normal network. With udp blocked the region is never selected. Blank latency in netcheck is the tell. |
"The region shows in netcheck and STUN passes, so it's done." |
That only proves the control plane published it. It isn't used until latency is measured and a client switches home DERP — and home DERP is sticky. Prove relaying via tailscale ping showing via DERP(<code>) + gauge_clients_total ≥ 1; force the switch with tailscale debug pick-new-derp. |
tailscale debug derp <id> reports certificate signed by unknown authority → "self-signed IP relays don't work." |
False alarm. That command builds a raw tls.Client with ServerName = cmp.Or(CertName, HostName) (ipn/localapi/debugderp.go) and never implements pinning. The real relay path (derp/derphttp/derphttp_client.go, sha256-raw: → tlsdial.SetConfigExpectedCertHash) honors it. Test with scripts/verify_derp_relay.go. |
ACL derpMap must list all official regions, or use omitDefaultRegions. |
Wrong. Official IDs are rejected: invalid region ID 1; must be in range [900,999] (verified). Put only your 900–999 region in; official relays merge in automatically. |
-http-port=-1 |
Go's flag parser wants -http-port -1 (space); the = form errors. Separately: derper binds :80 by default and log.Fatals on EADDRINUSE, so on a host already running nginx pass -http-port -1 in manual-cert mode (which needs no ACME). |
| "derper doesn't need tcp/443." | True only if you moved it with -a :8443; then clients need "DERPPort": 8443. Otherwise tcp/443 is the relay port and is required. |
| "The self-signed cert lasts N years." | Don't state a duration — query the cert. derper only creates it when the file is absent (os.IsNotExist guard), so an existing cert is simply reloaded and never auto-rotates; the printed expiry is the real deadline. Read it with openssl x509 -in <certdir>/<host>.crt -noout -startdate -enddate. Renewal changes the fingerprint → update the client map. |
Nothing relays, but the unit is active and /derp/probe returns 200. |
Check (a) udp/3478, (b) certName present for self-signed, (c) region selection — see the next row. Listening ≠ selected. |
| The region is healthy and measured, yet never carries peer traffic. | A relay is only preferred when the relay host itself is homed to it. Fix: systemctl restart tailscaled on the relay host (a clean restart makes it pick the near region), then re-check. Do not spam tailscale debug pick-new-derp — when the daemon has no measured latencies (DERPLatency empty, common where outbound STUN to public servers is blocked) it picks a random region and can wander to another continent, which is worse than doing nothing. Verify the host's own home with tailscale status --json \| grep Relay. |
| Public STUN is blocked from the relay host, so it can never measure any DERP region. | netcheck may still report a public IP + UDP; the tell is NetInfo.DERPLatency staying empty. The relay still works for other clients — only the host's own home selection is affected. Leave its home alone; forcing it randomly is the failure mode above. |
-verify-clients "just works" as a non-root user. |
It needs an explicit writable config path: pass -c /var/lib/derper/derper.key and set WorkingDirectory. Otherwise derper: -c <config path> not specified → restart loop under Restart=always. |
| Relay traffic is free. | Billed both directions, and shares the host's egress with whatever else runs there. Warn before enabling on a shared production box. |
Rejected claims (tested, do not repeat)
- "ACL
derpMaprequires PascalCase field names." — BothRegionID/RegionCode/HostNameand
regionID/regionCode/hostNameare accepted (HTTP 200) and stored verbatim. Only the ID
range is enforced. - "Opening tcp/443 is sufficient for a working relay." — Produces a relay that is never selected.
- "
netchecklisting the region + STUN working completes verification." — Neither proves traffic relays.
Red Flags — stop and re-check
- About to declare success because the unit is
activeand/derp/probeis 200. - About to trust
tailscale debug derpfor a self-signed relay. - About to write official region IDs into an ACL
derpMap. - About to skip udp/3478 "because 443 is enough".
- About to run a relay with no
-verify-clients/-verify-client-url.