Skip to main content
COSMICBYTEZLABS
NewsSecurityHOWTOsToolsTraining
StudyProjectsNewsletterHire MeAbout
Subscribe

Press Enter to search or Esc to close

News
Security
HOWTOs
Tools
Training
Study
Projects
Newsletter
Hire Me
About
RSS Feed
Reading List
Subscribe

Stay in the Loop

Get the latest security alerts, tutorials, and tech insights delivered to your inbox.

Subscribe NowFree forever. No spam.
COSMICBYTEZLABS

Your trusted source for IT intelligence, cybersecurity insights, and hands-on technical guides.

2722+ Articles
166+ Guides

CONTENT

  • Latest News
  • Security Alerts
  • HOWTOs
  • Checklists
  • Projects
  • Exam Prep

RESOURCES

  • Search
  • Browse Tags
  • Newsletter Archive
  • Reading List
  • RSS Feed

COMPANY

  • About Us
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 CosmicBytez Labs. All rights reserved.

System Status: Operational
  1. Home
  2. Projects
  3. Headscale: Self-Hosted Tailscale Control Server for Zero-Trust Mesh VPN
Headscale: Self-Hosted Tailscale Control Server for Zero-Trust Mesh VPN
PROJECTIntermediate

Headscale: Self-Hosted Tailscale Control Server for Zero-Trust Mesh VPN

Replace Tailscale's cloud coordination server with a self-hosted Headscale instance, then lock the tailnet down with ACL policies, tagged nodes, and a subnet router.

Dylan H.

Projects

September 9, 2026
6 min read
2-4 hours

Tools & Technologies

HeadscaleTailscale clientDocker ComposeHeadplaneTraefik

Overview

Tailscale is one of the easiest ways to build a mesh VPN — it wraps WireGuard in an authenticated, NAT-punching control plane so devices find and reach each other without manual key exchange or router port-forwarding. The catch: the default control server is Tailscale's own cloud, which means an outside party runs the coordination layer for your private network — device list, ACL policy, and DERP relay usage all pass through it.

Headscale is an open-source (BSD-3) reimplementation of that control server that speaks the exact same client protocol. Point stock Tailscale clients at your own Headscale instance instead of controlplane.tailscale.com, and you get the same NAT traversal, WireGuard key rotation, and MagicDNS — but the device registry, ACL policy, and audit trail live on hardware you control. It's the same idea as the site's existing WireGuard road-warrior build, but Headscale adds a real control plane on top: dynamic peer discovery, group/tag-based ACLs, and a mesh topology instead of static peer configs.

This build stands up Headscale in Docker, enrolls clients with pre-auth keys, writes a least-privilege ACL policy, and adds a subnet router so tailnet devices can reach the rest of a home or lab LAN without installing Tailscale on every box.

Architecture

                         ┌─────────────────────────┐
                         │   Headscale (Docker)     │
                         │  :8080 gRPC/HTTP control │
                         │  SQLite state + ACL      │
                         │  built-in DERP (STUN)    │
                         └────────────┬─────────────┘
                                      │ HTTPS (via reverse proxy)
              ┌───────────────────────┼───────────────────────┐
              │                       │                       │
      ┌───────▼──────┐        ┌───────▼──────┐        ┌───────▼───────┐
      │ Laptop        │        │ Phone         │        │ Subnet router  │
      │ (tag:admin)   │        │ (tag:client)  │        │ (tag:router)   │
      │ Tailscale     │        │ Tailscale app │        │ advertises     │
      │ client        │        │               │        │ 192.168.1.0/24 │
      └───────────────┘        └───────────────┘        └────────────────┘

Every peer holds a WireGuard key pair and a short-lived control-plane session; actual data still flows peer-to-peer over WireGuard (or via the built-in DERP relay when direct UDP is blocked). Headscale never sees traffic — only registration, key exchange, and ACL evaluation.

Step-by-Step Build

1. Lay out the Docker Compose stack

# docker-compose.yml
services:
  headscale:
    image: headscale/headscale:0.29.3
    container_name: headscale
    restart: unless-stopped
    command: headscale serve
    volumes:
      - ./config:/etc/headscale
      - ./data:/var/lib/headscale
    ports:
      - "8080:8080"   # control server
      - "3478:3478/udp" # built-in DERP/STUN
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.headscale.rule=Host(`hs.example.com`)"
      - "traefik.http.services.headscale.loadbalancer.server.port=8080"
      - "traefik.http.routers.headscale.tls.certresolver=letsencrypt"
 
  headplane:
    image: ghcr.io/tale/headplane:latest
    container_name: headplane
    restart: unless-stopped
    depends_on:
      - headscale
    environment:
      - COOKIE_SECRET=${HEADPLANE_COOKIE_SECRET}
      - HEADSCALE_URL=https://hs.example.com
    volumes:
      - ./headplane-config.yaml:/etc/headplane/config.yaml
      - ./config:/etc/headscale:ro
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.headplane.rule=Host(`hs-admin.example.com`)"
      - "traefik.http.services.headplane.loadbalancer.server.port=3000"

Headscale ships with no web UI — Headplane is the community admin panel for managing users, nodes, and policy without dropping to the CLI on every change. Both containers sit behind Traefik for TLS; MagicDNS and OIDC callbacks require a real HTTPS hostname, not a bare IP.

2. Write the base config

