Sovereign VPN: Setting up your own server with Headscale in Switzerland

Sovereign VPN: Setting up your own server with Headscale in Switzerland

·14 min read·Updated on March 5, 2026

Why self-host your VPN

The first part covered the quick ways to bypass censorship: alternative DNS, commercial VPN, Tor. They work, but they all depend on a third party — and commercial VPN IPs are known and listed, so they can be blocked one by one.

The sovereign solution: your own VPN server, in a country whose jurisdiction you choose. Blocking it means knowing your specific IP, which is much harder than blocking Proton's or NordVPN's address ranges.

Why Headscale and not raw WireGuard

WireGuard is the most performant VPN protocol that exists, but configuring it by hand on every device (keys, peers) gets tedious beyond two machines. Tailscale solves that with a coordination layer (automatic keys, NAT traversal, peer discovery) — at the cost of metadata flowing through their cloud.

Headscale is an open-source, self-hosted implementation of that coordination server. Devices use the official Tailscale client; only the server changes.

Raw WireGuardTailscaleHeadscale
ProtocolWireGuardWireGuardWireGuard
Key managementManualAutomaticAutomatic
NAT traversalNoYes (STUN/DERP)Yes (STUN/DERP)
Coordination serverNoneTailscale cloudSelf-hosted
CostFreeFree (3 users) / paidFree
SovereigntyTotalLimitedTotal

Why Switzerland

Outside the EU, outside the 14 Eyes, data protection legislation (nLPD) among the strictest in the world, and ~10ms latency from France or Germany. For hosting, Infomaniak: datacenters in Geneva, 100% renewable energy, subject exclusively to Swiss law. A Cloud VPS at ~5€/month is plenty.

Prerequisites

  • A VPS running Debian 12 or 13 (1 vCPU, 1 GB RAM is sufficient)
  • A domain name (we'll use hs.example.io for Headscale)
  • SSH access to the VPS
  • Ports 80, 443 (TCP) and 3478 (UDP) open in the VPS firewall
  • nginx (installed in step 4bis for SNI routing on port 443)

Step 1: install Headscale

# Add the Headscale repository
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.headscale.net/stable/debian/pubkey.asc | sudo gpg --dearmor -o /etc/apt/keyrings/headscale.gpg
echo "deb [signed-by=/etc/apt/keyrings/headscale.gpg] https://pkgs.headscale.net/stable/debian bookworm main" | sudo tee /etc/apt/sources.list.d/headscale.list

# Install
sudo apt update
sudo apt install -y headscale

headscale version
# headscale version v0.28.0

Step 2: DNS

An A record pointing to the VPS IP:

hs.example.ioA203.0.113.x

Headscale uses it for its coordination API, its Let's Encrypt certificate and its built-in DERP relay server.

Step 3: configure Headscale

The main file is /etc/headscale/config.yaml:

# Public server URL — what clients use to connect
# Clients connect through nginx on port 443
server_url: https://hs.example.io:443
# Headscale listens locally — nginx routes traffic to it via SNI
listen_addr: 127.0.0.1:4443
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false

# Noise private key (generated automatically on first launch)
noise:
  private_key_path: /var/lib/headscale/noise_private.key

# Tailscale network address ranges
prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
  allocation: sequential

# Built-in DERP server — relay for connections
# that cannot establish a direct link
derp:
  server:
    enabled: true
    region_id: 999
    region_code: 'myrelay'
    region_name: 'My DERP CH'
    verify_clients: true
    stun_listen_addr: '0.0.0.0:3478'
    private_key_path: /var/lib/headscale/derp_server_private.key
    automatically_add_embedded_derp_region: true
    # IMPORTANT: put your VPS public IP here
    ipv4: 203.0.113.x
  urls:
    # Keep Tailscale's public DERPs as fallback
    - https://controlplane.tailscale.com/derpmap/default
  paths: []
  auto_update_enabled: true
  update_frequency: 3h

disable_check_updates: true
ephemeral_node_inactivity_timeout: 30m

# Local SQLite database
database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite
    write_ahead_log: true

# Automatic TLS certificate via Let's Encrypt
acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: contact@example.io
tls_letsencrypt_hostname: hs.example.io
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ':http'

log:
  level: info
  format: text

# Access control policy (ACL)
policy:
  mode: file
  path: /etc/headscale/acl.json

# Internal DNS of the Tailscale network
dns:
  magic_dns: true
  base_domain: tail.example.io
  override_local_dns: true
  nameservers:
    global:
      - 1.1.1.1
      - 1.0.0.1

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

# No telemetry sent to Tailscale
logtail:
  enabled: false

randomize_client_port: false

Key points:

  • listen_addr: 127.0.0.1:4443: Headscale listens locally only. nginx exposes 443 and routes via SNI, which lets the port be shared with Xray.
  • tls_letsencrypt_challenge_type: HTTP-01: the challenge uses port 80, so Headscale listens on :http for it.
  • derp.server.ipv4: your VPS's real public IP. Without it, clients won't find the DERP relay.
  • logtail.enabled: false: nothing is sent to Tailscale's servers.

Step 4: configure ACLs

In /etc/headscale/acl.json:

{
  "tagOwners": {
    "tag:exit": ["my-user@"]
  },
  "autoApprovers": {
    "exitNode": ["tag:exit"]
  },
  "acls": [
    {
      "action": "accept",
      "src": ["*"],
      "dst": ["*:*"]
    }
  ]
}

autoApprovers.exitNode lets nodes tagged tag:exit advertise themselves as exit nodes automatically, instead of requiring manual approval for each.

Step 4bis: nginx SNI routing on port 443

To make Headscale and Xray share port 443, use nginx's stream module to route TCP based on SNI (the domain name sent in the clear in the TLS handshake).

sudo apt install -y nginx libnginx-mod-stream
sudo tee /etc/nginx/modules-enabled/90-stream-sni.conf << 'EOF'
stream {
    map $ssl_preread_server_name $backend {
        hs.example.io   headscale;
        default         xray;
    }

    upstream headscale {
        server 127.0.0.1:4443;
    }

    upstream xray {
        server 127.0.0.1:8443;
    }

    server {
        listen 443;
        listen [::]:443;
        proxy_pass $backend;
        ssl_preread on;
        proxy_protocol off;
    }
}
EOF

sudo nginx -t && sudo systemctl restart nginx

Important: nginx performs no TLS decryption. It's a pure TCP proxy — Headscale handles its own TLS (Let's Encrypt), Xray handles its own (Reality). nginx only reads the plaintext SNI in the ClientHello.

