Deployment guide

Self-Host Headscale on a VPS: Your Own Tailscale Control Server

Deploy on a VPS Cloud →

Tutorial

Security & Monitoring

Self-Host Headscale on a VPS: Your Own Tailscale Control Server

Tailscale's free tier (Personal) stops at 6 users, the Standard plan is billed per seat beyond that, and either way your network map lives in Tailscale's cloud. Headscale (BSD-3-Clause, more than 45,000 GitHub stars) is a drop-in open-source replacement for the Tailscale coordination server — deploy it on your VPS and keep using the official Tailscale clients on every device. Your team connects with zero-trust WireGuard tunnels, magic DNS and ACL-based access control, all self-hosted with no user cap. This guide covers installation (Docker or `.deb` package), the HTTPS reverse proxy, mesh verification, moving a team that already runs on Tailscale, the break-even maths and what Headscale cannot do.

19 min read21 steps
Contents· Why self-host the control server1/20
  1. 01Why self-host the control server
  2. 02What self-hosted Headscale gives you
  3. 03Requirements
  4. 04Ports to open on the VPS
  5. 05Deploy Headscale with Docker Compose
  6. 06Without Docker: the official .deb package
  7. 07Install Headscale from the .deb package
  8. 08Serve Headscale over HTTPS behind Nginx
  9. 09Pair Headscale with Authelia for MFA-protected network access
  10. 10Check that nodes can see each other
  11. 11Check the mesh from a client
  12. 12Use case: SSH between VPSs without exposing port 22
  13. 13Restrict SSH to the mesh network
  14. 14Migrate a team already on Tailscale
  15. 15Move clients over to Headscale
  16. 16Headscale vs Tailscale Cloud
  17. 17Tailscale Personal, Tailscale Standard and Headscale
  18. 18What Headscale does not replace
  19. 19Backups and upgrades
  20. 20Troubleshooting: the most common errors

Why self-host the control server

Tailscale is exceptional software — WireGuard performance, cross-platform clients, magic DNS that just works. Yet it does not carry your traffic: WireGuard packets travel directly from node to node, encrypted end to end. What runs in Tailscale's cloud is the control plane — the coordination server that exchanges public keys, lets peers discover each other, assigns 100.x.y.z addresses, publishes MagicDNS names and distributes ACLs and the DERP relay map. Without it, nodes cannot find each other. Headscale reimplements that coordination server in open-source Go, speaking the same protocol, so you can run it on your own infrastructure: your Tailscale clients only change their login URL.

Beyond the 6-user cap of the free tier, the main reason to self-host is data residency: the control plane knows which machines are on your network, their IPs, their names and your ACL policies. Running Headscale means that information stays on your VPS — not in someone else's cloud — and your network no longer depends on a third party's pricing decision or outage.

What self-hosted Headscale gives you

  • Users and devices without a cap: no limit imposed by the software, no per-seat pricing — your mesh grows with your team, bounded only by the VPS resources.
  • Official Tailscale clients everywhere: Linux, macOS, Windows, iOS, Android — no custom fork or alternative client required.
  • WireGuard peer-to-peer tunnels: Headscale only handles key exchange and ACL distribution; all actual traffic flows directly over WireGuard between peers.
  • Magic DNS: resolve every node in your mesh by hostname (laptop.mesh.yourdomain.com) without touching your public DNS.
  • HuJSON policies: the same policy file format as Tailscale, with ACLs and Grants — define which users and devices can reach which services.
  • DERP relay support: use Tailscale's public DERP map for NAT traversal, or enable Headscale's embedded DERP server to keep the relays in-house too.
  • headscale API and CLI: create users, issue pre-auth keys and manage nodes programmatically.
  • Registration without a tailscale.com account: your colleagues join the network with a key you generate, or through the OpenID Connect provider you already use (Keycloak, Authelia, Google Workspace…).
  • Subnet routers, exit nodes, Taildrop and Tailscale SSH: Tailscale's base features are supported by the server.
  • Under 50 MB of RAM at idle — runs comfortably on a 1 GB VPS alongside other workloads.

Requirements

A VPS with at least 1 vCPU and 512 MB of RAM (1 GB leaves headroom for the system) running Ubuntu 22.04 or newer, or Debian 12 or newer — the distributions targeted by the official packages. For the Docker route, install Docker and Docker Compose v2. A public IP is required; a dual-stack IPv4 + IPv6 setup is recommended.

