Skip to Content

Self-Host Headscale: Your Own Tailscale Control Server in 15 Minutes

Self-Host Headscale: Your Own Tailscale Control Server in 15 Minutes

WireGuard is great — until you have to manage keys, configs, and NAT traversal for every device manually. Tailscale solves that, but its control plane lives in someone else's cloud. Headscale gives you the best of both: the Tailscale client experience with a control server you own.

In this guide, you'll deploy Headscale on a VPS, connect your first devices, and understand when to choose it over plain WireGuard or managed Tailscale.

Why Self-Host Headscale?

  • Privacy: Your network topology, device names, and metadata never leave your infrastructure
  • No device limits: Tailscale's free tier caps you at 100 devices. Headscale has no such ceiling
  • Custom ACLs: Full control over who can reach what, defined in a simple YAML policy
  • Cost: One VPS serves unlimited users and devices
  • Integration: Works with existing Tailscale clients on Linux, macOS, Windows, iOS, and Android

Prerequisites

  • A VPS with a public IP (1 vCPU / 512MB RAM is plenty for small networks)
  • Docker and Docker Compose installed
  • A domain pointed at your VPS (e.g., headscale.example.com)
  • Ports 80/tcp and 443/tcp open (for ACME / reverse proxy)
  • UDP 41641 open (WireGuard)

Step 1: Create the Docker Compose File

Create a directory and drop in docker-compose.yml:

version: "3.8"

services:
  headscale:
    image: headscale/headscale:0.23.0
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./config:/etc/headscale
      - ./data:/var/lib/headscale
    ports:
      - "127.0.0.1:8080:8080"   # HTTP — reverse proxy handles TLS
      - "41641:41641/udp"       # WireGuard
    networks:
      - headscale-net

networks:
  headscale-net:
    driver: bridge

Step 2: Configure Headscale

Create config/config.yaml:

server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false

noise:
  private_key_path: /var/lib/headscale/noise_private.key

prefixes:
  v6: fd7a:115c:a1e0::/48
  v4: 100.64.0.0/10
  allocation: sequential

derp:
  server:
    enabled: false
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  auto_update_enabled: true
  update_frequency: 24h

disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m

database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite

log:
  format: text
  level: info

acl_policy_path: ""

dns:
  magic_dns: true
  base_domain: example.com
  nameservers:
    global:
      - 1.1.1.1
      - 9.9.9.9
  search_domains:
    - example.com

unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"

logtail:
  enabled: false

Replace headscale.example.com and example.com with your actual domain.

Step 3: Launch the Stack

mkdir -p config data
docker compose up -d
docker compose logs -f headscale

You should see listening and serving HTTP on: 0.0.0.0:8080.

Step 4: Add a Reverse Proxy (Caddy)

Headscale needs HTTPS for the Tailscale clients to trust it. Caddy handles TLS automatically:

# Add to docker-compose.yml

  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
    networks:
      - headscale-net

volumes:
  caddy_data:

Create Caddyfile:

headscale.example.com {
    reverse_proxy headscale:8080
}
docker compose up -d

Step 5: Create Your First User

docker exec headscale headscale users create ali

Step 6: Connect a Device

Generate a pre-auth key (one-time or reusable):

docker exec headscale headscale preauthkeys create --user ali --reusable --expiration 24h

On the client machine (with Tailscale installed):

sudo tailscale up --login-server https://headscale.example.com --authkey tskey-auth-...

Or use the interactive flow:

sudo tailscale up --login-server https://headscale.example.com
# Then browse to the URL shown and select your user

Verification

# On the server
docker exec headscale headscale nodes list

# On the client
tailscale status
tailscale ping 100.64.0.1

You should see both nodes with their 100.x IPs and direct or relay connectivity.

ACLs: Control Who Talks to Whom

Create config/acls.hujson:

{
  "acls": [
    {
      "action": "accept",
      "src": ["ali"],
      "dst": ["ali:*"]
    },
    {
      "action": "accept",
      "src": ["tag:server"],
      "dst": ["tag:server:*", "ali:22"]
    }
  ],
  "tagOwners": {
    "tag:server": ["ali"]
  }
}

Then set acl_policy_path: /etc/headscale/acls.hujson in config.yaml and restart.

Headscale vs. Alternatives

FeatureHeadscaleTailscale (managed)Plain WireGuard
Control planeSelf-hostedCloudNone (manual)
Device limitUnlimited100 (free tier)Unlimited
NAT traversalYes (DERP)YesManual port-forward
MagicDNSYesYesNo
ACLsYAML/JSONGUI + ACLsiptables
Setup effortMediumZeroHigh

Next Steps

  • Exit node: Route all client traffic through a Headscale node for secure browsing on untrusted networks
  • Subnet routing: Advertise entire LANs (tailscale up --advertise-routes=192.168.1.0/24)
  • SSO integration: Pair with Authentik or Keycloak for user provisioning
  • Monitoring: Scrape Headscale metrics with Prometheus and visualize in Grafana
  • Backup: Snapshot the data/ directory — it holds your keys and state

Headscale turns WireGuard from a manual, per-device chore into a modern, zero-config mesh — without surrendering your network map to a third party. For homelabs, small teams, and privacy-conscious deployments, it's the sweet spot.

Self-Host Nextcloud: Your Own Private Google Drive Alternative in 20 Minutes