Setting up a custom Tailscale DERP and peer relay
This post walks through how I run my own DERP server and enable the new peer relay feature, both with Docker. The full setup is at myderper.
Custom DERP
DERP can fetch a Let’s Encrypt cert automatically, but only when it listens on port 443. Many hosts in China block 443 unless the domain is ICP-filed, so automatic ACME is not an option.
My first attempt used certbot with the DNS-01 challenge and a cron script to renew every three months. The script kept missing the derper restart after renewal, so the new cert was never picked up.
I gave up on renewal and switched to a self-signed cert with a 10-year validity. Tailscale supports pinning a DERP cert by its SHA256 hash via "CertName": "sha256-raw:...", which is cryptographically equivalent to a CA-signed cert for this purpose. The mechanism was added in tailscale v1.78, so both derper and clients must be at least that version.
derper
--hostname has to parse as an IP literal so that derper enters noHostname mode and accepts the sha256-raw:... SNI that pinning clients send. 127.0.0.1 is just a placeholder; the user-facing address lives in the tailnet’s derpMap, not here.
services:
derper:
build:
context: ./derper
args:
DERPER_REF: ${DERPER_VERSION:-v1.96.5}
image: myderper/derper:${DERPER_VERSION:-v1.96.5}
container_name: myderp
depends_on:
tailscaled:
condition: service_healthy
ports:
- "443:443/tcp"
- "9443:443/tcp"
- "3478:3478/udp"
command:
- -a=:443
- -hostname=127.0.0.1
- -certmode=manual
- -certdir=/certs
- -stun=true
- -verify-clients=true
- -http-port=-1
volumes:
- ./certs:/certs:ro
- tailscale-run:/var/run/tailscale:ro
restart: unless-stopped
I build derper from the upstream source. You can also use an existing image like fredliang/derper.
FROM golang:1.26-alpine AS build
ARG DERPER_REF=v1.96.5
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
ENV CGO_ENABLED=0
ENV GOPROXY=${GOPROXY}
ENV GOSUMDB=${GOSUMDB}
ENV GOTOOLCHAIN=auto
RUN go install tailscale.com/cmd/derper@${DERPER_REF}
FROM alpine:3.20
COPY --from=build /go/bin/derper /usr/local/bin/derper
ENTRYPOINT ["/usr/local/bin/derper"]
Sidecar tailscaled
derper’s --verify-clients needs a local tailscaled socket so it can check that incoming clients belong to the tailnet.
tailscaled:
image: tailscale/tailscale:${TAILSCALE_VERSION:-v1.96.5}
container_name: tailscaled
environment:
TS_AUTHKEY: ${TS_AUTHKEY}
TS_HOSTNAME: derper-verifier
TS_USERSPACE: "true"
TS_SOCKET: /var/run/tailscale/tailscaled.sock
TS_STATE_DIR: /var/lib/tailscale
volumes:
- ts_state:/var/lib/tailscale
- tailscale-run:/var/run/tailscale
healthcheck:
test: ["CMD", "test", "-S", "/var/run/tailscale/tailscaled.sock"]
interval: 10s
timeout: 3s
retries: 30
start_period: 10s
restart: unless-stopped
Certificate and .env
Generate the cert with OpenSSL. derper’s manualCertManager loads files named after --hostname, so the leaf has to be 127.0.0.1.crt and 127.0.0.1.key. The CN/SAN can still embed the real domain for readability, since pinning ignores the subject.
One catch: Go 1.24+ enforces RFC 5280 even when InsecureSkipVerify=true, so a true self-signed leaf (Issuer==Subject with CA:FALSE) is rejected as non-compliant. The workaround is a throw-away CA that signs the leaf — derper still serves only the leaf, since Tailscale’s pinning VerifyConnection rejects any extra cert in the chain.
# 1. self-signed CA (only used to sign the leaf — never served)
openssl ecparam -name prime256v1 -genkey -noout -out certs/ca.key
openssl req -x509 -new -key certs/ca.key -out certs/ca.crt \
-days 3650 \
-subj "/CN=myderp-ca" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign"
# 2. leaf key + CSR
openssl ecparam -name prime256v1 -genkey -noout -out certs/127.0.0.1.key
openssl req -new -key certs/127.0.0.1.key -out leaf.csr \
-subj "/CN=your.domain.name"
# 3. CA signs the leaf
openssl x509 -req -in leaf.csr -CA certs/ca.crt -CAkey certs/ca.key \
-CAcreateserial -out certs/127.0.0.1.crt -days 3650 \
-extfile <(printf '%s\n' \
"subjectAltName = DNS:your.domain.name,IP:127.0.0.1" \
"basicConstraints = critical,CA:FALSE" \
"keyUsage = critical,digitalSignature" \
"extendedKeyUsage = serverAuth")
# pin hash
openssl x509 -in certs/127.0.0.1.crt -outform DER | openssl dgst -sha256
Or just run gen-cert.sh from the repo, which does all of the above and prints the derpMap snippet ready to paste.
Drop the SHA256 into the tailnet policy at login.tailscale.com/admin/acls:
"derpMap": {
"OmitDefaultRegions": true,
"Regions": {
"900": {
"RegionID": 900,
"RegionCode": "myderp",
"Nodes": [{
"Name": "1",
"RegionID": 900,
"HostName": "your.domain.name",
"IPv4": "<IPv4 address of your relay server>",
"IPv6": "<IPv6 address of your relay server>",
"DERPPort": 9443,
"CertName": "sha256-raw:xxxxxxxx"
}]
}
}
}
And a .env for the compose stack:
TS_AUTHKEY=tskey-auth-xxxxxxxx
TAILSCALE_VERSION=v1.96.5
DERPER_VERSION=v1.96.5
Bring it up and check from any tailnet client:
docker compose up -d --build
tailscale netcheck # the custom region should appear with a sane RTT
tailscale debug derp 900 # replace 900 with your RegionID
References:
- https://tailscale.com/docs/reference/derp-servers
- https://tailscale.com/docs/reference/derp-servers/custom-derp-servers
Peer Relay
Peer relay is a UDP relay tried before DERP when two clients can’t reach each other directly. It only handles WireGuard fallback; DERP traffic is unaffected. Both the relay and the clients that use it need tailscale v1.86 or later.
The same sidecar tailscaled can double as a peer relay. Add the tag and expose the UDP port:
tailscaled:
environment:
TS_AUTHKEY: ${TS_AUTHKEY}
TS_HOSTNAME: derper-verifier
TS_USERSPACE: "true"
TS_SOCKET: /var/run/tailscale/tailscaled.sock
TS_STATE_DIR: /var/lib/tailscale
+ TS_EXTRA_ARGS: "--advertise-tags=tag:relay"
+ ports:
+ - "${PEER_RELAY_PORT:-40000}:${PEER_RELAY_PORT:-40000}/udp"
In the tailnet policy, own the tag and grant relay access:
{
"tagOwners": {
"tag:relay": ["autogroup:admin"]
},
"grants": [{
"src": ["*"],
"dst": ["tag:relay"],
"app": {"tailscale.com/cap/relay": [{}]}
}]
}
Create the auth key with tag:relay pre-authorized and use it as TS_AUTHKEY. The relay port is a pref, not an up flag, so set it once after the first boot. It persists in the ts_state volume:
docker exec tailscaled tailscale set --relay-server-port=40000
Verify from a client with tailscale ping <peer>. When the relay is in use you’ll see:
pong from peer (100.x.y.z) via peer-relay(<server-ip>:40000:vni:1) in 23ms
If you see via DERP(...) instead, either direct P2P worked (no relay needed) or both peer-relay attempts failed — check the host firewall on the UDP port.