A domain name pointing to the VPS is required: Tailscale clients connect to the control server by URL, and the project strongly recommends serving Headscale over HTTPS on port 443, since the Tailscale client assumes that port in some situations. Plan one subdomain for the server (vpn.yourdomain.com) and a separate one for MagicDNS names (mesh.yourdomain.com): Headscale refuses a MagicDNS domain equal to the server_url domain.

Ports to open on the VPS

  • TCP 443: mandatory. Clients register and receive the network map over HTTPS; it is the only port the coordination server cannot do without.
  • TCP 80: only for Let's Encrypt HTTP-01 validation and the redirect to HTTPS.
  • UDP 3478: only if you enable the embedded DERP server, which needs it for STUN. With Tailscale's public DERP map there is no need to open it.
  • TCP 8080 (Headscale), 9090 (metrics) and 50443 (gRPC): keep them on 127.0.0.1. The reverse proxy talks to 8080 locally; never expose the gRPC admin interface unless you need it.

Deploy Headscale with Docker Compose

  1. Write the Compose file

    The stack is a single service: the official docker.io/headscale/headscale:0.29.7 image, the latest stable release as of 7 November 2026, started with the serve command. Headscale keeps everything — keys, database, node records — in two host directories: the configuration, mounted read-only at /etc/headscale, and the data, at /var/lib/headscale. No external database or cache service is needed; SQLite is built in.

    mkdir -p /opt/headscale/config /opt/headscale/lib
    cat > /opt/headscale/compose.yaml <<'EOF'
    services:
      headscale:
        image: docker.io/headscale/headscale:0.29.7
        container_name: headscale
        restart: unless-stopped
        read_only: true
        tmpfs:
          - /var/run/headscale
        ports:
          - "127.0.0.1:8080:8080"
        volumes:
          - /opt/headscale/config:/etc/headscale:ro
          - /opt/headscale/lib:/var/lib/headscale
        command: serve
        healthcheck:
          test: ["CMD", "headscale", "health"]
    EOF

    The port is only published on 127.0.0.1: the reverse proxy is what faces the internet.

  2. Generate the configuration

    Start from the example file of the exact version you deploy, then change only the keys below. server_url is the HTTPS URL your Tailscale clients will connect to; listen_addr must listen on 0.0.0.0 inside the container. The database is declared under database (type: sqlite). Only the noise key path remains to set: Headscale generates it on first start if the file does not exist.

    curl -Lo /opt/headscale/config/config.yaml \
      https://raw.githubusercontent.com/juanfont/headscale/v0.29.7/config-example.yaml
    server_url: https://vpn.yourdomain.com
    listen_addr: 0.0.0.0:8080
    noise:
      private_key_path: /var/lib/headscale/noise_private.key
    database:
      type: sqlite
      sqlite:
        path: /var/lib/headscale/db.sqlite
    dns:
      magic_dns: true
      base_domain: mesh.yourdomain.com

    On ServOrbit, the provisioning job writes config.yaml from your domain automatically.

  3. Start the container and verify

    Run docker compose up -d from /opt/headscale. Headscale generates its noise key, then starts listening on port 8080. Check the health endpoint: curl -s http://127.0.0.1:8080/health should return {"status":"pass"}. The image is built on a distroless base with no shell, but the headscale CLI can be called directly: docker exec headscale headscale version confirms the running version, and docker logs --follow headscale shows the logs.

  4. Create a user and generate an auth key

    Headscale organizes devices into 'users' (formerly called namespaces). Create one, then note its numeric identifier: the --user option of preauthkeys create expects that ID, not the name.

    docker exec headscale headscale users create myteam
    docker exec headscale headscale users list
    docker exec headscale headscale preauthkeys create --user 1 --reusable --expiration 24h

    Without --reusable the key works only once, and without --expiration it expires after one hour. Copy the printed key — you will use it when registering each device.

  5. Register a device

    On any device with the official Tailscale client, point it at your Headscale instance: tailscale up --login-server https://vpn.yourdomain.com --authkey <your-preauth-key>. The device appears in Headscale's node list within seconds: docker exec headscale headscale nodes list. Without a key, the client opens a page showing an authentication ID; approve it on the server with docker exec headscale headscale auth register --user myteam --auth-id <AUTH_ID>. Once registered, devices reach each other by their mesh IP (100.64.x.y) or by magic DNS hostname.

  6. Define access control policies

    As long as no policy is loaded, Headscale lets all traffic flow between nodes — fine for a small trusted team, not once developer laptops, contractors and production servers share the network. Create /opt/headscale/config/policy.hujson and declare it in config.yaml with policy.path: /etc/headscale/policy.hujson. Headscale uses the same HuJSON format as Tailscale: define user groups, tag devices as servers or workstations, and write rules stating which groups can reach which ports. The project recommends Grants over ACLs, which Tailscale considers legacy. Validate the file with headscale policy check --file <file>, then reload: the policy is only re-read on reload (docker kill --signal=HUP headscale, or systemctl reload headscale on a native install). Keep the file in a private Git repository to retain a history of access changes.

  7. Logging in for the first time

    Headscale has no web interface and no account: it is the control server of your private network, driven from the command line. Over SSH: docker exec -it headscale headscale users create monuser, then the key with preauthkeys create --user <ID> --reusable --expiration 24h, and connect your machines with tailscale up --login-server https://<your-domain> --authkey <key>. The graphical apps can target a custom server too: on macOS, Option + click the Tailscale icon, open the Debug menu, then Custom Login Server; on iOS, Use custom coordination server in the login menu; on Android, Use an alternate server; on Windows, tailscale login --login-server in PowerShell.