Step 5: enable IP forwarding

So the VPS can route other devices' traffic (act as exit node):

cat << EOF | sudo tee /etc/sysctl.d/99-tailscale.conf
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF

sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

Step 6: create a user and start

sudo systemctl enable --now headscale
sudo headscale users create my-user
sudo systemctl status headscale

On first launch, Headscale generates its keys and obtains the Let's Encrypt certificate. Check that https://hs.example.io responds.

Step 7: connect the VPS as an exit node

# Install the Tailscale client
curl -fsSL https://tailscale.com/install.sh | sh

# Find your user's numeric ID
sudo headscale users list
# → ID: 1, Name: my-user

# Generate an auth key with the exit tag
# The --user flag takes the numeric ID (not the name)
sudo headscale preauthkeys create \
  --user 1 \
  --reusable \
  --expiration 24h \
  --tags tag:exit

# Connect the VPS to its own Headscale
sudo tailscale up \
  --login-server https://hs.example.io:443 \
  --authkey MY_PREAUTHKEY \
  --hostname vps-ch \
  --advertise-exit-node

sudo tailscale status

The VPS is now simultaneously the Headscale coordination server, a node on the network, and an exit node.

Step 8: connect your devices

Linux

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server https://hs.example.io:443

# Tailscale prints a registration URL — register the node server-side:
# --user takes the numeric ID (not the name)
sudo headscale nodes register --key nodekey:abc123... --user 1

Windows

Download Tailscale from tailscale.com/download/windows, then in an administrator terminal:

tailscale logout   # disconnect from Tailscale cloud if needed
tailscale up --login-server https://hs.example.io:443

Copy the printed URL and register the node server-side.

Android

Install Tailscale from the Play Store, menu ⋮ → Use an alternate serverhttps://hs.example.io:443. The app opens a registration URL, to be validated server-side with headscale nodes register.

Verification

sudo headscale nodes list

All devices should appear with their Tailscale IP (100.64.0.x) and online status.

Step 9: route everything through Switzerland

# Linux
sudo tailscale set --exit-node=vps-ch --exit-node-allow-lan-access

# Windows (PowerShell) — or via the GUI: menu → Exit Node → vps-ch
tailscale set --exit-node=vps-ch --exit-node-allow-lan-access

On Android: menu ⋮ → Use exit nodevps-ch.

--exit-node-allow-lan-access keeps local network access (printer, NAS) while internet traffic goes through the VPS. Use tailscale set rather than tailscale up to change one parameter without resetting the others (such as --login-server).

Verify from each device: curl -s https://ipinfo.io must return the Swiss VPS IP.

Daily use: all traffic is WireGuard-encrypted, transits the Swiss VPS and exits with the Swiss IP. The local ISP only sees a WireGuard connection to a single IP.

Anti-DPI option: VLESS+Reality (Xray)

