Serve a home server through a VPS with WireGuard and nginx
I have a server rack full of servers in my garage. Renting CPU, memory, and disk space from a hosting provider means paying for hardware I already own. Instead, I run the sites on my own machines and rent only what my ISP does not provide, which is a static public IP address.
That is the virtual private server's (VPS) role here. It is the cheapest instance the provider sells, and it does three things: hold the IP address, terminate TLS, and pass each request down a WireGuard tunnel to a machine in my garage that serves it. The garage server opens the tunnel, so nothing has to connect inward to my home network.
Here is the path a request takes:
browser
│ https://home.gregdonald.com
▼
VPS nginx ──[ wg0: 10.9.0.1 ]
│
WireGuard tunnel
│
garage server ──[ wg0: 10.9.0.2 ]
│
nginx on 10.9.0.2:3000
Because the garage server initiates the tunnel, the home router only sees an outbound UDP flow, so there is no port forwarding to configure, no dynamic DNS to register, and no inbound firewall rule to add.
Both machines run latest Debian.
Pick a tunnel subnet#
My LAN is 10.0.0.0/24, so the tunnel gets 10.9.0.0/24 to keep the two from colliding. The VPS is 10.9.0.1 and the garage server is 10.9.0.2. If by chance your LAN already uses 10.9.x, pick something else.
Install WireGuard on both machines#
apt-get update && apt-get install -y wireguard
Generate a keypair on each machine#
On the garage server:
umask 077 \
&& wg genkey > /etc/wireguard/local.key \
&& wg pubkey < /etc/wireguard/local.key > /etc/wireguard/local.pub \
&& cat /etc/wireguard/local.pub
On the VPS:
umask 077 \
&& wg genkey > /etc/wireguard/server.key \
&& wg pubkey < /etc/wireguard/server.key > /etc/wireguard/server.pub \
&& cat /etc/wireguard/server.pub
umask 077 creates the key files mode 600, and wg-quick warns when a private key is readable by other users. Each machine keeps its own private key and receives the other machine's public key, so the two values printed above are the only ones that move between machines.
Configure the VPS side#
The VPS is the listening peer. Paste the garage server's public key into PublicKey:
umask 077 && cat > /etc/wireguard/wg0.conf <<CONF
[Interface]
Address = 10.9.0.1/24
ListenPort = 51820
PrivateKey = $(cat /etc/wireguard/server.key)
[Peer]
PublicKey = <garage server public key>
AllowedIPs = 10.9.0.2/32
CONF
AllowedIPs in a peer block does two jobs. It routes traffic addressed to 10.9.0.2 to this peer, and it drops packets arriving from this peer that carry any other source address.
Bring it up:
wg-quick up wg0 && wg show
If your provider has a cloud firewall, allow UDP 51820 to the VPS there. On the host itself there is nothing to open unless you run ufw or nftables.
Configure the garage side#
This end is the dialer, so it gets an Endpoint and a keepalive:
umask 077 && cat > /etc/wireguard/wg0.conf <<CONF
[Interface]
Address = 10.9.0.2/24
PrivateKey = $(cat /etc/wireguard/local.key)
[Peer]
PublicKey = <VPS public key>
Endpoint = <VPS public IP>:51820
AllowedIPs = 10.9.0.1/32
PersistentKeepalive = 25
CONF
PersistentKeepalive = 25 sends a packet every 25 seconds. Without it, the NAT mapping in the home router expires after a couple of minutes of silence, and the VPS has no return path until the garage server sends something. WireGuard keeps no connection state to re-establish, so an expired mapping reports nothing anywhere until a request times out.
Bring it up and check the tunnel:
wg-quick up wg0 && ping -c 3 10.9.0.1
64 bytes from 10.9.0.1: icmp_seq=1 ttl=64 time=112 ms
64 bytes from 10.9.0.1: icmp_seq=2 ttl=64 time=62.8 ms
64 bytes from 10.9.0.1: icmp_seq=3 ttl=64 time=59.9 ms
Serve something on the garage server#
apt-get install -y nginx
mkdir -p /var/www/garage
Write a page at /var/www/garage/index.html, then a server block that listens on the tunnel address and a port of your choosing:
server {
listen 10.9.0.2:3000;
server_name _;
root /var/www/garage;
index index.html;
}
Binding to 10.9.0.2 rather than 0.0.0.0 means the site answers on the tunnel and nowhere else. Nothing on my LAN or my home Wi-Fi can reach it.
ln -sf /etc/nginx/sites-available/garage \
/etc/nginx/sites-enabled/garage \
&& nginx -t \
&& systemctl reload nginx
Check the tunnel carries HTTP#
From the VPS:
curl -s http://10.9.0.2:3000/
The page comes back. Run this before configuring the public vhost, so a later failure tells you whether the tunnel or the proxy is at fault.
Proxy to it from the VPS#
server {
listen 80;
server_name home.gregdonald.com;
location / {
proxy_pass http://10.9.0.2:3000;
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;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
The garage nginx sees every request as coming from 10.9.0.1. Without the X-Forwarded-* headers, its logs and any application behind it record the tunnel address rather than the client address.
Enable and reload:
ln -sf /etc/nginx/sites-available/home.gregdonald.com.conf \
/etc/nginx/sites-enabled/home.gregdonald.com.conf \
&& nginx -t \
&& systemctl reload nginx
Point an A record for the hostname at the VPS public IP.
Add TLS#
The certificate lives on the VPS, since that is where the public connection terminates. The tunnel handles encryption for the second hop.
apt-get install -y certbot python3-certbot-nginx
certbot --nginx \
-d home.gregdonald.com \
--redirect \
--agree-tos \
-m you@example.com \
--no-eff-email
Certbot rewrites the vhost to listen on 443 with the certificate, and adds an HTTP block that redirects to HTTPS. Confirm renewal works:
certbot renew --dry-run
Start the tunnel at boot#
wg-quick up does not persist across a reboot. Enable the unit on both machines:
systemctl enable wg-quick@wg0
Result#
The hostname resolves to the VPS, serves a Let's Encrypt certificate, and returns pages from hardware I actually own. The rented part is one small instance holding an IP address, and its price does not change with the CPU or disk the site behind it uses. A second site means another server block on the VPS and another peer on the tunnel rather than a new hosting bill.
The VPS is also the only host exposed to the internet, and the only thing it can reach on my home network is port 3000 on 10.9.0.2.
The same arrangement works for anything that speaks HTTP. Point proxy_pass at the port it listens on and bind it to the tunnel address.