Without Docker: the official .deb package

The project publishes .deb packages for Debian and Ubuntu and recommends them on those distributions: they create the headscale system user, ship a default configuration and install the systemd unit. It is the simplest route if Headscale runs alone on its VPS or if you prefer a native service to a container. The management commands are the same as above, without the docker exec headscale prefix.

Install Headscale from the .deb package

  1. Download and install the package

    Fetch the package for the version you want from the GitHub releases page, for your architecture (amd64 or arm64), and install it with apt, which resolves dependencies:

    HEADSCALE_VERSION="0.29.7"
    HEADSCALE_ARCH="amd64"
    wget --output-document=headscale.deb \
      "https://github.com/juanfont/headscale/releases/download/v${HEADSCALE_VERSION}/headscale_${HEADSCALE_VERSION}_linux_${HEADSCALE_ARCH}.deb"
    sudo apt install ./headscale.deb

    Then check: headscale version should print 0.29.7.

  2. Adapt the configuration

    The package puts the configuration in /etc/headscale/config.yaml and an up-to-date example in /usr/share/doc/headscale/examples/config-example.yaml. Change the same keys as for Docker (server_url, database, dns.base_domain), but keep listen_addr: 127.0.0.1:8080: the service runs on the host, and only the reverse proxy should reach it. Data lives in /var/lib/headscale.

  3. Restart and check the service

    Restart the service so it re-reads the configuration, then check its state and logs:

    sudo systemctl restart headscale
    sudo systemctl status headscale
    sudo journalctl -u headscale -f

    The output should show Active: active (running). On first start, the most common errors are a server_url without the https:// scheme and a MagicDNS domain identical to the server's.

  4. Use the CLI without being root

    The CLI talks to the service through the Unix socket /var/run/headscale/headscale.sock, which by default only the headscale user and root can access. Use sudo, run the commands as the headscale user, or add your account to the headscale group. Check access with headscale users list.

Serve Headscale over HTTPS behind Nginx

Tailscale clients talk to Headscale with their own protocol, carried over a WebSocket connection opened with POST, whose Upgrade header is tailscale-control-protocol. The reverse proxy must therefore pass the Upgrade and Connection headers through unchanged and must not buffer responses. The configuration below follows the example in the project documentation; get the certificate first with certbot (certbot certonly --nginx -d vpn.yourdomain.com).

map $http_upgrade $connection_upgrade {
  default keep-alive;
  ''      close;
}

