Use a WireGuard tunnel to carry traffic between an NGINX reverse proxy and services on another VPS. Visitors connect to the proxy over HTTPS; the proxy connects to the backend over the tunnel.
This example routes traffic to the listed tunnel addresses only. It does not move all server traffic into the VPN or block other public listeners. Restrict backend exposure separately and test it.
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. Each backend accepts connections on its tunnel address. This example does not enable routing between backends.
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 support in the installed Linux kernel.
Before changing the network
Test the browser console on every VPS and keep an independent SSH connection open. Choose private addresses that do not overlap your existing networks. The addresses, keys and hostnames below are examples; replace them before use. Back up existing configurations and do not overwrite a working wg0 interface.
Step 1: Install WireGuard
Run on all servers:
apt update apt install -y wireguard
Step 2: Generate Keys
On each server, generate a new key pair only if these files do not already exist. Set restrictive permissions before creating private-key material:
umask 077 wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key chmod 600 /etc/wireguard/private.key
Exchange public keys only. Protect each wg0.conf with mode 600 because it contains the private key. Do not paste private keys into support tickets.
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
For fixed server peers, restrict this port to their public addresses. Use your existing firewall manager; do not enable UFW alongside firewalld. Preserve SSH access, allow HTTPS to the proxy and allow the application port on each backend from the proxy tunnel address. These two UDP rules alone are not a complete firewall configuration.
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');
Recreate the affected container or reload the native service using its documented procedure, then check the actual listeners and port mappings. Docker networking can bypass ordinary UFW input rules. Test that the backend port is unreachable through every public IPv4 and IPv6 address, while remaining reachable from the proxy. Binding to a tunnel address alone is not proof of complete isolation.
Step 8: Configure nginx Reverse Proxy
Install NGINX on the proxy, point your domain to its public address and obtain a valid TLS certificate before enabling this example. The certificate paths below must exist. Add the virtual host using your distribution's NGINX layout. The proxy_pass target is the backend's tunnel address.
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;
}
}
This example includes WebSocket upgrade headers. For a mixed HTTP and WebSocket application, follow the conditional map pattern in the NGINX WebSocket documentation; do not assume every application needs an unconditional upgrade header. Configure the application to trust forwarded headers only from your proxy.
Run nginx -t before reloading NGINX. If validation fails, restore or fix the configuration without reloading. After a successful reload, test HTTPS and any WebSocket functionality from a client.
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
}
Replace the example allowlist with your actual trusted client addresses. A VPN subnet matches only if NGINX sees that subnet as the client source; a VPN user accessing the public endpoint may appear under a public address instead. If another trusted proxy sits in front, configure real-client-IP handling explicitly rather than trusting arbitrary forwarded headers. Test permitted and denied clients.
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
- Apply the peer change using the documented WireGuard update method for your system. Do not take the hub tunnel down over your only connection: that interrupts every existing backend. Keep the browser console available and arrange a maintenance window if a restart is required.
- Start the tunnel on the new server:
systemctl enable --now wg-quick@wg0
No changes needed on existing backend servers.
What This Gets You
- Reduced backend exposure: only the intended proxy and administration endpoints should remain public after you have verified bindings and firewall rules.
- Encrypted tunnel traffic: traffic sent to the configured peer addresses crosses the WireGuard tunnel. Other routes remain unchanged.
- 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.
- Resource use: measure encryption, proxy and application load on your chosen VPS.
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 unavailable after reboot
Check systemctl status wg-quick@wg0 and its journal. Ensure services binding to the tunnel address start only after that address exists.
Summary
Verify the complete route: HTTPS to the proxy, tunnel access to the backend and denied public access to backend ports. Recheck after container, firewall or network changes. See the WireGuard quickstart and Docker firewall documentation.