Self-hosted Headscale + DERP server

Previously, I accessed my NAS at home through port mapping, but there were some issues:

  • Need to configure DDNS, there may be a delay when switching IP
  • Accessing web services requires adding ports
  • Only expose certain services, cannot open public network access to core services (such as router configuration)

Recently, I have researched and tried various networking solutions (ZeroTier/WireGuard/Tailscale), and finally chose to build my own Headscale solution.


  • A VPS, with Docker and Docker Compose installed, and the following ports open:
    • 8080 Headscale HTTP
    • 9090 Headscale Metrics
    • 8443 DERP server HTTPS
    • 3478/udp DERP server STUN
    • 8008 Headscale-Admin
  • Client (Linux/macOS/Windows/iOS/Android)

Deploy DERP Server#

Create a directory to store the configuration files for the DERP server

mkdir -p /app/derper/certs
cd /app/derper

Create a self-signed certificate

openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes -keyout /app/derper/certs/<hostname>.key -out /app/derper/certs/<hostname>.crt -subj "/CN=<hostname>" -addext "subjectAltName=DNS:<hostname>"

hostname can be any value, it just needs to be consistent

Create a docker-compose.yaml file with the following content:

    image: ghcr.nju.edu.cn/yangchuansheng/derper:latest
    container_name: derper
    restart: always
      - /app/derper/certs:/app/certs
      - DERP_CERT_MODE=manual
      - DERP_ADDR=:443
      - DERP_DOMAIN=<hostname>
      - 8443:443
      - 3478:3478/udp

Here, we are using the GHCR image provided by Nanjing University. If not needed, you can replace ghcr.nju.edu.cn with ghcr.io

Finally, start the DERP server

docker compose pull
docker compose up -d

Create a derp.json configuration file and upload it to a location that can be accessed directly:

  "Regions": {
    "901": {
      "RegionID": 901,
      "RegionCode": "cn-hangzhou",
      "RegionName": "Hangzhou",
      "Nodes": [
          "Name": "901h",
          "RegionID": 901,
          "DERPPort": 8443,
          "HostName": "<hostname>",
          "IPv4": "<IP>",
          "InsecureForTests": true

Here, we assume that we have uploaded it to https://example.com/derp.json

Deploy Headscale#

Create a directory for the Headscale configuration files

mkdir -p /app/headscale
cd /app/headscale
mkdir config
touch config/db.sqlite
wget -O config/config.yaml https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml

Modify the configuration in the config/config.yaml file:

  • Modify server_url to http://<IP>:8080
  • Modify listen_addr to
  • Modify metrics_listen_addr to (optional)
  • Modify grpc_listen_addr to
  • Modify noise.private_key_path to /etc/headscale/noise_private.key
  • Modify prefixes.v4 and prefixes.v6 as needed
  • Modify derp.urls[0] to the URL of the uploaded derp.json file, which is https://example.com/derp.json in this case
  • Modify database.sqlite.path to /etc/headscale/db.sqlite
  • Modify dns_config.nameservers to and (modify according to your situation)
  • Modify dns_config.base_domain to your own domain name, which will be displayed in the client

Create a docker-compose.yaml file:

    image: headscale/headscale:0.23.0-alpha8
    container_name: headscale
    restart: always
    command: serve
      - ./config:/etc/headscale
      - 8080:8080
      - 9090:9090

Finally, start Headscale

docker compose pull
docker compose up -d

Deploy Headscale-Admin#

Create a directory for the Headscale-Admin configuration files

mkdir -p /app/headscale-admin
cd /app/headscale-admin

Due to the HTTP security restrictions of browsers, direct access through a domain name will result in cross-origin issues. Here, we use Caddy as a reverse proxy to bypass this issue.

nano Caddyfile

The content is as follows:

:{$PORT:80} {
  reverse_proxy /api/* <IP>:8080
  root * /app
  encode gzip zstd
  try_files {path}.html {path}

Create a docker-compose.yaml file:

    image: goodieshq/headscale-admin:latest
    container_name: headscale-admin
    restart: unless-stopped
      - ./Caddyfile:/etc/caddy/Caddyfile
      - PORT=80
      - ENDPOINT=/headscale
      - 8008:80

Finally, start it

docker compose pull
docker compose up -d

The API Key for Headscale can be created with the following command:

docker exec -it headscale headscale apikey create

The Web UI can be accessed at http://<IP>:8008/headscale, and the Legacy API option should be unchecked in the configuration.

Create Users#

Execute the following command on the server

docker exec -it headscale headscale users create <your username>

Client Configuration#


First, install the Tailscale client

curl -fsSL https://tailscale.com/install.sh | sh

Then, request to join the Headscale network

tailscale up --login-server=http://<IP>:8080 --accept-routes

If you need to access other networks through this node, add the --advertise-routes parameter:

tailscale up --login-server=http://<IP>:8080 --advertise-routes= --accept-routes

Open the link in the returned result, there is an authentication command inside, execute it on the server

docker exec headscale headscale nodes register --user <your username> --key nodekey:******

Apple Devices#

Access http://<IP>:8080/apple directly and follow the steps.


Open F-Droid and download the APK installation package:


After installation, open the app, tap the three dots in the upper right corner multiple times, and the Change Server option will appear. Enter http://<IP>:8080 and follow the prompts to complete the authentication.


I don't have a Windows device at home, so I can't test and verify it. Please refer to the tutorials in the reference materials.

Reference Materials#

Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.