Self-Hosted Tailscale with Headscale + Headplane (2026 Guide)

This guide shows how to set up your own private Tailscale control server using Headscale (open-source alternative to Tailscale’s coordination server) and Headplane (nice web UI for managing it).
Everything runs in Docker, works behind Nginx reverse proxy with HTTPS, and is perfect for a small private network (e.g. you + your devices + friends).

Tested on a cheap VPS (Ubuntu 24.04), but works on any Linux server with Docker.

Prerequisites

Step 1: Initial structure

mkdir ~/tailscale && cd ~/tailscale

This folder will contain all necessary configs.

Ensure that you changed all “your.domain” in configs to your actual domain name.

docker-compose.yml

version: '3.8'

services:
  headscale:
    image: headscale/headscale:latest
    container_name: headscale
    restart: unless-stopped
    command: serve
    volumes:
      - ./config.yaml:/etc/headscale/config.yaml:ro
      - ./data:/var/lib/headscale
    networks:
      - tailnet

  headplane:
    image: ghcr.io/tale/headplane:latest
    container_name: headplane
    restart: unless-stopped
    volumes:
      - ./headplane-config.yaml:/etc/headplane/config.yaml:ro
      - ./headplane-data:/var/lib/headplane
    networks:
      - tailnet

networks:
  tailnet:
    driver: bridge

config.yaml (Main headscale config)

server_url: https://your.domain

listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 0.0.0.0:50443
grpc_allow_insecure: false

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

prefixes:
  v4: 100.64.0.0/10

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

derp:
  server:
    enabled: false
    stun_listen_addr: "0.0.0.0:3478"
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  auto_update_enabled: true

dns:
  magic_dns: true
  base_domain: your.domain
  override_local_dns: true
  nameservers:
    global:
      - 1.1.1.1
      - 1.0.0.1
  search_domains: []
  extra_records: []
  split_dns: []

log:
  level: info

headplane-config.yaml

server:
  host: 0.0.0.0
  port: 3000
  cookie_secure: true
  cookie_secret: "SoMeRaNdOmStRiNgflepfswgoe"   # openssl rand -base64 32

headscale:
  url: http://headscale:8080
  api_key: ""   # fill later
  config_strict: false

/etc/nginx/sites-avaiable/your.domain

server {
    listen 80;
    server_name your.domain;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name your.domain;

    ssl_certificate /etc/letsencrypt/live/your.domain/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your.domain/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
    
    location /admin {
        proxy_pass http://127.0.0.1:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }   
}

Enable configs by symlinking it to sites-enabled and generate certs with certbot. And restart nginx

sudo ln -s /etc/nginx/sites-available/your.domain /etc/nginx/sites-enabled/
sudo certbot --nginx -d your.domain
sudo nginx -t && sudo systemctl reload nginx

First launch

After all configuration you can run

docker compose up -d

And check docker compose logs for any error.

After first launch your folder should look like this

tailscale
├── docker-compose.yml
├── config.yaml
├── headplane-config.yaml
├── data
└── headplane-data

If all started correctly you can request api key for admin panel

docker exec -it headscale headscale apikeys create --expiration 365d

Copy the key and paste into headplane-config.yaml to api_key: “” Restart headplane:

docker compose restart headplane

To check open: https://your.domain/admin - headplane dashboard

In Headplane: Users → create user Pre-auth keys → create key

On client:

tailscale up --login-server=https://net.kurays.dev --authkey=your-pre-auth-key-here

Now you can fully enjoy your very own Tailnet with own coordination server.

Possibilities:

MagicDNS: your.doman (devices become e.g. hostname.your.domain) You can enter even non existed domain inside Tailnet it will work.

Subnet routes: advertise your home LAN and approve in Headplane. You can install tailscale on your OpenWRT routes and use it to pass entire homelab into tailnet and access it everywhere.

Exit node: advertise on VPS and approve the use as exit node in Headplane. You can use your home node as exit in public WiFi or even in another country.