If you run services across multiple VPS servers, those services probably talk to each other over the public internet. Database connections, reverse proxy traffic, API calls - all crossing networks you don't control. This guide fixes that.
We'll set up a private WireGuard network between your servers and route all inter-server traffic through it. Public-facing requests hit your reverse proxy over HTTPS. Everything behind it travels through an encrypted tunnel. Services never touch the public internet.
Architecture
User (HTTPS) → proxy-server (nginx) → WireGuard tunnel → backend-server (service)
Three components:
- Proxy server - runs nginx, handles SSL termination, connects to backends over WireGuard
- Backend servers - run your actual services (apps, databases, dashboards), only reachable via tunnel
- WireGuard tunnel - encrypted private network connecting all servers
The proxy server is a hub. Backend servers connect to it. Services bind to the WireGuard interface only - no public ports exposed.
Requirements
- Two or more Linux VPS (KVM) running Debian 12+ or Ubuntu 22.04+
- Root access on all servers
- One server designated as the proxy/hub
- WireGuard kernel support (available on all KVM VPS at ArkHost)
Step 1: Install WireGuard
Run on all servers:
apt update apt install -y wireguard
Step 2: Generate Keys
On each server, generate a key pair:
wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key chmod 600 /etc/wireguard/private.key
Note down each server's public key. You'll need them for the configuration.
Step 3: Configure the Proxy Server (Hub)
This is your central node. All other servers connect to it.
Create /etc/wireguard/wg0.conf:
[Interface] Address = 10.50.0.1/24 PrivateKey = <proxy-server-private-key> ListenPort = 51820 [Peer] # backend-1 PublicKey = <backend-1-public-key> AllowedIPs = 10.50.0.2/32 [Peer] # backend-2 PublicKey = <backend-2-public-key> AllowedIPs = 10.50.0.3/32
Add a peer block for each backend server. Assign sequential IPs: 10.50.0.2, 10.50.0.3, etc.
Step 4: Configure Backend Servers
Each backend server gets a client configuration pointing to the proxy server.
Create /etc/wireguard/wg0.conf on backend-1:
[Interface] Address = 10.50.0.2/24 PrivateKey = <backend-1-private-key> [Peer] PublicKey = <proxy-server-public-key> AllowedIPs = 10.50.0.1/32 Endpoint = <proxy-server-public-ip>:51820 PersistentKeepalive = 25
Same for backend-2, using Address = 10.50.0.3/24 and its own private key.
Step 5: Firewall
On the proxy server, allow WireGuard only from known backend IPs:
ufw allow from <backend-1-public-ip> to any port 51820 proto udp ufw allow from <backend-2-public-ip> to any port 51820 proto udp
Do not open 51820 to the world. Restrict it to your server IPs.
Step 6: Enable and Start
On all servers:
systemctl enable --now wg-quick@wg0
Verify the tunnel:
wg show
You should see a "latest handshake" timestamp for each peer. If it's empty, the connection didn't establish - check firewall rules and keys.
Test connectivity:
# From proxy server ping 10.50.0.2 # From backend-1 ping 10.50.0.1
Step 7: Bind Services to the Tunnel
This is the critical part. Your services should only listen on the WireGuard IP, not on all interfaces.
For Docker containers, change the port binding in your docker-compose.yml:
# Before (exposed publicly) ports: - "8080:8080" # After (only reachable via tunnel) ports: - "10.50.0.2:8080:8080"
For native services, bind to the WireGuard IP in their configuration. For example, a Node.js app:
app.listen(3000, '10.50.0.2');
After this change, the service is invisible from the public internet. It only responds to requests coming through the tunnel.
Step 8: Configure nginx Reverse Proxy
On the proxy server, create a virtual host for each service. The key line is proxy_pass pointing to the backend's WireGuard IP.
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
location / {
proxy_pass http://10.50.0.2:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The Upgrade and Connection headers are needed for WebSocket support. Include them even if your app doesn't use WebSockets - it won't cause issues and saves debugging later.
Step 9: Restrict Access (Optional)
For admin panels and internal tools, add IP restrictions in nginx:
location / {
allow 10.66.66.0/24; # Your VPN network
allow 203.0.113.50; # Your static IP
deny all;
proxy_pass http://10.50.0.2:8080;
# ... proxy headers
}
Anyone not on the allow list gets a 403.
Adding More Servers
To add a new backend:
- Install WireGuard on the new server
- Generate keys
- Add a
[Peer]block to the proxy server'swg0.conf - Create
wg0.confon the new server pointing to the proxy - Add a firewall rule on the proxy for the new server's public IP
- Restart the tunnel on the proxy:
wg-quick down wg0 && wg-quick up wg0 - Start the tunnel on the new server:
systemctl enable --now wg-quick@wg0
No changes needed on existing backend servers.
What This Gets You
- No public ports - Services don't listen on public interfaces. Port scans find nothing.
- Encrypted inter-server traffic - WireGuard uses ChaCha20 encryption. No plaintext between servers.
- Centralized SSL - One server handles all certificates. Backends don't need SSL configuration.
- Simple scaling - Adding a server is one peer block and a firewall rule.
- Minimal overhead - WireGuard runs in kernel space. The performance impact is negligible.
Troubleshooting
No handshake after setup
Check that the proxy server's firewall allows UDP 51820 from the backend's public IP. Verify the public keys match on both ends.
Tunnel is up but can't reach the service
Make sure the service is bound to the WireGuard IP, not 0.0.0.0 or 127.0.0.1. Check with ss -tlnp | grep <port>.
nginx returns 502 Bad Gateway
The backend service isn't running or isn't listening on the expected port. Check with curl http://10.50.0.2:8080 from the proxy server.
Tunnel drops after logout
If running WireGuard as a non-root user with Podman, enable session persistence: loginctl enable-linger username. With systemd and root, this doesn't apply.
Summary
Point your DNS to the proxy server. Let nginx handle SSL and routing. Services sit behind an encrypted tunnel with no public exposure. It takes 15 minutes to set up and scales to as many servers as you need.