WireGuard has one flaw: its traffic is identifiable by Deep Packet Inspection (handshake, packet sizes, UDP format). In Russia, China and Iran, raw WireGuard is blocked — Headscale/Tailscale won't work there.

The fix: a VLESS+Reality proxy (Xray) on the same VPS. Unlike obfuscated VPNs that mask traffic, VLESS+Reality makes it look like a legitimate HTTPS connection to a real website (e.g. www.microsoft.com). DPI can't block it without blocking the imitated site.

Install and configure Xray on the VPS

sudo bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install

# Reality keys
xray x25519
# → Private key: eE6MDfDF1JliKiDijcPojrOJB4-GsA_ux7InREW7hEg
# → Public key:  _kP9S_vKqSksfj9MXNn0pULtphbzRVuNq5DYYafYpz8

xray uuid
# → 8672f031-7aaa-4a63-8835-6cd7f58ea703

openssl rand -hex 8   # short ID
# → e318e30924f77899

In /usr/local/etc/xray/config.json:

{
  "log": {
    "loglevel": "warning",
    "access": "/var/log/xray/access.log",
    "error": "/var/log/xray/error.log"
  },
  "inbounds": [
    {
      "listen": "127.0.0.1",
      "port": 8443,
      "protocol": "vless",
      "settings": {
        "clients": [
          {
            "id": "8672f031-7aaa-4a63-8835-6cd7f58ea703",
            "flow": "xtls-rprx-vision"
          }
        ],
        "decryption": "none"
      },
      "streamSettings": {
        "network": "tcp",
        "security": "reality",
        "realitySettings": {
          "dest": "www.microsoft.com:443",
          "serverNames": ["www.microsoft.com", "microsoft.com"],
          "privateKey": "eE6MDfDF1JliKiDijcPojrOJB4-GsA_ux7InREW7hEg",
          "shortIds": ["e318e30924f77899"]
        }
      },
      "sniffing": {
        "enabled": true,
        "destOverride": ["http", "tls", "quic"]
      }
    }
  ],
  "outbounds": [
    { "protocol": "freedom", "tag": "direct" },
    { "protocol": "blackhole", "tag": "block" }
  ]
}

Key points:

  • listen: 127.0.0.1, port: 8443: Xray listens locally, nginx routes 443 to it.
  • dest: www.microsoft.com:443: the imitated site. A non-VLESS client that connects is redirected to the real microsoft.com — the server is indistinguishable from a legitimate proxy.
  • flow: xtls-rprx-vision: XTLS Vision mode, the fastest.
sudo mkdir -p /var/log/xray
sudo systemctl enable --now xray

Thanks to the SNI routing from step 4bis, port 443 dispatches:

Internetnginx (:443)
  ├── SNI: hs.example.ioHeadscale (:4443)Tailscale clients
  └── SNI: * (default)Xray (:8443)VLESS+Reality clients

To an observer, all traffic on 443 looks like normal HTTPS.

Configuring clients

The simplest path is a VLESS link, importable into v2rayN (Windows), v2rayNG (Android) and Streisand (iOS) — via clipboard import or QR scan:

vless://UUID@VPS_IP:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.microsoft.com&fp=chrome&pbk=PUBLIC_KEY&sid=SHORT_ID&type=tcp#My-VPS-Reality

On Linux, install Xray as a client with /usr/local/etc/xray/config.json:

{
  "log": { "loglevel": "warning" },
  "inbounds": [
    {
      "listen": "127.0.0.1",
      "port": 10818,
      "protocol": "socks",
      "settings": { "udp": true },
      "tag": "socks-in"
    },
    {
      "listen": "127.0.0.1",
      "port": 10819,
      "protocol": "http",
      "tag": "http-in"
    }
  ],
  "outbounds": [
    {
      "protocol": "vless",
      "settings": {
        "vnext": [
          {
            "address": "203.0.113.42",
            "port": 443,
            "users": [
              {
                "id": "8672f031-7aaa-4a63-8835-6cd7f58ea703",
                "flow": "xtls-rprx-vision",
                "encryption": "none"
              }
            ]
          }
        ]
      },
      "streamSettings": {
        "network": "tcp",
        "security": "reality",
        "realitySettings": {
          "serverName": "www.microsoft.com",
          "publicKey": "_kP9S_vKqSksfj9MXNn0pULtphbzRVuNq5DYYafYpz8",
          "shortId": "e318e30924f77899",
          "fingerprint": "chrome"
        }
      },
      "tag": "vless-out"
    },
    { "protocol": "freedom", "tag": "direct" }
  ]
}

The client exposes a local SOCKS5 proxy (127.0.0.1:10818) and HTTP proxy (127.0.0.1:10819):

