VPN & Tunnels
Portability note: Compose examples use rootless Podman and
host.containers.internal(the host gateway from a container). When using Docker, replacepodman-composewithdocker composeandhost.containers.internalwithhost-gateway(addextra_hosts: [host-gateway:host-gateway]to the service). All concepts, architecture patterns, and CLI commands are container-runtime-agnostic.
VPN & Tunnels
All VPN and tunnel solutions on this system can run fully containerised. Rootless containers handle traffic routing but require specific capabilities (NET_ADMIN, NET_RAW), kernel modules (tun), and IP forwarding enabled on the host.
---
Key Concepts
WireGuard vs OpenVPN vs IPSec — when interviewers ask
WireGuard: modern (2015), small codebase (~4000 lines), fast, kernel-level, uses fixed modern crypto (ChaCha20, Curve25519). No backward compatibility negotiation — a feature, not a limitation. OpenVPN: mature (2001), large ecosystem, configurable cipher suites, userspace TLS so slower, supports TCP mode (useful when UDP is blocked). IPSec: the enterprise/router standard, complex to configure, built into most OSes natively.
Mesh VPN topology vs hub-and-spoke
Traditional VPNs are hub-and-spoke — all client traffic flows through a central VPN server. Mesh VPNs (Tailscale, NetBird, Nebula, ZeroTier) connect every peer directly to every other peer using STUN/TURN for NAT traversal. Advantages: lower latency (direct peer-to-peer), no single point of failure, no bandwidth bottleneck at the hub. Tailscale's control plane manages the key exchange; the data plane is direct WireGuard.
STUN vs TURN vs ICE
- STUN (Session Traversal Utilities for NAT) — tells a client its public IP and port as seen from the internet. Used for hole-punching.
- TURN (Traversal Using Relays around NAT) — a relay that proxies traffic when direct hole-punching fails (symmetric NAT). More expensive (all traffic flows through the relay).
- ICE (Interactive Connectivity Establishment) — the negotiation protocol that tries STUN first, falls back to TURN. Used by WebRTC and mesh VPNs.
Zero-trust network access (ZTNA) vs VPN
A traditional VPN grants access to the network — once connected, the user can typically reach everything on the network. ZTNA grants access to specific applications or resources, not the network itself. Firezone and Teleport implement ZTNA: you get access to postgres.internal:5432 not to the entire 10.0.0.0/8 network. Better for the principle of least privilege.
Tunnel overhead and MTU
Every VPN tunnel adds overhead to each packet (headers, encryption padding). WireGuard adds ~60 bytes. This reduces the effective inner MTU below the standard 1500 bytes — if you send a full-size 1500-byte packet through a WireGuard tunnel, the outer packet exceeds the link MTU and gets fragmented or dropped. Solutions: (1) set the WireGuard client MTU to 1420 (MTU = 1420 in wg0.conf), (2) enable MSS clamping on the server's PostUp iptables rule to automatically tell TCP connections about the reduced MTU.
Split tunnel security implications
With a split tunnel (AllowedIPs = 192.168.1.0/24), only traffic to the home LAN goes through the VPN — all other internet traffic goes directly from the client's ISP. This means: (1) DNS queries for non-.home.local domains don't use your Pi-hole, (2) your ISP can still see your general browsing, (3) a malicious website can't be blocked by your home DNS. A full tunnel (AllowedIPs = 0.0.0.0/0) routes everything through home, but adds latency and uses your home bandwidth. Choose based on the use case.
Cloudflare Tunnel vs self-hosted reverse proxy — the trade-off
Cloudflare Tunnel (cloudflared) exposes a local service to the internet without opening firewall ports or having a public IP. The tunnel connects outbound to Cloudflare's network, which terminates HTTPS for your domain. Trade-offs: you must trust Cloudflare to terminate your TLS (they see plaintext), your availability depends on Cloudflare's uptime, and all traffic passes through their network (latency + bandwidth cost for large transfers). For services that don't need to bypass Cloudflare, Pangolin (self-hosted) or a VPS-based reverse proxy (frp) gives you the same capability without the dependency.
NAT traversal and hole-punching
When two peers are both behind NAT (typical home routers), neither can initiate a direct connection to the other because there's no public IP:port mapping. Hole-punching works by having both peers send UDP packets to each other simultaneously — each packet causes the NAT to create a mapping for the return direction, opening a bidirectional path. STUN servers facilitate this by telling each peer their external address. This fails with symmetric NAT (different external port for each destination), which requires a TURN relay. WireGuard-based mesh VPNs (Tailscale, NetBird) handle all of this automatically.
Access control at the VPN layer vs application layer
A VPN (WireGuard, OpenVPN) controls who can reach the network; the application still controls what authenticated users can do. Zero-Trust tools (Firezone, Teleport) add a third layer: per-application access policies enforced at the gateway — user Alice can SSH to server A but not server B, even though both are on the same VPN subnet. This maps access control to identity (user + device) rather than just network position. Audit logs at the gateway layer (Teleport's session recording) are also available here, not possible with a plain VPN.
Protocol obfuscation — when and why
Standard WireGuard and OpenVPN traffic patterns are fingerprint-able by deep packet inspection (DPI). ISPs and national firewalls (GFW) identify and block them. Obfuscation tools (Xray/V2Ray with VLESS+XTLS-Reality, Hysteria2) make VPN traffic look like normal HTTPS, video streaming, or QUIC traffic. This is relevant for: (1) countries with internet censorship, (2) corporate networks that block non-HTTP outbound, (3) ISPs that throttle VPN traffic. Hysteria2 additionally uses QUIC's congestion control to improve performance on high-latency, high-loss links (satellite, mobile).
WireGuard cryptography — what you're actually running
WireGuard uses Curve25519 for key exchange (ECDH), ChaCha20-Poly1305 for symmetric encryption (authenticated, AEAD), and BLAKE2s for hashing. This is a modern, audited cryptographic stack — significantly simpler than OpenVPN's TLS negotiation (which can be misconfigured to use weak ciphers). Each peer has a 32-byte public key derived from its private key. Handshakes are silent — WireGuard never responds to unauthenticated packets, making it invisible to port scanners.
Tailscale vs Headscale — what the control plane actually does
WireGuard handles data-plane encryption; the control plane distributes public keys, allocates IP addresses, and implements NAT traversal. Tailscale's control plane is managed (tailscale.com servers). Headscale self-hosts the control plane — same WireGuard data plane, but your server handles key distribution and NAT traversal coordination. The trade-off: Headscale means no Tailscale SaaS dependency, but you run the coordination server, and some Tailscale features (MagicDNS via their resolvers, some client apps) may have reduced functionality.
Key rotation and peer management at scale
WireGuard has no built-in PKI — each peer is a static public key. At small scale (10 peers) this is manageable. At medium scale (50+ peers), use Headscale's API or Netbird's management plane to automate key distribution and rotation. Key compromise in WireGuard requires removing the peer's public key from all other peers' AllowedPeers lists — in a mesh of 50 nodes, this means 49 config updates. This is why a management plane (Headscale, Netbird, Innernet) is not optional at scale.
VPN split tunneling — security implications
Full tunnel routes all traffic through the VPN — protects DNS queries, prevents local network leakage, but adds latency and routes your ISP's traffic through your home server. Split tunnel routes only specific CIDRs through the VPN — your home LAN is accessible, but public traffic goes direct. Security risk: split tunnel exposes your traffic on the exit network (corporate WiFi, coffee shop) for non-VPN-routed traffic. DNS leak: even with split tunnel, ensure DNS queries for home resources go through the VPN resolver, not the local network's DNS.
---
---
Prerequisites
Before running any VPN container, enable IP forwarding and load the TUN module:
# Enable IPv4 and IPv6 forwarding
sudo sysctl -w net.ipv4.ip_forward=1
sudo sysctl -w net.ipv6.conf.all.forwarding=1
# Make it persistent across reboots
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-ipforward.conf
echo "net.ipv6.conf.all.forwarding=1" | sudo tee -a /etc/sysctl.d/99-ipforward.conf
# Load the TUN/TAP module (usually auto-loaded on first use)
sudo modprobe tun
---
Quick Selection Guide
| Tool | Best For | Complexity | Web UI | Protocol | |------|----------|------------|--------|----------| | WG-Easy | Home/SOHO WireGuard with a clean UI | Low | ✅ | WireGuard | | Tailscale | Zero-config mesh for personal/team use | Low | ✅ App | WireGuard (managed) | | Headscale + Headplane | Self-hosted Tailscale control server | Medium | ✅ | WireGuard | | Cloudflared | Expose services publicly, no port forwarding | Low | ✅ Dashboard | HTTPS/TLS | | Pangolin + Newt | Self-hosted tunnel server, full data ownership | Medium | ✅ | WireGuard | | NetBird | Open-source self-hosted Tailscale alternative | Medium | ✅ | WireGuard | | Pritunl | Enterprise VPN — SSO, multi-site, audit logs | Medium | ✅ | WireGuard / OpenVPN | | Firezone | Zero-trust access (ZTNA), granular policies | High | ✅ | WireGuard | | Nebula | Decentralised mesh, cert-based auth, GitOps | Medium | ❌ CLI | Nebula (UDP) | | ZeroTier | ZeroTier network with self-hosted controller | Medium | ✅ | ZeroTier (UDP) | | Hysteria 2 | High-loss / censored networks (QUIC) | Medium | ⚠️ Experimental | QUIC (HTTP/3) | | OpenVPN | Legacy compatibility, cert-based auth | Medium | ⚠️ Community | UDP/TCP | | WireGuard Road Warrior | Manual split-tunnel config for mobile clients | Medium | ❌ CLI | WireGuard | | Outline VPN | Simple Shadowsocks proxy for censorship resistance | Low | ✅ App | Shadowsocks | | Xray / V2Ray | Protocol-obfuscating proxy for censored networks | Medium | ❌ CLI | VLESS/VMESS/XTLS |
---
WireGuard / WG-Easy
Purpose: Modern, high-performance VPN with state-of-the-art cryptography. WG-Easy adds a lightweight web UI for managing peers, generating QR codes, and controlling routes — no CLI required.
# ~/wg-easy/compose.yaml
services:
wg-easy:
image: ghcr.io/wg-easy/wg-easy
ports:
- "127.0.0.1:51821:51821"
- "0.0.0.0:51820:51820/udp"
volumes:
- /home/user/wgeasy:/etc/wireguard:Z
environment:
WG_HOST: vpn.example.com
PASSWORD: changeme
WG_DEFAULT_ADDRESS: 10.8.0.x
WG_DEFAULT_DNS: 1.1.1.1
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
net.ipv4.ip_forward: "1"
restart: unless-stopped
cd ~/wg-easy && podman-compose up -d
Common operations
# View connected peers and their traffic stats
# Get session cookie first
SESSION=$(curl -s -c - -X POST http://localhost:51821/api/session \
-H 'Content-Type: application/json' \
-d '{"password":"changeme"}' | grep -o 'connect.sid=[^;]*')
# Then use it
curl http://localhost:51821/api/wireguard/client -H "Cookie: $SESSION"
# View WireGuard interface status on the host
sudo wg show
# View logs
podman logs -f wg-easy
# Restart to apply config changes
podman restart wg-easy
- Management UI:
http://localhost:51821(proxy through Caddy for HTTPS) - Client data: persisted in
/home/user/wgeasy/ - Firewall:
sudo firewall-cmd --add-port=51820/udp --permanent && sudo firewall-cmd --reload
How WireGuard Works
WireGuard is fundamentally different from older VPN protocols (OpenVPN, IPSec) in both design and implementation.
Cryptography: WireGuard uses a fixed, modern cryptographic suite — no negotiation, no cipher selection, no version mismatches:
- ChaCha20-Poly1305 — authenticated symmetric encryption (fast on CPUs without AES hardware acceleration)
- Curve25519 — elliptic-curve Diffie-Hellman key exchange
- BLAKE2s — fast cryptographic hashing
- SipHash — for routing table lookups
No handshake at connection time: WireGuard peers are configured with each other's public keys in advance. The "tunnel" is stateless — there is no session establishment phase. Packets are just encrypted and sent. This makes WireGuard silent when idle (nothing to detect) and extremely fast to reconnect after a network change (roaming between WiFi and mobile data works seamlessly).
Kernel-space implementation: WireGuard runs as a kernel module (or via a wireguard-go userspace implementation on unsupported platforms). This means packet processing happens without crossing the user/kernel boundary, giving it significantly better throughput than OpenVPN's userspace TLS stack.
Compared to OpenVPN: OpenVPN is a PKI-based TLS VPN running in userspace. It supports dynamic certificate revocation, many cipher suites, and protocol obfuscation — useful in enterprise environments. WireGuard trades that flexibility for simplicity, speed, and a drastically smaller codebase (~4000 lines vs ~100,000+ for OpenVPN).
Kill Switch
A kill switch ensures that if the VPN tunnel drops, traffic stops rather than falling back to your clearnet IP. Without it, a brief VPN disconnect leaks your real IP.
# Add to your WireGuard client config (wg0.conf)
[Interface]
PrivateKey = <your-private-key>
Address = 10.8.0.2/32
DNS = 10.8.0.1
# Kill switch: block all traffic except through wg0
PostUp = iptables -I OUTPUT ! -o %i -m mark ! --mark $(wg show %i fwmark) -m addrtype ! --dst-type LOCAL -j REJECT
PreDown = iptables -D OUTPUT ! -o %i -m mark ! --mark $(wg show %i fwmark) -m addrtype ! --dst-type LOCAL -j REJECT
[Peer]
...
This iptables rule allows only traffic marked by the WireGuard interface plus local traffic (LAN). All other outbound traffic is rejected at the kernel level. If WireGuard goes down, PreDown removes the rule and normal routing resumes.
Split DNS for VPN Clients
When routing LAN traffic through WireGuard, DNS for .home.local domains must resolve to LAN IPs even from a remote client. Configure the WireGuard client to use your home DNS server for .home.local queries only:
# wg0.conf client config — split DNS
[Interface]
DNS = 192.168.1.10 # your home AdGuard Home / Pi-hole IP
[Peer]
AllowedIPs = 10.8.0.0/24, 192.168.1.0/24 # route LAN traffic through VPN
# DNS queries for .home.local go to 192.168.1.10, which resolves them correctly
On Linux clients, systemd-resolved handles split DNS when DNS= is set in the WireGuard interface config. On macOS/Windows, the WireGuard GUI app respects the DNS setting from the config file.
---
Tailscale (Managed) & Headscale (Self-Hosted Control Server)
Purpose: Zero-config mesh VPN built on WireGuard. Tailscale uses managed coordination; Headscale is the fully open-source self-hosted control server — giving you the same experience with no third-party dependency.
Tailscale (Managed)
# Tailscale is pre-installed on this system — just run:
sudo tailscale up
# Enable SSH over Tailscale
sudo tailscale up --ssh
# Advertise as an exit node
sudo tailscale up --advertise-exit-node
# Advertise local subnets (share your LAN with other tailnet devices)
sudo tailscale up --advertise-routes=192.168.1.0/24
Or run as a container:
# ~/tailscale/compose.yaml
services:
tailscale:
image: tailscale/tailscale
command: tailscaled --tun=userspace-networking --socks5-server=:1080
volumes:
- /home/user/tailscale:/var/lib:Z
devices:
- /dev/net/tun
cap_add:
- NET_ADMIN
- NET_RAW
restart: unless-stopped
cd ~/tailscale && podman-compose up -d
Tailscale ACL Policies
By default, all devices in a Tailscale network can reach all other devices. ACL policies (in HuJSON format) let you control exactly which devices can talk to which — essential for separating personal devices from servers, or restricting access to sensitive ports.
Configure ACLs in the Tailscale admin console under Access Controls, or for Headscale via the policy.hujson config:
{
// Tags are assigned to devices — servers get "tag:server", laptops get "tag:laptop"
"tagOwners": {
"tag:server": ["autogroup:admin"],
"tag:laptop": ["autogroup:admin"]
},
"acls": [
// Laptops can SSH to servers
{"action": "accept", "src": ["tag:laptop"], "dst": ["tag:server:22"]},
// Servers can reach each other on any port (internal service mesh)
{"action": "accept", "src": ["tag:server"], "dst": ["tag:server:*"]},
// Laptops can reach Grafana dashboard on servers
{"action": "accept", "src": ["tag:laptop"], "dst": ["tag:server:3001"]},
// All other traffic denied (implicit deny at end of list)
],
// Tailscale SSH — which users can SSH to which tags
"ssh": [
{
"action": "accept",
"src": ["autogroup:admin"],
"dst": ["tag:server"],
"users": ["autogroup:nonroot"]
}
]
}
# Apply policy to Headscale
headscale policy set -f policy.hujson
# Verify policy was applied
headscale policy get
1. Create config directory and config file
mkdir -p /home/user/headscale/{config,data}
/home/user/headscale/config/config.yaml:
server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
grpc_listen_addr: 0.0.0.0:9090
database:
type: sqlite3
sqlite:
path: /var/lib/headscale/db.sqlite
dns:
base_domain: headscale.lan
magic_dns: true
nameservers:
- 1.1.1.1
- 8.8.8.8
2. Run the container
# ~/headscale/compose.yaml
services:
headscale:
image: headscale/headscale:latest
ports:
- 127.0.0.1:8080:8080
- 127.0.0.1:9090:9090
volumes:
- /home/user/headscale/config:/etc/headscale:Z
- /home/user/headscale/data:/var/lib/headscale:Z
restart: unless-stopped
cd ~/headscale && podman-compose up -d
3. Create a user and connect devices
# Create a namespace
podman exec headscale headscale users create home
# Generate a reusable pre-auth key (valid 30 days)
podman exec headscale headscale preauthkeys create --user home --reusable --expiration 30d
# Connect any Tailscale-compatible device to your Headscale server
tailscale up --login-server https://headscale.example.com --authkey <key>
# List connected nodes
podman exec headscale headscale nodes list
Headplane (Web UI for Headscale)
# ~/headplane/compose.yaml
services:
headplane:
image: ghcr.io/tale/headplane:latest
ports:
- 127.0.0.1:3001:3000
volumes:
- /home/user/headscale/config:/etc/headscale:ro,Z
restart: unless-stopped
cd ~/headplane && podman-compose up -d
Common operations
# Create a user (namespace)
podman exec headscale headscale users create myuser
# List users
podman exec headscale headscale users list
# Generate a reusable pre-auth key (30 days)
podman exec headscale headscale preauthkeys create --user myuser --reusable --expiration 30d
# List pre-auth keys
podman exec headscale headscale preauthkeys list --user myuser
# List all connected nodes
podman exec headscale headscale nodes list
# Expire (force-disconnect) a node
podman exec headscale headscale nodes expire --identifier NODE_ID
# Delete a node
podman exec headscale headscale nodes delete --identifier NODE_ID
# Get debug info for a node
podman exec headscale headscale nodes --output json list | python3 -m json.tool
# Generate an API key for Headplane
podman exec headscale headscale apikeys create
# Check server version
podman exec headscale headscale version
---
Cloudflare Tunnel (Cloudflared)
Purpose: Expose local services to the internet without opening any firewall ports. Cloudflared makes outbound-only HTTPS connections to Cloudflare's edge — your router needs no changes.
# Authenticate with your Cloudflare account
cloudflared login
# Create a tunnel
cloudflared tunnel create home-server
# Configure services to expose
cat > ~/.cloudflared/config.yml << EOF
tunnel: <tunnel-id>
credentials-file: /home/user/.cloudflared/<tunnel-id>.json
ingress:
- hostname: media.example.com
service: http://localhost:8096
- hostname: files.example.com
service: http://localhost:8384
- service: http_status:404
EOF
# Run as a persistent system service
sudo cloudflared service install
sudo systemctl enable --now cloudflared
Or run as a container using a token from the Cloudflare Zero Trust dashboard:
# ~/cloudflared/compose.yaml
services:
cloudflared:
image: cloudflare/cloudflared:latest
volumes:
- /home/user/cloudflared/config:/etc/cloudflared:Z
command: tunnel --no-autoupdate run --token <your-token>
restart: unless-stopped
cd ~/cloudflared && podman-compose up -d
- DNS: Add a CNAME for each hostname pointing to
<tunnel-id>.cfargotunnel.com - Firewall: None required — all traffic is outbound
---
Pangolin (Self-Hosted Tunnel Server)
Purpose: A fully open-source alternative to Cloudflare Tunnel. Expose local services via a public HTTPS URL through an encrypted WireGuard tunnel to a VPS you control. No third-party cloud — you own the entire path. Supports identity-aware access control per resource.
Pangolin has two components:
- Pangolin — the server, runs on a VPS, receives tunnelled traffic and routes it to your services
- Newt — the agent, runs on your Shani OS machine, creates the outbound WireGuard tunnel
1. Server Setup (on a VPS)
/home/user/pangolin/config/config.yml:
app:
dashboard_url: https://pangolin.yourdomain.com
base_domain: yourdomain.com
admin_email: admin@yourdomain.com
admin_password: changeme
log_level: info
server:
external_port: 443
internal_port: 8080
db:
encryption_key: "your-32-char-hex-key"
# ~/pangolin/compose.yaml
services:
pangolin:
image: fosrl/pangolin:latest
ports:
- 0.0.0.0:443:443
- 0.0.0.0:51820:51820/udp
volumes:
- /home/user/pangolin/config:/app/config:Z
- /home/user/pangolin/data:/app/data:Z
restart: unless-stopped
cd ~/pangolin && podman-compose up -d
Access the dashboard at https://pangolin.yourdomain.com, create a site, and copy the Newt credentials.
VPS firewall
open 443/tcp and 51820/udp
2. Newt Agent (on this system)
# ~/newt/compose.yaml
services:
newt:
image: fosrl/newt:latest
environment:
PANGOLIN_URL: https://pangolin.yourdomain.com
NEWT_ID: <your-newt-id>
NEWT_SECRET: <your-newt-secret>
restart: unless-stopped
cd ~/newt && podman-compose up -d
---
NetBird
Purpose: Open-source, peer-to-peer WireGuard mesh VPN platform. The most complete self-hosted alternative to Tailscale — management dashboard, STUN/TURN relay, and kernel-level WireGuard all run on hardware you control. Supports SSO (OIDC), ACL policies, DNS routing, and split tunnelling. Clients are available for Linux, macOS, Windows, iOS, and Android.
Architecture
NetBird has three server components:
- Management — API, ACL policy store, device registry
- Signal — WebRTC signalling for peer hole-punching
- Relay (Coturn) — TURN relay for peers behind strict NAT
Full Self-Hosted Deployment
# 1. Get the official compose stack
curl -sSL https://raw.githubusercontent.com/netbirdio/netbird/main/infrastructure_files/docker-compose.yml \
-o ~/netbird/compose.yaml
curl -sSL https://raw.githubusercontent.com/netbirdio/netbird/main/infrastructure_files/.env.example \
-o ~/netbird/.env
# 2. Edit .env — set your domain, OIDC provider, and TURN credentials
nano ~/netbird/.env
Key .env variables
NETBIRD_DOMAIN=netbird.example.com
# OIDC provider (e.g., Authentik, Keycloak, Zitadel, or Dex)
NETBIRD_AUTH_OIDC_CONFIGURATION_ENDPOINT=https://auth.example.com/application/o/netbird/.well-known/openid-configuration
NETBIRD_AUTH_CLIENT_ID=netbird
NETBIRD_AUTH_CLIENT_SECRET=changeme
# TURN relay credentials
NETBIRD_TURN_USER=coturn
NETBIRD_TURN_PASSWORD=changeme
3. Start all services
cd ~/netbird && podman-compose up -d
Services started
managementon port443(HTTPS/gRPC)signalon port10000coturn(TURN relay) on port3478/udpand5349/tcpdashboard(React SPA served by Nginx)
Firewall:
sudo firewall-cmd --add-port=443/tcp \
--add-port=10000/tcp \
--add-port=3478/udp \
--add-port=5349/tcp \
--permanent && sudo firewall-cmd --reload
Connect a Client
# Install the NetBird client
curl -fsSL https://pkgs.netbird.io/install.sh | sh
# Connect to your self-hosted management server
netbird up --management-url https://netbird.example.com:443
# Check status
netbird status
# Show peers
netbird peers
# Disconnect
netbird down
ACL Policies
NetBird lets you define granular access policies per group in the dashboard:
- Create groups (e.g.,
servers,laptops,phones) - Assign devices to groups
- Create policies that allow specific traffic (e.g.,
laptops→serverson port 22) - Block all other inter-peer traffic by default
DNS Routes
Route private DNS to your server's Pi-hole or AdGuard instance:
- In the dashboard: DNS → Nameservers → Add
- Domain:
home.local, Nameserver: IP of your Pi-hole peer - Enable: all peers in the
laptopsgroup use this nameserver for.home.local
Caddy Configuration
netbird.example.com {
reverse_proxy localhost:80
# gRPC for management API
@grpc protocol grpc
reverse_proxy @grpc localhost:443 {
transport http { versions h2c }
}
}
---
Pritunl (Enterprise VPN)
Purpose: Enterprise-grade VPN with a modern web UI. Supports WireGuard and OpenVPN, SSO, multi-site routing, and audit logging. Requires MongoDB.
1. MongoDB Backend
# ~/pritunl-mongo/compose.yaml
services:
pritunl-mongo:
image: mongo:6
ports:
- 127.0.0.1:27017:27017
volumes:
- /home/user/pritunl/mongo:/data/db:Z
restart: unless-stopped
cd ~/pritunl-mongo && podman-compose up -d
2. Pritunl Server
# ~/pritunl/compose.yaml
services:
pritunl:
image: linuxserver/pritunl:latest
network_mode: host
volumes:
- /home/user/pritunl/config:/etc/pritunl:Z
cap_add:
- NET_ADMIN
- SYS_ADMIN
restart: unless-stopped
cd ~/pritunl && podman-compose up -d
Initial setup
- Generate setup key:
podman exec pritunl pritunl setup-key - Access UI:
https://<server-ip>:443 - Set MongoDB URI:
mongodb://127.0.0.1:27017/pritunl - Create Org → Add Users → Create Server → Attach → Start
Firewall: sudo firewall-cmd --add-port=443/tcp --add-port=51820/udp --add-port=1194/udp --permanent && sudo firewall-cmd --reload
---
Firezone (Zero-Trust Access)
Purpose: Zero-trust network access (ZTNA) built on WireGuard. Features SSO (OIDC/SAML), granular access policies, device posture checks, and a unified dashboard.
# ~/firezone/compose.yaml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: firezone
POSTGRES_PASSWORD: strongpassword
POSTGRES_DB: firezone
volumes: [db_data:/var/lib/postgresql/data]
restart: unless-stopped
firezone:
image: firezone/firezone:latest
ports:
- "0.0.0.0:4443:443"
- "0.0.0.0:51820:51820/udp"
environment:
DATABASE_URL: postgresql://firezone:strongpassword@db:5432/firezone
SECRET_KEY_BASE: changeme-generate-with-openssl-rand-base64-64
DEFAULT_ADMIN_EMAIL: admin@example.com
cap_add: [NET_ADMIN]
sysctls:
net.ipv4.ip_forward: "1"
net.ipv6.conf.all.disable_ipv6: "0"
depends_on: [db]
restart: unless-stopped
volumes:
db_data:
cd ~/firezone && podman-compose up -d
- Access:
https://localhost:4443 - Firewall:
sudo firewall-cmd --add-port=4443/tcp --add-port=51820/udp --permanent && sudo firewall-cmd --reload
---
Nebula (Overlay Mesh Network)
Purpose: A scalable, decentralised overlay network developed by Slack. Nodes communicate directly using certificate-based PKI. Designed for GitOps-style config management — no web UI.
1. Generate Certificates
# Generate CA
nebula-cert ca -name "Home Network CA"
# Generate lighthouse (coordination node) certificate
nebula-cert sign -name "lighthouse" -ip "192.168.100.1/24"
# Generate client certificate
nebula-cert sign -name "shani-server" -ip "192.168.100.2/24"
2. Lighthouse Config (config.yml)
pki:
ca: /etc/nebula/ca.crt
cert: /etc/nebula/lighthouse.crt
key: /etc/nebula/lighthouse.key
static_host_map:
"192.168.100.1": ["your-vps-public-ip:4242"]
lighthouse:
am_lighthouse: true
listen:
host: 0.0.0.0
port: 4242
firewall:
outbound: [{ port: any, proto: any, host: any }]
inbound: [{ port: any, proto: any, host: any }]
3. Run Container
# ~/nebula/compose.yaml
services:
nebula:
image: slacktechnologiesllc/nebula:latest
ports:
- 0.0.0.0:4242:4242/udp
volumes:
- /home/user/nebula:/etc/nebula:Z
devices:
- /dev/net/tun
cap_add:
- NET_ADMIN
restart: unless-stopped
cd ~/nebula && podman-compose up -d
Firewall: sudo firewall-cmd --add-port=4242/udp --permanent && sudo firewall-cmd --reload
Nebula has no official web UI. Manage configs via Git and distribute with
scpor Ansible.
---
ZeroTier (Self-Hosted Controller)
Purpose: Run a private ZeroTier network controller without using ZeroTier's central cloud servers. Manage virtual networks and peers on your own hardware.
# ~/zerotier-controller/compose.yaml
services:
zerotier-controller:
image: mgk/zerotier-controller:latest
ports:
- 127.0.0.1:9993:9993/udp
- 127.0.0.1:3180:3180/tcp
volumes:
- /home/user/zerotier-controller:/var/lib/ztnetwork:Z
restart: unless-stopped
cd ~/zerotier-controller && podman-compose up -d
- Dashboard:
http://localhost:3180 - Client setup:
zerotier-cli join <network-id> --controller <your-server-ip>:3180
---
OpenVPN
Purpose: Legacy, highly configurable VPN standard. Use when you need specific cipher suites, client certificate management, or compatibility with older devices.
# ~/openvpn/compose.yaml
services:
openvpn:
image: kylemanna/openvpn
ports:
- 0.0.0.0:1194:1194/udp
volumes:
- /home/user/openvpn:/etc/openvpn:Z
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
net.ipv4.ip_forward: 1
restart: unless-stopped
cd ~/openvpn && podman-compose up -d
---
Hysteria 2 (High-Loss Networks)
Purpose: A QUIC-based proxy and tunnel that performs well in high-latency, high-loss, or censored network environments where WireGuard/TCP struggle. Traffic looks like normal HTTP/3 to firewalls.
/home/user/hysteria/config.yaml:
listen: :443
tls:
cert: /etc/hysteria/fullchain.pem
key: /etc/hysteria/privkey.pem
auth:
type: password
password: "your-strong-password"
# ~/hysteria/compose.yaml
services:
hysteria:
image: ghcr.io/apernet/hysteria:latest
ports:
- 0.0.0.0:443:443/udp
volumes:
- /home/user/hysteria/config.yaml:/etc/hysteria/config.yaml:ro,Z
- /home/user/hysteria/certs:/etc/hysteria:ro,Z
command: server -c /etc/hysteria/config.yaml
restart: unless-stopped
cd ~/hysteria && podman-compose up -d
---
Gluetun (VPN Client Container)
Purpose: Route any container's traffic through a commercial VPN provider — without installing a VPN client on the host. Gluetun supports 50+ providers (Mullvad, ProtonVPN, NordVPN, Private Internet Access, ExpressVPN, etc.) and acts as a network gateway container. Other containers join its network namespace via network_mode: service:gluetun — their traffic exits through the VPN tunnel transparently.
Common use case
Route qBittorrent through Mullvad so torrent traffic never uses your home IP.
# ~/gluetun/compose.yaml
services:
gluetun:
image: qmcgaw/gluetun:latest
cap_add: [NET_ADMIN]
devices:
- /dev/net/tun
ports:
- "127.0.0.1:8080:8080" # qBittorrent WebUI exposed via gluetun
environment:
VPN_SERVICE_PROVIDER: mullvad
VPN_TYPE: wireguard
WIREGUARD_PRIVATE_KEY: your-mullvad-wireguard-private-key
WIREGUARD_ADDRESSES: 10.64.222.21/32
SERVER_COUNTRIES: Netherlands
volumes:
- /home/user/gluetun:/gluetun:Z
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
network_mode: "service:gluetun" # all qbittorrent traffic goes through gluetun
environment:
PUID: "1000"
PGID: "1000"
WEBUI_PORT: 8080
volumes:
- /home/user/qbittorrent:/config:Z
- /home/user/downloads:/downloads:Z
depends_on: [gluetun]
restart: unless-stopped
cd ~/gluetun && podman-compose up -d
When
network_mode: service:gluetunis set, the dependent container shares gluetun's network — all ports are exposed on the gluetun container, not the app container. The qBittorrent WebUI is reached athttp://localhost:8080via gluetun's port mapping.
Check that traffic is routed through the VPN
podman exec qbittorrent curl -s https://api.ipify.org
# Should return the VPN exit IP, not your home IP
Common operations
# Verify traffic is routed through VPN (should show VPN exit IP)
podman exec qbittorrent curl -s https://api.ipify.org
# Check Gluetun control server status
curl http://localhost:8000/v1/openvpn/status 2>/dev/null || curl http://localhost:8000/v1/publicip/ip
# View logs to debug connection issues
podman logs -f gluetun
# Force reconnect (pick a different VPN server)
podman restart gluetun
# List available servers for your provider (Mullvad example)
podman exec gluetun cat /gluetun/servers.json | python3 -m json.tool | grep '"city"' | head -20
Supported providers include
Mullvad, ProtonVPN, NordVPN, ExpressVPN, Private Internet Access, Surfshark, Windscribe, IVPN, AzireVPN, and any custom WireGuard/OpenVPN config.
---
WireGuard Road Warrior (Manual Split-Tunnel Config)
Purpose: A "road warrior" setup lets mobile or laptop clients connect to your home server from anywhere, routing only selected traffic through the VPN (split tunnel) rather than all traffic. Unlike WG-Easy, this is a fully manual config — useful when you want precise control over allowed IPs, DNS, and per-client routing without running a web UI.
1. Server Config
/etc/wireguard/wg0.conf on the server:
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = <server-private-key>
# Allow VPN clients to reach the server LAN
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
# Mobile client — phone or laptop
PublicKey = <client-public-key>
AllowedIPs = 10.10.0.2/32
# Generate server key pair
wg genkey | tee server.key | wg pubkey > server.pub
# Generate client key pair (on the client or server)
wg genkey | tee client.key | wg pubkey > client.pub
# Bring the interface up
sudo wg-quick up wg0
# Enable on boot
sudo systemctl enable wg-quick@wg0
2. Client Config (Split Tunnel)
/etc/wireguard/wg0.conf on the client (phone or laptop):
[Interface]
Address = 10.10.0.2/24
PrivateKey = <client-private-key>
DNS = 10.10.0.1 # or your Pi-hole / Adguard address
[Peer]
PublicKey = <server-public-key>
Endpoint = vpn.example.com:51820
# Split tunnel — only route home LAN and VPN subnet through WireGuard
# Change to 0.0.0.0/0 for full tunnel (all traffic)
AllowedIPs = 10.10.0.0/24, 192.168.1.0/24
PersistentKeepalive = 25
Generate a scannable QR code for the mobile WireGuard app:
nix-env -iA nixpkgs.qrencode
qrencode -t ansiutf8 < /etc/wireguard/client.conf
3. Run in a Container (Podman)
For a fully containerised road warrior server without touching the host WireGuard stack:
# ~/wireguard-rw/compose.yaml
services:
wireguard:
image: lscr.io/linuxserver/wireguard
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
net.ipv4.ip_forward: "1"
ports:
- "0.0.0.0:51820:51820/udp"
volumes:
- /home/user/wireguard-rw/config:/config:Z
environment:
PUID: "1000"
PGID: "1000"
TZ: Asia/Kolkata
SERVERURL: vpn.example.com
SERVERPORT: "51820"
PEERS: phone,laptop # generates one config per peer name
PEERDNS: auto
ALLOWEDIPS: 10.13.13.0/24,192.168.1.0/24 # split tunnel
INTERNAL_SUBNET: 10.13.13.0
restart: unless-stopped
cd ~/wireguard-rw && podman-compose up -d
Client configs and QR codes are generated automatically at /home/user/wireguard-rw/config/peer_phone/ and peer_laptop/.
- Firewall:
sudo firewall-cmd --add-port=51820/udp --permanent && sudo firewall-cmd --reload
Split tunnel vs full tunnel:
AllowedIPs = 0.0.0.0/0routes all traffic through the VPN (full tunnel — use for privacy on untrusted networks).AllowedIPs = 10.13.13.0/24, 192.168.1.0/24routes only the VPN subnet and home LAN (split tunnel — use when you only need access to home services without affecting other traffic).
---
Outline VPN
Purpose: Shadowsocks-based proxy server by Jigsaw (Google). Designed for ease of deployment and resistance to traffic fingerprinting — traffic is indistinguishable from regular HTTPS to deep packet inspection. Unlike WireGuard, Outline is proxy-based rather than a full network tunnel, making it suitable for censorship circumvention where WireGuard is blocked. Management is via the Outline Manager desktop app (Linux/macOS/Windows) which generates a one-line docker run command and per-user access keys.
# ~/outline/compose.yaml
# Note: Outline Manager generates an exact run command including ports and secrets.
# Use its output directly. The compose below is a representative template.
services:
outline:
image: quay.io/outline/shadowbox:stable
ports:
- "0.0.0.0:8080:8080/tcp"
- "0.0.0.0:8080:8080/udp"
- "0.0.0.0:9090:9090/tcp" # management API (bind to 127.0.0.1 if behind a proxy)
volumes:
- /home/user/outline/persisted-state:/root/shadowbox/persisted-state:Z
environment:
SB_API_PORT: "9090"
SB_API_PREFIX: "your-random-prefix" # generated by Outline Manager
SB_CERTIFICATE_FILE: /root/shadowbox/persisted-state/shadowbox-selfsigned.crt
SB_PRIVATE_KEY_FILE: /root/shadowbox/persisted-state/shadowbox-selfsigned.key
restart: unless-stopped
Recommended: Use the Outline Manager desktop app to generate the exact command, copy the
docker runoutput and convert it to compose. The Manager handles certificate generation, port selection, and API key management automatically.
Firewall: Open the data port (default 8080/tcp and 8080/udp) and the management API port on your server.
sudo firewall-cmd --add-port=8080/tcp --add-port=8080/udp --permanent
sudo firewall-cmd --reload
Clients
Distribute per-user access keys (ss:// URIs) generated by the Manager. Users install the Outline Client app on Android, iOS, Windows, macOS, or Linux.
Outline and Hysteria 2 solve different problems. Outline is optimised for censorship circumvention (traffic obfuscation). Hysteria 2 is optimised for high-loss / high-latency networks (QUIC transport). Use Outline where WireGuard is fingerprinted and blocked; use Hysteria 2 where packet loss degrades TCP-based protocols.
---
Xray / V2Ray (Protocol-Obfuscating Proxy)
Purpose: A suite of network proxy tools that wrap traffic in protocols designed to evade deep packet inspection — VLESS, VMESS, and XTLS over WebSocket or gRPC, disguised as ordinary HTTPS. Widely used alongside Hysteria 2 for censorship circumvention. Xray is the actively maintained fork of V2Ray with additional protocols (XTLS, VLESS, XHTTP) and better performance.
Use case vs WireGuard
Xray is a proxy, not a VPN — it forwards traffic through an HTTPS tunnel that looks like web traffic. WireGuard is a full network tunnel with a distinct UDP fingerprint. In environments where WireGuard and Shadowsocks are actively blocked, Xray VLESS+XTLS over port 443 is significantly harder to detect.
/home/user/xray/config.json (VLESS + XTLS-Reality — the modern recommended config):
{
"inbounds": [{
"port": 443,
"protocol": "vless",
"settings": {
"clients": [{
"id": "your-uuid-here",
"flow": "xtls-rprx-vision"
}],
"decryption": "none"
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"dest": "www.google.com:443",
"serverNames": ["www.google.com"],
"privateKey": "your-reality-private-key",
"shortIds": ["your-short-id"]
}
}
}],
"outbounds": [{"protocol": "freedom"}]
}
# ~/xray/compose.yaml
services:
xray:
image: ghcr.io/xtls/xray-core:latest
ports:
- "0.0.0.0:443:443/tcp"
volumes:
- /home/user/xray/config.json:/etc/xray/config.json:ro,Z
command: run -config /etc/xray/config.json
restart: unless-stopped
cd ~/xray && podman-compose up -d
Generate a UUID and Reality keys
# Generate a UUID for the client ID
podman run --rm ghcr.io/xtls/xray-core:latest uuid
# Generate a Reality key pair
podman run --rm ghcr.io/xtls/xray-core:latest x25519
Clients
v2rayN (Windows), v2rayNG (Android), Shadowrocket (iOS), Nekoray (Linux/Windows). Share the connection config as a vless:// URI or QR code.
XTLS-Reality (shown above) is the recommended modern config — it borrows a real TLS certificate fingerprint from a public site (
www.google.com), making the server indistinguishable from that site even to active probers. Older VMESS+WS configs are simpler but more detectable.
Firewall:
sudo firewall-cmd --add-port=443/tcp --permanent && sudo firewall-cmd --reload
Troubleshooting
| Issue | Solution | |-------|----------| | TUN/TAP device not found | Run sudo modprobe tun on the host | | Clients can't route traffic | Verify net.ipv4.ip_forward=1 is set; check --sysctl flags in the container run command | | DNS not resolving for VPN clients | Set WG_DEFAULT_DNS or equivalent to 1.1.1.1 or your Pi-hole address | | Headscale nodes show offline | Verify Headscale is listening on 0.0.0.0:8080; check that server_url in config matches your public domain | | Pangolin tunnel not connecting | Verify Newt credentials (NEWT_ID, NEWT_SECRET); check VPS firewall allows 51820/udp | | NetBird peers not connecting | Ensure the TURN relay (Coturn) port 3478/udp is open; check signal server is reachable on port 10000 | | NetBird dashboard blank | OIDC configuration may be wrong — check management logs: podman-compose logs management | | Firezone DB error on startup | Ensure DATABASE_URL host points to the db service name; check podman-compose logs db | | Hysteria QUIC timeout | Ensure UDP port 443 is open on your VPS firewall and not blocked by the ISP | | Nebula nodes can't reach each other | Verify ca.crt matches on all nodes; check static_host_map IPs resolve correctly | | MongoDB connection refused (Pritunl) | Confirm pritunl-mongo is running; use --network host so both containers share the same network namespace | | OpenVPN auth fails | Re-export the client config with ovpn_getclient; verify firewall allows 1194/udp | | Gluetun VPN not connecting | Verify WIREGUARD_PRIVATE_KEY and WIREGUARD_ADDRESSES are correct; check podman logs gluetun for auth errors | | Gluetun leaking real IP | Ensure the app container uses network_mode: service:gluetun — any other network mode bypasses the tunnel | | qBittorrent WebUI unreachable via Gluetun | Port must be published on the gluetun container, not qbittorrent; the app container shares gluetun's network | | WireGuard client can't reach LAN | Ensure AllowedIPs includes the home subnet (e.g., 192.168.1.0/24) and that PostUp iptables MASQUERADE rule is active on the server | | WireGuard road warrior QR not showing | Install qrencode via Nix: nix-env -iA nixpkgs.qrencode; for the linuxserver container, peer QR PNGs are in config/peer_<name>/peer_<name>.png | | Outline Manager can't connect to server | The management API port (default 9090) must be reachable; check firewall and that the SB_API_PREFIX in the environment matches the Manager's saved config | | Outline client times out | Ensure both TCP and UDP on the data port are open; Shadowsocks uses both; check ISP is not blocking the port | | Xray VLESS connection rejected | Verify the client UUID matches exactly; check that port 443 is open; confirm the Reality serverNames is reachable from the server itself | | Xray Reality private key error | Regenerate the key pair with xray x25519 — the public key goes in the client config, private key stays on the server |
🔒 Security tip: Always bind management UIs (
wg-easy,headplane,portainer) to127.0.0.1and proxy through Caddy. Never expose control-plane interfaces directly to the internet. Rotate pre-auth keys periodically and usefail2banon any publicly facing port.