Sovereign VPN: Setting up your own server with Headscale in Switzerland
- Published on
- ·20 min read
Why self-host your VPN
In the first part, we saw quick solutions to bypass censorship: changing DNS, using a commercial VPN, Tor. They work, but they all have the same flaw: you depend on a third party.
Proton VPN can decide to change its terms. Cloudflare can cut WARP in certain regions. The IPs of commercial VPNs are known and listed — a motivated government can block them one by one.
The sovereign solution: your own VPN server, on a machine you control, in a country whose jurisdiction you choose. No one can cut your access without cutting the connection to your server specifically. And for that, they need to know your IP — which is much more complicated than blocking Proton or NordVPN's address ranges.
Why Headscale and not raw WireGuard
WireGuard is the most performant and simplest VPN protocol that exists. But configuring it manually on each device (key generation, public key exchange, peer configuration) quickly becomes tedious when you have more than two machines.
Tailscale solves this problem by adding a coordination layer on top of WireGuard: automatic key management, NAT traversal, peer discovery. But Tailscale is a cloud service — your connection metadata passes through their servers.
Headscale is an open-source, self-hosted implementation of Tailscale's coordination server. You keep everything that makes Tailscale powerful (ease, WireGuard, NAT traversal) but the coordination server runs on your machine. Your 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
Switzerland is not in the EU. It's not a member of the 14 Eyes. Its data protection legislation (nLPD) is among the strictest in the world. And geographically, it's around 10ms latency from France or Germany — imperceptible in daily use.
For hosting, I chose Infomaniak: Swiss host, datacenters in Geneva powered 100% by renewable energy, subject exclusively to Swiss law. A Cloud VPS at ~5€/month is more than enough.
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 3bis for SNI routing on port 443)
Step 1: Install Headscale
We'll install the latest stable version from the official repository:
# Add 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
Verify the installation:
headscale version
# headscale version v0.28.0
Step 2: DNS
Create a DNS A record pointing to your VPS IP:
hs.example.io → A → 203.0.113.x
Headscale will use this domain for:
- Its coordination API (Tailscale clients connect to it)
- The TLS certificate (Let's Encrypt)
- The integrated DERP relay server
Step 3: Configure Headscale
The main configuration file is /etc/headscale/config.yaml. Here's the complete commented config:
# Public URL of the server — what clients use to connect
# Clients connect via 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 (automatically generated on first run)
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
# Integrated 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 DERP servers 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 policy (ACL)
policy:
mode: file
path: /etc/headscale/acl.json
# Internal DNS for 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'
# Do not send telemetry to Tailscale
logtail:
enabled: false
randomize_client_port: false
Important points
listen_addr: 127.0.0.1:4443: Headscale listens locally only. nginx exposes port 443 and routes traffic to Headscale via SNI (see step 3bis). This allows sharing port 443 with other services (Xray).server_url: https://hs.example.io:443: clients still connect on the standard port 443 — nginx dispatches.tls_letsencrypt_challenge_type: HTTP-01: the Let's Encrypt challenge uses port 80. Headscale listens on:httpfor that.derp.server.ipv4: put your VPS's actual public IP. Without it, clients won't find the DERP relay.logtail.enabled: false: we don't want to send anything to Tailscale's servers.
Step 4: Configure ACLs
ACLs (Access Control Lists) define who can talk to whom in the network. Create /etc/headscale/acl.json:
{
"tagOwners": {
"tag:exit": ["mon-user@"]
},
"autoApprovers": {
"exitNode": ["tag:exit"]
},
"acls": [
{
"action": "accept",
"src": ["*"],
"dst": ["*:*"]
}
]
}
The important thing here is autoApprovers.exitNode: it allows nodes tagged with tag:exit to automatically announce themselves as exit nodes. Without it, you'd have to manually approve each exit node.
Step 4bis: nginx SNI routing on port 443
We want Headscale and Xray (installed later) to share the same port 443. We use nginx's stream module to route TCP traffic based on SNI (Server Name Indication) — the domain name sent by the client in the TLS handshake.
# Install nginx with the stream module
sudo apt install -y nginx libnginx-mod-stream
Create the SNI configuration file:
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
How it works:
- All traffic arrives on nginx
:443 - nginx reads the SNI (without decrypting TLS)
- If SNI is
hs.example.io→ route to Headscale on:4443 - Otherwise (everything else) → route to Xray on
:8443
# Check config and restart
sudo nginx -t && sudo systemctl restart nginx
Important: nginx does no TLS decryption. It's a pure TCP proxy. Headscale handles its own TLS (Let's Encrypt), and Xray handles its own (Reality). nginx only reads the plaintext SNI in the ClientHello and routes accordingly.
Step 5: Enable IP forwarding
For the VPS to route traffic from other devices (function as an exit node), you need to enable IP forwarding:
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
# Start Headscale
sudo systemctl enable --now headscale
# Create a user
sudo headscale users create mon-user
# Verify the service is running
sudo systemctl status headscale
On first startup, Headscale generates the cryptographic keys and obtains the Let's Encrypt certificate. Verify that https://hs.example.io responds (you should see a Headscale page).
Step 7: Connect the VPS as an exit node
The VPS itself must join the Tailscale network and announce itself as an exit node:
# Install 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 authentication key with the exit tag
# The --user flag takes the numeric ID (not the name)
# The --tags flag assigns the node to a special "tagged-devices" user
sudo headscale preauthkeys create \
--user 1 \
--reusable \
--expiration 24h \
--tags tag:exit
# The command displays the key, note it down
# Connect the VPS to its own Headscale
sudo tailscale up \
--login-server https://hs.example.io:443 \
--authkey MA_CLE_PREAUTHKEY \
--hostname vps-ch \
--advertise-exit-node
# Verify
sudo tailscale status
The VPS is now:
- The coordination server Headscale (network management)
- A node on the Tailscale network (like other devices)
- An exit node (capable of routing all traffic to the internet)
Step 8: Connect your devices
Linux
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
# Connect to Headscale server
sudo tailscale up --login-server https://hs.example.io:443
# Tailscale displays a registration URL
# → copy it and register the node on the server:
# --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
- Open a terminal as administrator:
# Disconnect from Tailscale cloud if needed
tailscale logout
# Connect to your Headscale
tailscale up --login-server https://hs.example.io:443
- Copy the displayed URL and register the node on the server.
Android
- Install Tailscale from the Play Store
- In the app, go to the menu ⋮ → Use an alternate server
- Enter the URL:
https://hs.example.io:443 - The app opens a browser with a registration URL
- On the server:
sudo headscale nodes register --key nodekey:abc123... --user 1
Verification
From the server, list all nodes:
sudo headscale nodes list
You should see all your devices with their Tailscale IP (100.64.0.x) and their online status.
Step 9: Enable routing through Switzerland
Now that everyone is connected, we enable the exit node to route all traffic through the Swiss VPS.
Linux
sudo tailscale set --exit-node=vps-ch --exit-node-allow-lan-access
--exit-node-allow-lan-access allows you to keep access to your local network (printer, NAS, etc.) even when internet traffic goes through the VPS.
We use tailscale set rather than tailscale up to modify a parameter without reinitializing others (like --login-server).
Windows
tailscale set --exit-node=vps-ch --exit-node-allow-lan-access
Or in the Tailscale GUI: click the menu → Exit Node → select vps-ch.
Android
In the Tailscale app: menu ⋮ → Use exit node → select vps-ch.
Verification
Test from each device:
curl -s https://ipinfo.io
You should see your Swiss VPS IP, city, country (CH) and your hosting provider's name.
Final architecture
┌──────────────────────────────────────────────────────────┐
│ Internet │
│ ▲ │
│ │ │
│ ┌───────────┴───────────┐ │
│ │ Swiss VPS :443 │ │
│ │ │ │
│ │ nginx (SNI router) │ │
│ │ ┌─────┴─────┐ │ │
│ │ │ │ │ │
│ │ Headscale Xray │ │
│ │ :4443 :8443 │ │
│ │ (Tailscale) (VLESS) │ │
│ └──┬────┬────┬────┬────┘ │
│ WireGuard │ │ │ │ VLESS+Reality │
│ ┌─────────────┘ │ │ └──────────────┐ │
│ │ │ │ │ │
│ ┌─┴──────┐ ┌───────┴┐ ┌┴────────┐ ┌──────┴──────┐ │
│ │ Linux │ │Windows │ │ Android │ │ v2rayN / │ │
│ │Tailscale│ │Tailscale│ │Tailscale│ │ v2rayNG │ │
│ │100.64 │ │100.64 │ │100.64 │ │ (censored │ │
│ │ .0.2 │ │ .0.3 │ │ .0.4 │ │ countries)│ │
│ └────────┘ └────────┘ └────────┘ └────────────┘ │
└──────────────────────────────────────────────────────────┘
Daily use: all traffic is encrypted by WireGuard via Tailscale, transits through the Swiss VPS, and exits to the internet with the Swiss IP. Your local ISP only sees a WireGuard connection to a single IP.
In censored countries: switch to v2rayN/v2rayNG which connects to the same VPS via VLESS+Reality. The ISP only sees an HTTPS connection to www.microsoft.com — impossible to distinguish or block.
Anti-DPI option: VLESS+Reality (Xray)
WireGuard has a flaw: its traffic is identifiable by Deep Packet Inspection. The handshake, packet size, UDP format — everything is recognizable. In Russia, China, Iran, raw WireGuard is blocked. Headscale/Tailscale won't work in these countries.
The solution: add a VLESS+Reality proxy (Xray) on the same VPS, alongside Headscale. 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 access to the impersonated site.
The idea: use Headscale/Tailscale daily (mesh network, exit node, performance), and switch to VLESS+Reality only when in a country that blocks VPNs.
Install Xray on the VPS
# Official installation
sudo bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install
Generate Reality keys
xray x25519
# → Private key: eE6MDfDF1JliKiDijcPojrOJB4-GsA_ux7InREW7hEg
# → Public key: _kP9S_vKqSksfj9MXNn0pULtphbzRVuNq5DYYafYpz8
Note both keys. The private key goes in the server config, the public key in the client config.
Also generate a UUID to identify the client:
xray uuid
# → 8672f031-7aaa-4a63-8835-6cd7f58ea703
And a short ID (8 to 16 hex characters):
openssl rand -hex 8
# → e318e30924f77899
Configure the Xray server
Edit /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 port 443 traffic to it (see step 4bis).dest: www.microsoft.com:443: the site Reality impersonates. When a non-VLESS client connects, it gets redirected to the real microsoft.com — the server is indistinguishable from a legitimate microsoft.com proxy.privateKey: the private key generated withxray x25519.flow: xtls-rprx-vision: the XTLS Vision mode offering the best performance.
# Create log directory and start
sudo mkdir -p /var/log/xray
sudo systemctl enable --now xray
Coexistence with Headscale on port 443
This is where the architecture comes together. Thanks to the SNI routing configured in step 4bis, nginx dispatches port 443 traffic:
Internet → nginx (:443)
├── SNI: hs.example.io → Headscale (:4443) → Tailscale clients
└── SNI: * (default) → Xray (:8443) → VLESS+Reality clients
Tailscale clients connect to hs.example.io:443 and get routed to Headscale. VLESS clients connect to the VPS IP on port 443 without a specific SNI (or with www.microsoft.com as SNI) and get routed to Xray. To an observer, all port 443 traffic looks like normal HTTPS.
Configure clients
VLESS share link
The simplest way to configure a client is using a VLESS link:
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
This link imports directly into v2rayN (Windows), v2rayNG (Android), Streisand (iOS).
Linux (Xray client)
# Install Xray
sudo bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install
Configure /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 creates a local SOCKS5 proxy (127.0.0.1:10818) and HTTP proxy (127.0.0.1:10819). Configure your browser or apps to use this proxy.
# Test
curl -x socks5h://127.0.0.1:10818 https://ifconfig.me
# → should show your VPS IP
Windows (v2rayN)
- Download v2rayN (version
v2rayN-windows-64.zip) - Extract to a folder (e.g.
C:\v2rayN) - Launch
v2rayN.exe - Menu → Servers → Import from clipboard → paste the VLESS link
- Select the server → click Start
- v2rayN enables the system proxy automatically
Conflict with Tailscale: don't run v2rayN and Tailscale (exit node) simultaneously. Both want to route all traffic and create a loop. Use Tailscale daily, v2rayN only in censored countries.
Android (v2rayNG)
- Install v2rayNG from the Play Store or GitHub
- Scan the QR code or import the VLESS link
- Tap the connect button
iOS (Streisand)
- Install Streisand from the App Store
- Import the VLESS link
- Connect
When to use what
| Situation | Solution |
|---|---|
| Daily use (France, Germany, etc.) | Tailscale via Headscale — VPS exit node, mesh network |
| Travel to Russia, China, Iran | VLESS+Reality via v2rayN/v2rayNG — anti-DPI |
| Both available | Tailscale by default, VLESS as backup |
You don't need VLESS+Reality daily. Tailscale is more performant (native WireGuard, direct peer connections, mesh network). VLESS+Reality is the anti-censorship weapon — enable it only when Tailscale is blocked.
Tip: don't set v2rayN to auto-start on Windows. You only want it for occasional use. Tailscale, on the other hand, can stay active permanently.
Verify everything is airtight
A misconfigured VPN can leak your real IP without you knowing. Here are the checks to perform systematically.
1. Public IP — the basic test
# Must return your VPS IP, not your ISP's
curl -s https://ipinfo.io/json | jq '{ip, city, country, org}'
Correct result (you're exiting through your Swiss VPS):
{
"ip": "203.0.113.42",
"city": "Geneva",
"country": "CH",
"org": "AS29222 My Hosting Provider SA"
}
Leak (your local ISP appears):
{
"ip": "86.xxx.xxx.xxx",
"city": "Paris",
"country": "FR",
"org": "AS3215 Orange S.A."
}
If you see your local ISP, the exit node is not active (tailscale set --exit-node=vps-ch).
2. IPv6 leak — the classic pitfall
Even with an active exit node, IPv6 traffic can bypass the tunnel and go directly through your ISP. This is the most common and most discreet leak.
# Test with a service that prioritizes IPv6
curl -s https://ipleak.net/json/ | jq '{ip, country_name, isp_name}'
Correct result (VPS IPv4, no IPv6 leak):
{
"ip": "203.0.113.42",
"country_name": "Switzerland",
"isp_name": "My Hosting Provider SA"
}
IPv6 leak (your ISP appears via your local IPv6 address):
{
"ip": "2a02:xxxx:xxxx:xxxx::1",
"country_name": "Germany",
"isp_name": "Vodafone GmbH"
}
If the result shows your local ISP, you have an IPv6 leak. Fix it by disabling IPv6 on the network interface:
# Identify the main network interface
ip route show default
# → default via 192.168.x.x dev eth0 ...
# Disable IPv6 on this interface
sudo sysctl -w net.ipv6.conf.eth0.disable_ipv6=1
# Make it permanent
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
Replace eth0 with your interface name (visible in the output of ip route).
3. DNS leak — the ISP sees your queries
Even if your traffic goes through the VPN, your DNS queries might still go to your ISP's resolver.
# Check which DNS resolver is used
# The result must show your VPS IP or Cloudflare (1.1.1.1), not your ISP
curl -s https://am.i.mullvad.net/json | jq '{ip, country, organization, mullvad_exit_ip}'
Correct result (VPS IP, not a Mullvad exit):
{
"ip": "203.0.113.42",
"country": "Switzerland",
"organization": "My Hosting Provider SA",
"mullvad_exit_ip": false
}
Headscale automatically configures internal DNS via MagicDNS. If you still see your ISP's DNS, force DNS in the Headscale config (dns.override_local_dns: true in config.yaml).
4. Triple verification — cross-reference sources
Never trust a single service. Cross-reference at least three independent sources:
echo "=== ipinfo.io ==="
curl -s https://ipinfo.io/json | jq '{ip, city, country}'
echo "=== ipleak.net ==="
curl -s https://ipleak.net/json/ | jq '{ip, country_name}'
echo "=== mullvad ==="
curl -s https://am.i.mullvad.net/json | jq '{ip, country}'
All three must return the same IP — that of your VPS. If a service shows a different IP, you have a leak.
=== ipinfo.io ===
203.0.113.42 — Geneva, CH (AS29222 My Hosting Provider SA)
=== ipleak.net ===
203.0.113.42 — Switzerland (My Hosting Provider SA)
=== mullvad ===
203.0.113.42 — Switzerland (My Hosting Provider SA)
Same Swiss IP three times = no leaks.
5. Verify VLESS+Reality (Xray)
Verify Xray is running and the tunnel works:
# On the VPS — check Xray is active
sudo systemctl status xray
# Check logs (successful connections appear here)
sudo tail -f /var/log/xray/access.log
From a configured client:
# Via local SOCKS5 proxy (Linux)
curl -x socks5h://127.0.0.1:10818 https://ifconfig.me
# → should show your VPS IP
# Via HTTP proxy (alternative)
curl -x http://127.0.0.1:10819 https://ifconfig.me
On Windows with v2rayN active, simply open a browser and go to ifconfig.me — you should see your VPS IP.
To verify traffic actually looks like normal HTTPS (not a VPN):
# Capture traffic to the VPS
sudo tcpdump -i eth0 -c 20 host 203.0.113.42 and port 443 -w /tmp/capture.pcap
# Analyze — you should see standard TLS 1.3, no VPN signature
tcpdump -r /tmp/capture.pcap -v | head -30
VLESS+Reality traffic is indistinguishable from an HTTPS connection to www.microsoft.com. No characteristic WireGuard handshake, no UDP signature — only standard TCP/TLS on port 443.
Hardening
A few additional measures to harden the setup:
VPS firewall
Don't leave all ports open. Restrict to the strictly necessary:
# Install ufw
sudo apt install -y ufw
# Rules
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
Disable password authentication, allow only keys:
# /etc/ssh/sshd_config
PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin no
Automatic updates
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
Total cost
| Component | Monthly cost |
|---|---|
| Infomaniak VPS (1 vCPU, 1 GB) | ~5€ |
| Domain name | ~1€/month (amortized) |
| Headscale | Free (open-source) |
| Tailscale client | Free |
| Let's Encrypt | Free |
| Total | ~6€/month |
That's the price of a commercial VPN subscription, except you control everything: the server, the logs, the jurisdiction, the access rules.
Commercial VPN vs self-hosted: the verdict
| Criteria | Commercial VPN | Self-hosted Headscale + Xray |
|---|---|---|
| Setup time | 2 minutes | 2-3 hours |
| Maintenance | None | Updates to apply |
| Number of servers | Dozens of countries | 1 (your VPS) |
| Trust | You trust the provider | You trust yourself |
| Resistance to blocking | Weak (known IPs) | Strong (unique IP + VLESS+Reality anti-DPI) |
| Sovereignty | None | Total |
| Cost | 5-10€/month | ~6€/month |
| Multi-device | Limited by plan | Unlimited |
| Anti-censorship (DPI) | Variable (Stealth, etc.) | VLESS+Reality — proven in Russia/China |
Self-hosting isn't for everyone. If you just want to unblock YouTube in Gabon, install Proton VPN and move on. But if you want a network infrastructure you control, that no one can cut you off from, and that 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 quick 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 recognized by Article 19 of the Universal Declaration of Human Rights. Use them responsibly and with knowledge of local laws.