curl -x socks5h://127.0.0.1:10818 https://ifconfig.me
# → should show your VPS IP

Conflict with Tailscale: don't run v2rayN and Tailscale (exit node) at the same time — both want to route all traffic and create a loop. Tailscale for daily use, v2rayN only in censored countries, and not on autostart.

When to use what

SituationSolution
Daily use (France, Germany, etc.)Tailscale via Headscale — VPS exit node, mesh net
Travel to Russia, China, IranVLESS+Reality via v2rayN/v2rayNG — anti-DPI
Both availableTailscale by default, VLESS as backup

Tailscale is faster (native WireGuard, direct peer connections). VLESS+Reality is the anti-censorship weapon, to enable only when Tailscale is blocked.

Checking for leaks

A misconfigured VPN can leak your real IP. Never trust a single service — cross-check at least three independent sources:

echo "=== ipinfo.io ==="
curl -s https://ipinfo.io/json | jq '{ip, city, country, org}'

echo "=== ipleak.net ==="   # tests IPv6 first
curl -s https://ipleak.net/json/ | jq '{ip, country_name, isp_name}'

echo "=== mullvad ==="
curl -s https://am.i.mullvad.net/json | jq '{ip, country, organization}'

All three must return the same IP — the Swiss VPS one. Diagnosis based on what shows up:

  • Your local ISP over IPv4 → the exit node isn't active (tailscale set --exit-node=vps-ch).
  • An IPv6 address from your ISP (typically on ipleak.net) → IPv6 leak, the most common and most discreet one. IPv6 traffic bypasses the tunnel.
  • Your ISP's DNS resolver → force dns.override_local_dns: true in the Headscale config (MagicDNS normally handles it).

Fixing an IPv6 leak:

# Identify the main network interface
ip route show default
# → default via 192.168.x.x dev eth0 ...

cat << EOF | sudo tee /etc/sysctl.d/99-disable-ipv6.conf
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.eth0.disable_ipv6 = 1
EOF
sudo sysctl -p /etc/sysctl.d/99-disable-ipv6.conf

On the VLESS+Reality side, check Xray is running and the tunnel exits through the VPS:

sudo systemctl status xray
sudo tail -f /var/log/xray/access.log       # successful connections show up here

curl -x socks5h://127.0.0.1:10818 https://ifconfig.me   # from a client

To confirm the traffic looks like normal HTTPS, a capture shows plain TLS 1.3 — no WireGuard handshake, no UDP signature:

sudo tcpdump -i eth0 -c 20 host 203.0.113.42 and port 443 -w /tmp/capture.pcap
tcpdump -r /tmp/capture.pcap -v | head -30

Hardening

Firewall restricted to the strict minimum:

sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp    # SSH
sudo ufw allow 80/tcp    # Let's Encrypt challenge
sudo ufw allow 443/tcp   # nginx → Headscale + Xray (SNI routing)
sudo ufw allow 3478/udp  # STUN (NAT traversal)
sudo ufw allow 41641/udp # WireGuard direct connections
sudo ufw enable

SSH by keys only, in /etc/ssh/sshd_config:

PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin no

And automatic updates: sudo apt install -y unattended-upgrades && sudo dpkg-reconfigure -plow unattended-upgrades.

Commercial VPN vs self-hosted: the verdict

ComponentMonthly cost
Infomaniak VPS (1 vCPU, 1 GB)~5€
Domain name~1€/month (amortised)
Headscale / Tailscale / LEFree
Total~6€/month

The price of a commercial VPN subscription, but with control over the server, the logs, the jurisdiction and the access rules.

CriterionCommercial VPNSelf-hosted Headscale + Xray
Setup2 minutes2-3 hours
MaintenanceNoneUpdates on you
Number of serversDozens of countries1 (your VPS)
TrustYou trust the providerYou trust yourself
Blocking resistanceWeak (known IPs)Strong (single IP + VLESS+Reality)
SovereigntyNoneTotal
Cost5-10€/month~6€/month
Multi-devicePlan-limitedUnlimited
Anti-censorship (DPI)Variable (Stealth, etc.)VLESS+Reality — proven in Russia/China

Self-hosting isn't for everyone. To just unblock YouTube while travelling, a commercial VPN is enough. But for network infrastructure nobody can cut off, which resists even the most aggressive DPI, Headscale + Xray on a Swiss VPS is the answer.


This article is part of a two-part series. The first part covers immediate solutions (DNS, commercial VPN, Tor, VLESS+Reality). This second part covers the sovereign approach with Headscale and Xray.

The techniques presented here aim to preserve access to information, a fundamental right recognised by Article 19 of the Universal Declaration of Human Rights. Use them responsibly and with awareness of local laws.

ShareLinkedInXBluesky

Related articles