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:

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.