Multi-arch Tailscale DERP with HTTP/HTTPS, acme.sh, optional STUN, and dynamic-IP policy GitOps.
511
This project builds Tailscale's derper from upstream source and packages the
repository-root derper-linux-amd64 or
derper-linux-arm64 binary with a pinned copy of acme.sh. It can terminate
TLS directly or run as a certificate-free HTTP DERP behind an external TLS
terminator. It can also detect changing public IP
addresses and update one custom DERP region in a HuJSON tailnet policy stored in
a Git repository. GitHub CLI (gh) is included for API, authentication, and PR
workflows. The automatic policy sync supports either Git over SSH with a deploy
key or GitHub HTTPS authentication with GH_TOKEN.
Prebuilt multi-architecture images are published on Docker Hub as
gugang2006/tailscale-derper,
with linux/amd64 and linux/arm64 variants.
Clone the Tailscale source tree and this project as sibling directories. The commands below use the Go toolchain pinned by Tailscale, so a separately installed matching Go release is not required.
Prerequisites: Git, Docker with Buildx, and network access to GitHub, Docker Hub, the Go toolchain download service, and an Alpine package mirror.
mkdir tailscale-derper-build
cd tailscale-derper-build
git clone https://github.com/tailscale/tailscale.git
git clone https://github.com/yeabow/tailscale-derper.git
Compile both Linux architectures from the current Tailscale checkout and place the output binaries in this project's build context:
cd tailscale
git pull --ff-only origin main
TS_USE_TOOLCHAIN=1 GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
./build_dist.sh --strip \
-o ../tailscale-derper/derper-linux-amd64 ./cmd/derper
TS_USE_TOOLCHAIN=1 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
./build_dist.sh --strip \
-o ../tailscale-derper/derper-linux-arm64 ./cmd/derper
eval "$(TS_USE_TOOLCHAIN=1 ./build_dist.sh shellvars)"
cd ../tailscale-derper
VERSION_SHORT is used as the immutable image tag, while VERSION_LONG is
stored in the OCI image metadata and checked against derper -version during
the build. This keeps the source revision, binary, and image version in sync.
For a local single-architecture image, select the platform matching the target
machine and use --load:
docker buildx build \
--platform linux/amd64 \
--load \
--build-arg DERPER_VERSION="$VERSION_LONG" \
-t "tailscale-derper:$VERSION_SHORT" \
-t tailscale-derper:latest \
.
To publish both architectures, log in to Docker Hub, set your image repository,
and use --push:
docker login
IMAGE=your-dockerhub-user/tailscale-derper
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
--build-arg DERPER_VERSION="$VERSION_LONG" \
-t "$IMAGE:$VERSION_SHORT" \
-t "$IMAGE:latest" \
.
If the default Alpine CDN is slow or unavailable, select a compatible mirror
without editing the Dockerfile, for example by adding
--build-arg ALPINE_MIRROR=https://mirrors.aliyun.com/alpine.
The Dockerfile accepts linux/amd64 and linux/arm64 targets. It selects and
verifies the matching DERP binary and pinned acme.sh release, and builds and
tests the HuJSON policy helper.
Set DERP_MODE=http to run only the main DERP HTTP listener. In this mode no
hostname, ACME account, certificate, Git repository, GitHub key, or token is
required. ACME_ENABLED=auto resolves to disabled, and policy sync is disabled
by default. The following example also disables STUN, so the process opens only
TCP port 8080:
docker run -d \
--name derper \
--restart unless-stopped \
--network host \
-e DERP_MODE=http \
-e DERP_PORT=8080 \
-e STUN_ENABLED=false \
-e ACME_ENABLED=false \
-e POLICY_SYNC_ENABLED=false \
-v derper-state:/var/lib/derper \
gugang2006/tailscale-derper:latest
Probe it with:
curl -fsS http://127.0.0.1:8080/derp/probe
The standalone Compose file provides the same minimal mode:
DERP_PORT=8080 docker compose \
-f compose.http.yaml \
up -d
Standard Tailscale DERP clients use HTTPS. Expose this HTTP-only mode through a correctly configured TLS terminator, or use it only on a trusted internal/test network. To publish the container directly as a custom DERP, use HTTPS mode.
The hostname must already resolve to the host. Allow inbound TCP 80 and 443 and UDP 3478. TCP 80 must remain available for the standalone ACME challenge.
docker run -d \
--name derper \
--restart unless-stopped \
--network host \
-e DERP_HOSTNAME=relay.example.com \
-e DERP_PORT=443 \
-e STUN_PORT=3478 \
-e [email protected] \
-v derper-state:/var/lib/derper \
-v derper-acme:/var/lib/acme \
gugang2006/tailscale-derper:latest
On Linux, host networking avoids an extra container NAT layer for DERP and
STUN. If host networking is unavailable, publish the selected TCP
DERP_PORT, UDP STUN_PORT, and TCP port 80 for HTTP-01.
The included compose.yaml provides the same host-networked setup. For this
HTTPS mode, set DERP_HOSTNAME and ACME_EMAIL in the environment before
running it.
Set ACME_CHALLENGE=dns and ACME_DNS_PROVIDER to an acme.sh DNS provider
name, then pass that provider's credential environment variables. The provider
type and parameters are passed directly to acme.sh, so any DNS API implemented
under acme.sh's dnsapi directory is supported.
Cloudflare example:
-e ACME_CHALLENGE=dns \
-e ACME_DNS_PROVIDER=dns_cf \
-e CF_Token="$CF_Token"
Alibaba Cloud DNS example:
-e ACME_CHALLENGE=dns \
-e ACME_DNS_PROVIDER=dns_ali \
-e Ali_Key="$Ali_Key" \
-e Ali_Secret="$Ali_Secret"
ACME_DNS_SLEEP optionally overrides DNS propagation wait time. Keep provider
credentials outside the image and restrict access to the container
configuration. DNS-01 is particularly useful with a dynamic public IP because
certificate issuance and renewal do not require the hostname's A record to
point at the current address.
Enable this feature with POLICY_SYNC_ENABLED=true. At startup the container:
derpMap.Regions[POLICY_DERP_REGION_ID].It repeats IP detection every POLICY_SYNC_INTERVAL seconds (five minutes by
default). It only clones the repository again when the detected address or DERP
endpoint settings changed. A transient detection or Git failure leaves the
existing remote policy untouched and DERP online; set
POLICY_SYNC_REQUIRED=true to make the initial synchronization mandatory.
Example using a GitHub deploy key with write access:
docker run -d \
--name derper \
--restart unless-stopped \
--network host \
-e DERP_HOSTNAME=relay.example.com \
-e DERP_PORT=8443 \
-e STUN_PORT=3479 \
-e [email protected] \
-e ACME_CHALLENGE=dns \
-e ACME_DNS_PROVIDER=dns_cf \
-e CF_Token="$CF_Token" \
-e POLICY_SYNC_ENABLED=true \
-e [email protected]:example/tailnet-policy.git \
-e POLICY_GIT_BRANCH=main \
-e POLICY_FILE=policy.hujson \
-e POLICY_DERP_REGION_ID=900 \
-e POLICY_DERP_REGION_CODE=home \
-e 'POLICY_DERP_REGION_NAME=Home Dynamic DERP' \
--mount type=bind,src=/secure/github-deploy-key,dst=/run/secrets/github_ssh_key,readonly \
-v derper-state:/var/lib/derper \
-v derper-acme:/var/lib/acme \
gugang2006/tailscale-derper:latest
The key must be unencrypted because the service is non-interactive. The image
uses GitHub's published SSH host keys with strict host-key checking. For GitHub
Enterprise or another SSH Git service, mount its verified known_hosts file
and set POLICY_GIT_KNOWN_HOSTS_FILE to that in-container path. A base64 key in
POLICY_GIT_SSH_KEY_B64 is also supported, but a read-only Docker secret is
preferred because environment variables are easier to expose accidentally.
The built-in policy sync can also use GH_TOKEN when POLICY_GIT_REPO is an
HTTPS URL whose host matches GH_HOST. It configures gh as Git's credential
helper without writing the token into the repository URL or Git config. A
fine-grained token needs read and write access to repository contents, and any
branch rules must allow the token owner to push.
Mounting a token file is preferred over putting the token directly in the container environment:
docker run -d \
--name derper \
--restart unless-stopped \
--network host \
-e DERP_HOSTNAME=relay.example.com \
-e [email protected] \
-e POLICY_SYNC_ENABLED=true \
-e POLICY_GIT_REPO=https://github.com/example/tailnet-policy.git \
-e GH_HOST=github.com \
-e GH_TOKEN_FILE=/run/secrets/github_token \
--mount type=bind,src=/secure/github-token,dst=/run/secrets/github_token,readonly \
-v derper-state:/var/lib/derper \
-v derper-acme:/var/lib/acme \
gugang2006/tailscale-derper:latest
Passing -e GH_TOKEN="$GH_TOKEN" directly is also supported. gh gives
GH_TOKEN precedence over its stored credentials. For GitHub Enterprise,
configure GH_HOST and use the token variable appropriate for that host.
The updater never replaces the whole policy and never edits grants, acls,
ssh, groups, or another DERP region. If derpMap or Regions is absent, it
creates that container object; otherwise it changes only the configured region
ID. POLICY_OMIT_DEFAULT_REGIONS=preserve (the default) also leaves the current
default-region setting untouched.
The Git push only changes the repository. Configure Tailscale's GitOps workflow for that repository separately so accepted commits are applied to the tailnet. With Compose, add the included overlay:
docker compose \
-f compose.yaml \
-f compose.policy.yaml \
up -d
That overlay is the SSH deploy-key example. For HTTPS token authentication,
use compose.policy-token.yaml instead and set GITHUB_TOKEN_PATH to the host
path of the token file.
docker compose \
-f compose.yaml \
-f compose.policy-token.yaml \
up -d
The supervisor runs acme.sh --cron every six hours by default. acme.sh copies
renewed material into:
/var/lib/derper/certs/<hostname>.crt/var/lib/derper/certs/<hostname>.keyIts persisted reload command writes a marker under /run; PID 1 validates the
new files and restarts only the DERP child process. Persist both /var/lib/acme
and /var/lib/derper, otherwise the ACME account, certificates, or DERP node
identity will be lost when the container is replaced.
To test issuance against the Let's Encrypt staging service, set
ACME_STAGING=true. Staging certificates are not trusted by clients.
To use externally managed certificates in HTTPS mode, set ACME_ENABLED=false
and mount the certificate and key at the two paths above before starting the
container. In HTTP mode, certificate files are neither required nor read.
Useful variables:
| Variable | Default | Purpose |
|---|---|---|
DERP_MODE | https | https for TLS or http for a certificate-free main listener |
DERP_HOSTNAME | required for HTTPS | TLS hostname and certificate filename; optional in HTTP mode |
DERP_PORT | auto | Main DERP port: 443 for HTTPS or 80 for HTTP unless explicitly set |
STUN_ENABLED | true | Enable the built-in UDP STUN listener; set false for DERP-only mode |
STUN_PORT | 3478 | Public and local UDP STUN port; written to policy |
ACME_ENABLED | auto | Enable acme.sh; auto enables it for HTTPS and disables it for HTTP |
ACME_EMAIL | required with ACME | ACME account contact |
ACME_SERVER | letsencrypt | acme.sh CA alias or directory URL |
ACME_CHALLENGE | standalone | standalone or dns |
ACME_KEY_LENGTH | ec-256 | ECC or RSA certificate key type |
ACME_CRON_INTERVAL | 21600 | Renewal check interval in seconds |
ACME_DNS_PROVIDER | required for DNS-01 | acme.sh provider type, such as dns_cf or dns_ali |
ACME_DNS_SLEEP | provider default | DNS propagation wait override in seconds |
DERP_HTTP_PORT | -1 | Secondary HTTP listener in HTTPS mode; must remain -1 in HTTP-only mode |
DERP_HEALTHCHECK_PORT | DERP_PORT | Optional local HTTP/HTTPS health-check port override |
POLICY_SYNC_ENABLED | false | Enable public-IP detection and policy Git sync |
POLICY_SYNC_INTERVAL | 300 | Public-address recheck interval in seconds |
POLICY_GIT_REPO | required with sync | SSH, HTTPS, or file Git repository URL |
POLICY_GIT_BRANCH | main | Branch to clone and push |
POLICY_FILE | policy.hujson | Relative policy path inside the repository |
POLICY_GIT_SSH_KEY_FILE | /run/secrets/github_ssh_key | Read-only deploy-key secret path |
GH_TOKEN | unset | GitHub token used by gh and HTTPS policy Git authentication |
GH_TOKEN_FILE | unset | Read-only token file; its first line overrides GH_TOKEN for policy sync |
GH_HOST | github.com | GitHub hostname that must match the HTTPS policy repository host |
POLICY_DERP_REGION_ID | 900 | Managed custom region; must be 900 through 999 |
POLICY_DERP_REGION_CODE | dynamic | Region code written to policy |
POLICY_DERP_REGION_NAME | Dynamic DERP | Region display name written to policy |
POLICY_DERP_NODE_NAME | <code>-<id> | Unique node name written to policy |
POLICY_OMIT_DEFAULT_REGIONS | preserve | Preserve, enable, or disable default DERP regions |
DERP_IPV4 | auto | Detect, disable (none), or pin the policy IPv4 value |
DERP_IPV6 | none | Detect (auto), disable, or pin the policy IPv6 value |
PUBLIC_IPV4_URLS | three built-ins | Comma-separated plain-text IPv4 endpoints |
PUBLIC_IPV6_URLS | two built-ins | Comma-separated plain-text IPv6 endpoints |
PUBLIC_IP_TIMEOUT | 10s | Timeout per public-IP endpoint |
Additional container arguments are appended to the DERP command line.
Tailscale's current documentation requires custom DERP servers to have direct internet access and warns against placing them behind NAT, a firewall doing address translation, or a load balancer. Dynamic-address automation reduces policy drift but does not remove those protocol and source-address requirements.
Content type
Image
Digest
sha256:8168e2724…
Size
47.8 MB
Last updated
about 2 months ago
docker pull gugang2006/tailscale-derper