mkdir -p config data
curl -fsSL https://raw.githubusercontent.com/juanfont/headscale/v0.29.3/config-example.yaml \
  -o config/config.yaml

Edit the essentials:

# config/config.yaml
server_url: https://hs.example.com
listen_addr: 0.0.0.0:8080
database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite
 
dns:
  magic_dns: true
  base_domain: tailnet.example.com
  nameservers:
    global:
      - 1.1.1.1
      - 9.9.9.9
 
derp:
  server:
    enabled: true
    region_id: 999
    stun_listen_addr: "0.0.0.0:3478"

Headscale exits with a "config file not found" error if config.yaml isn't in place before the container's first start — create it, then docker compose up -d.

3. Create a user and enroll the first client

docker compose exec headscale headscale users create dylan
 
# pre-auth key: single device, expires in 24h
docker compose exec headscale headscale preauthkeys create \
  --user dylan --expiration 24h --tags tag:admin

On the client:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server https://hs.example.com \
  --authkey <preauthkey-from-above>

Repeat with a fresh pre-auth key (and appropriate tag) per device — phone, laptop, server. Check registration server-side:

docker compose exec headscale headscale nodes list

4. Write a least-privilege ACL policy

Headscale's policy format mirrors Tailscale's own ACL syntax (HuJSON), so existing Tailscale ACL examples apply with minimal changes. Without a policy file, every device on the same user can reach every other device — fine for a two-device test, not for a real tailnet.

// config/acl.hujson
{
  "groups": {
    "group:admins": ["dylan@"],
  },
  "tagOwners": {
    "tag:admin": ["group:admins"],
    "tag:router": ["group:admins"],
    "tag:client": ["group:admins"],
  },
  "acls": [
    // admins reach everything
    { "action": "accept", "src": ["group:admins"], "dst": ["*:*"] },
 
    // regular clients: DNS + HTTPS to the subnet router only
    { "action": "accept", "src": ["tag:client"], "dst": ["tag:router:53,443"] },
  ],
}

Load it:

docker compose exec headscale headscale policy set --file /etc/headscale/acl.hujson

5. Add a subnet router

Instead of installing Tailscale on every LAN device, one box advertises the whole subnet:

sudo tailscale up --login-server https://hs.example.com \
  --authkey <preauthkey-router> \
  --advertise-routes=192.168.1.0/24 \
  --advertise-tags=tag:router

Routes stay disabled until approved server-side:

docker compose exec headscale headscale nodes list-routes
docker compose exec headscale headscale routes enable -r <route-id>

Now any enrolled device can reach 192.168.1.0/24 through the router node, gated by the ACL rules above.

Testing

  • tailscale status on any client — confirms peers, relay vs. direct (direct vs relay in the output), and route advertisements.
  • tailscale ping <peer-hostname> — confirms whether traffic is going peer-to-peer or bouncing through DERP.
  • From a tag:client device, curl https://<router-tagged-host> should succeed; curl http://<router-tagged-host>:8080 (a port not in the ACL) should hang/refuse — proves the policy is enforced, not just present.
  • docker compose exec headscale headscale nodes list — cross-check expected devices, tags, and last-seen timestamps against what's actually enrolled.
  • Kill outbound UDP on a client's network (or test from a double-NAT connection) and confirm the peer still connects — validates DERP fallback.

Deployment Notes

  • Run Headscale behind a reverse proxy with real TLS (Traefik/Caddy/nginx) — server_url must be a routable HTTPS hostname for MagicDNS and OIDC to work correctly.
  • SQLite is fine for a single Headscale instance; move to PostgreSQL only if you need multiple coordination nodes for HA.
  • Back up data/db.sqlite and config/ together — the database and the noise/derp keys in config/ must stay paired, or existing clients lose their registration.
  • Headscale does not support direct upgrades from databases older than v0.25 — step through intermediate stable releases when upgrading a long-lived instance.
  • Prefer OIDC (oidc.issuer in config) over long-lived pre-auth keys for anything beyond a handful of personal devices — it ties tailnet membership to your existing identity provider and revokes cleanly on offboarding.

Extensions

  • Wire Headplane's OIDC login to the same identity provider as the tailnet itself, so admin-panel access and device enrollment share one auth source.
  • Add a second tag:router node behind a Zabbix/Grafana check that alerts if the route ever drops out of headscale nodes list-routes.
  • Combine with the site's Traefik + Docker TLS build to expose only Headscale's control port publicly while every backend service stays reachable solely over the tailnet.
  • Script pre-auth key rotation with a short cron job so unused single-use keys never linger past their expiry window.

Sources: Headscale releases, Headscale ACL/policy docs, Headplane, Techdox: Headscale with Docker Compose, wg/all: Headscale complete guide

#headscale#tailscale#wireguard#zero-trust#vpn#self-hosted#homelab

Related Articles

WireGuard Road Warrior VPN Server

Build a self-hosted WireGuard VPN server on Ubuntu for secure remote access — with NAT masquerading, DNS leak protection, QR-code client provisioning, and...

7 min read

WireGuard VPN: Secure Remote Access for IT Professionals

Deploy a modern, high-performance WireGuard VPN server on Linux for secure remote access. Covers server setup, client configuration, multi-peer management, and firewall rules.

9 min read

WireGuard VPN Setup and Security Hardening on Linux

Deploy a hardened WireGuard VPN server on Linux — key generation, server and client config, firewall rules, and security best practices for production use.

8 min read
Back to all Projects