
Sovereign VPN: Setting up your own server with Headscale in Switzerland
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 WireGuard | Tailscale | Headscale | |
|---|---|---|---|
| Protocol | WireGuard | WireGuard | WireGuard |
| Key management | Manual | Automatic | Automatic |
| NAT traversal | No | Yes (STUN/DERP) | Yes (STUN/DERP) |
| Coordination server | None | Tailscale cloud | Self-hosted |
| Cost | Free | Free (3 users) / paid | Free |
| Sovereignty | Total | Limited | Total |
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.iofor 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.io → A → 203.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:httpfor 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 server → https://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 node → vps-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:
Internet → nginx (:443)
├── SNI: hs.example.io → Headscale (: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
| Situation | Solution |
|---|---|
| Daily use (France, Germany, etc.) | Tailscale via Headscale — VPS exit node, mesh net |
| Travel to Russia, China, Iran | VLESS+Reality via v2rayN/v2rayNG — anti-DPI |
| Both available | Tailscale 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: truein 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
| Component | Monthly cost |
|---|---|
| Infomaniak VPS (1 vCPU, 1 GB) | ~5€ |
| Domain name | ~1€/month (amortised) |
| Headscale / Tailscale / LE | Free |
| Total | ~6€/month |
The price of a commercial VPN subscription, but with control over the server, the logs, the jurisdiction and the access rules.
| Criterion | Commercial VPN | Self-hosted Headscale + Xray |
|---|---|---|
| Setup | 2 minutes | 2-3 hours |
| Maintenance | None | Updates on you |
| Number of servers | Dozens of countries | 1 (your VPS) |
| Trust | You trust the provider | You trust yourself |
| Blocking resistance | Weak (known IPs) | Strong (single IP + VLESS+Reality) |
| Sovereignty | None | Total |
| Cost | 5-10€/month | ~6€/month |
| Multi-device | Plan-limited | Unlimited |
| 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.
Related articles