server {
  listen 80;
  server_name vpn.yourdomain.com;
  location = /generate_204 { return 204; }
  location / { return 301 https://$server_name$request_uri; }
}

server {
  listen 443 ssl;
  http2 on;
  server_name vpn.yourdomain.com;
  ssl_certificate     /etc/letsencrypt/live/vpn.yourdomain.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/vpn.yourdomain.com/privkey.pem;

  location / {
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    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_buffering off;
    proxy_pass http://127.0.0.1:8080;
  }
}

Leave tls_cert_path and tls_key_path empty in config.yaml: Nginx terminates TLS, so the listening without TLS warning at startup is expected. For Headscale's logs to show clients' real IPs, declare the proxy as a trusted source. On a native install the loopback is enough; with Docker, also add the Docker bridge network (docker network inspect), because the container sees the proxy arriving from its gateway:

trusted_proxies:
  - 127.0.0.1/32
  - ::1/128

Caddy works as well, with a plain reverse_proxy 127.0.0.1:8080. The Cloudflare proxy and Cloudflare Tunnel, however, are not supported, since they do not handle WebSocket POSTs: the server's DNS record must stay DNS-only.

Pair Headscale with Authelia for MFA-protected network access

Headscale handles network-layer access control (which IPs can talk to which peers), but it does not enforce application-layer authentication. Pair it with Authelia on the same VPS: route your internal services (reachable only through the mesh) through Authelia's forward-auth layer. Users must complete MFA to access services even once they are on the mesh — defense in depth for sensitive workloads.

Check that nodes can see each other

A node listed by headscale nodes list only proves it reached the coordination server. Before routing real traffic through the mesh, check the end-to-end path from the clients themselves.

Check the mesh from a client

  1. Read the network state

    On a registered node, run tailscale status. It lists peers with their mesh IP, name and link type: direct means the WireGuard tunnel is established with no intermediary, relay means it goes through a DERP relay — working, but with higher latency.

  2. Test connectivity

    Take a peer's IP from tailscale status, then ping 100.64.0.2, or better tailscale ping <node-name>, which tells you whether the reply arrives directly or through a relay. If the peer is listed but does not answer, tailscale netcheck measures latency to each DERP region and reports filtered outbound UDP.

  3. Test MagicDNS resolution

    With dns.magic_dns: true, every node answers to its short and full names: ping node-name or ping node-name.mesh.yourdomain.com. No public DNS record is needed for these internal names. If resolution fails, tailscale dns status shows the DNS configuration the client received from the server.

Use case: SSH between VPSs without exposing port 22

Once your development and production VPSs are registered on the same mesh, SSH no longer needs to listen on the public interface: the connection uses the mesh address, inside the WireGuard tunnel. Keep console access open at your provider before touching the firewall, in case a rule locks you out.

Restrict SSH to the mesh network

  1. Find the mesh interface and address

    On each VPS, the Tailscale client creates the tailscale0 interface. tailscale ip -4 prints the machine's mesh address; note the production server's, for example 100.64.0.3.

  2. Allow SSH only through tailscale0

    On the production VPS, allow SSH on the mesh interface before denying it elsewhere — UFW applies the first matching rule:

    ufw allow in on tailscale0 to any port 22 proto tcp
    ufw deny 22/tcp
    ufw enable
    ufw status verbose

    Port 22 is no longer reachable from the internet, but stays open to every mesh node your policy allows.

  3. Connect through the mesh

    From the development VPS or your registered workstation, connect by IP or MagicDNS name: ssh [email protected] or ssh [email protected]. Everything travels inside the encrypted tunnel; then restrict in the Headscale policy which groups can reach this server's port 22.

Migrate a team already on Tailscale

The switch requires no reinstallation: the official client accepts the --login-server option, so you only log it out of the old network and back into your server. Each machine drops for a few seconds during the operation. Because your server assigns 100.x addresses and MagicDNS names itself, plan to update the scripts, firewall rules and configuration files that reference the old ones.

Move clients over to Headscale

  1. Carry over the access policy

    Copy your tailnet's policy file from the Access controls editor in the Tailscale console and start from it: the HuJSON format is the same. Check it with headscale policy check before the switch — some features, such as IP sets, are not supported, and only a subset of autogroups exists.

  2. Log each client out, then back in

    On Linux or from the command line, chain the logout and the reconnection with a pre-auth key:

    tailscale logout
    tailscale up --login-server https://vpn.yourdomain.com --authkey <PREAUTHKEY>

    On macOS, iOS, Android and Windows, log the account out in the app, then add the custom server as described above.

  3. Start with test machines

    Switch a test workstation and a test server first, check tailscale status and name resolution, then the development machines and, last, production. While some machines remain on the other side, the two networks cannot see each other: plan to move a service and its clients in the same window.

  4. Close or downgrade the Tailscale account

    Once all nodes are migrated and verified, close the tailnet or move back to the Personal plan if you keep a personal use. Generate a fresh pre-auth key for future registrations rather than keeping the migration key forever.

Headscale vs Tailscale Cloud

Tailscale Cloud remains the easier path for small teams: no server to maintain, automatic upgrades, integrated billing — and the Personal plan stays free up to 6 users. Beyond that, the Standard plan is listed at $8 per user per month on the pricing grid in force since 8 April 2026: $40 a month for five people, $80 for ten. Headscale costs the price of the VPS whatever the headcount — on ServOrbit, the VPS Solo (2 vCPU / 4 GB) at 99 DH/month is enough for dozens of nodes — plus operating time: upgrades, backups, monitoring.

Headscale therefore makes sense beyond 6 users, when data residency requires keeping your network topology off third-party servers, or when you want full auditability of your coordination logs. The clients are identical — moving from Tailscale Cloud to Headscale means re-running tailscale up --login-server on each device.

Tailscale Personal, Tailscale Standard and Headscale

Scroll the table

CriterionTailscale PersonalTailscale StandardHeadscale on a VPS
PriceFree$8 per user per monthThe cost of the VPS
UsersUp to 6No capNo software limit
User devicesNo capNo capNo software limit
Coordination serverTailscale cloudTailscale cloudYour VPS, under your control
ClientOfficial Tailscale clientOfficial Tailscale clientOfficial Tailscale client (`--login-server`)
MagicDNS, ACLs, GrantsYesYesYes, on your own domain
Funnel and ServeOffered by TailscaleOffered by TailscaleNot supported
MaintenanceNone (managed service)None (managed service)Upgrades, backups, monitoring

What Headscale does not replace

Headscale implements the coordination server, not the whole commercial platform. Its own documentation lists what is missing: Tailscale Funnel (publishing a local service to the internet), Tailscale Serve (sharing it in the tailnet through a local proxy) and network flow logs are not supported; OIDC groups cannot be used in ACLs. There is no official web console either: everything goes through the CLI or the API. If your use is limited to mesh connectivity, MagicDNS, access policies, subnet routers and exit nodes, Headscale covers the need; if you actively use Funnel or Serve, weigh those features against the savings before migrating — or look at NetBird, which self-hosts its admin interface too.

Backups and upgrades

  • Back up before every upgrade: Headscale applies its database migrations when the new version starts. Stop the service, copy the configuration and data, then start it again: cd /opt/headscale && docker compose stop, cp -aR /opt/headscale /opt/headscale.backup-$(date +%Y%m%d%H%M%S), docker compose up -d.
  • The data directory holds db.sqlite and noise_private.key: losing that key changes the server's identity and forces every node to re-register. Copy it off the VPS as well, for example to object storage.
  • Upgrade one version at a time: the project requires going through each stable minor version (for example 0.27, then 0.28, then 0.29), always at its latest patch, without skipping any.
  • Read the release notes on GitHub before each upgrade: they flag breaking configuration changes. Then compare your config.yaml with the new version's example file.

Troubleshooting: the most common errors

  • The client cannot register: check that https://vpn.yourdomain.com/health answers from outside, that server_url exactly matches the certificate's domain, and that the proxy passes the Upgrade and Connection headers.
  • Connections in relay instead of direct: a strict NAT or a firewall blocks UDP between the two nodes. Run tailscale netcheck on both sides to see what is filtered; traffic still flows, through the DERP relays.
  • Node listed as offline: the client reached the server but does not keep the session. Check systemctl status tailscaled and journalctl -u tailscaled on the node; a local firewall cutting outbound connections is the most common cause.
  • Headscale refuses to start after an upgrade: a configuration key was renamed or removed. The log names it; refer to the example file of the installed version.
  • MagicDNS does not resolve: check that dns.magic_dns is true and that dns.base_domain differs from the server_url domain, then reconnect the client so it fetches the new DNS configuration.

Deploy Headscale on your VPS in one click

Self-host your Tailscale control server with unlimited users and no cloud dependency. ServOrbit provisions a ready-to-use Headscale instance — config generated, HTTPS ready, first auth key printed in the job output.