If you run a Proxmox homelab or manage a dedicated server, you’ve probably wanted to route some VMs through a VPN while keeping others on your regular internet connection. Maybe you want to hide the origin IP for certain workloads, bypass geo-restrictions for specific services, or just keep your torrenting VM separate from your web server.
The problem? Most VPN guides are all-or-nothing. Connect to a VPN and everything goes through it—including your SSH session, which promptly dies.
In this post, I’ll show you how to set up selective VPN routing in Proxmox. We’ll create two internal networks: one that uses your normal internet connection, and another that routes all traffic through a VPN. VMs can live on either network depending on your needs.
What We’re Building
Here’s the architecture:
vmbr1 VMs see the internet through your real IP address. vmbr2 VMs see it through your VPN provider’s IP. Internal traffic between bridges stays local—no VPN involved.
The Secret Sauce: Policy-Based Routing
Linux doesn’t limit you to a single routing table. You can create multiple tables and use policy rules to decide which one applies to each packet.
The default setup looks like this:
$ ip rule show
0: from all lookup local
32766: from all lookup main
32767: from all lookup default
Every packet checks these rules in order. The main table contains your default route (through your ISP). We’re going to insert a new rule that says: “if the packet comes from 10.0.0.0/24, use a different table that routes through the VPN.”
$ ip rule show
0: from all lookup local
100: from 10.0.0.0/24 lookup vpn ← Our new rule
32766: from all lookup main
32767: from all lookup default
Now vmbr2 traffic (10.0.0.0/24) uses the VPN table, while everything else uses the main table. Simple and elegant.
Prerequisites
- Proxmox VE 7.x or 8.x
- Root SSH access
- A VPN provider that supports OpenVPN or WireGuard
- About 30 minutes
Step 1: Configure the Network Bridges
First, backup your current config:
cp /etc/network/interfaces /etc/network/interfaces.backup
Edit /etc/network/interfaces. Here’s a template—adjust the IPs for your environment:
source /etc/network/interfaces.d/*
auto lo
iface lo inet loopback
# Your physical NIC (check with 'ip link')
auto eno1
iface eno1 inet manual
# vmbr0 - WAN bridge (your public or LAN IP)
auto vmbr0
iface vmbr0 inet static
address YOUR_IP/CIDR
gateway YOUR_GATEWAY
bridge-ports eno1
bridge-stp off
bridge-fd 0
# vmbr1 - Regular NAT network
auto vmbr1
iface vmbr1 inet static
address 192.168.1.1/24
bridge-ports none
bridge-stp off
bridge-fd 0
post-up echo 1 > /proc/sys/net/ipv4/ip_forward
post-up iptables -t nat -A POSTROUTING -s '192.168.1.0/24' -o vmbr0 -j MASQUERADE
post-down iptables -t nat -D POSTROUTING -s '192.168.1.0/24' -o vmbr0 -j MASQUERADE
# vmbr2 - VPN-routed network
auto vmbr2
iface vmbr2 inet static
address 10.0.0.1/24
bridge-ports none
bridge-stp off
bridge-fd 0
Notice vmbr2 doesn’t have NAT rules. We’ll handle that in the VPN scripts.
Apply the changes:
ifreload -a
Step 2: Set Up the VPN
I’ll cover both OpenVPN and WireGuard. Pick whichever your provider supports.
Option A: OpenVPN
Install it:
apt update && apt install openvpn -y
Copy your provider’s .ovpn file:
cp your-vpn-config.ovpn /etc/openvpn/vpn-client.conf
If your VPN needs credentials, create a file:
cat > /etc/openvpn/credentials.txt << 'EOF'
your_username
your_password
EOF
chmod 600 /etc/openvpn/credentials.txt
Edit the config to use these credentials and our custom routing:
nano /etc/openvpn/vpn-client.conf
Add or modify these lines:
auth-user-pass /etc/openvpn/credentials.txt
route-noexec
script-security 2
up /etc/openvpn/vpn-up.sh
down /etc/openvpn/vpn-down.sh
The route-noexec is crucial—it tells OpenVPN not to mess with our routing. We’ll handle it ourselves.
Option B: WireGuard
Install it:
apt install wireguard -y
Create the config:
nano /etc/wireguard/wg0.conf
[Interface]
PrivateKey = YOUR_PRIVATE_KEY
Table = off
PostUp = /etc/wireguard/wg-up.sh
PostDown = /etc/wireguard/wg-down.sh
[Peer]
PublicKey = VPN_SERVER_PUBLIC_KEY
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
Again, Table = off prevents WireGuard from touching our routes.
Step 3: Create the Routing Scripts
This is where the magic happens. First, register a custom routing table:
echo "100 vpn" >> /etc/iproute2/rt_tables
Now create the up script. For OpenVPN, save this as /etc/openvpn/vpn-up.sh:
#!/bin/bash
# OpenVPN passes $dev (tun0) and $route_vpn_gateway
# Local routes - traffic to these networks stays local
ip route add 10.0.0.0/24 dev vmbr2 table 100
ip route add 192.168.1.0/24 dev vmbr1 table 100
# Everything else goes through VPN
ip route add default via $route_vpn_gateway dev $dev table 100
# Apply this table to traffic FROM vmbr2
ip rule add from 10.0.0.0/24 table 100 priority 100
# NAT vmbr2 traffic through VPN interface
iptables -t nat -A POSTROUTING -s '10.0.0.0/24' -o $dev -j MASQUERADE
echo "$(date): VPN routing configured" >> /var/log/vpn-routing.log
And the down script (/etc/openvpn/vpn-down.sh):
#!/bin/bash
ip rule del from 10.0.0.0/24 table 100 priority 100 2>/dev/null
ip route flush table 100
iptables -t nat -D POSTROUTING -s '10.0.0.0/24' -o $dev -j MASQUERADE 2>/dev/null
echo "$(date): VPN routing cleaned up" >> /var/log/vpn-routing.log
Make them executable:
chmod +x /etc/openvpn/vpn-up.sh /etc/openvpn/vpn-down.sh
For WireGuard, the scripts are nearly identical—save them as /etc/wireguard/wg-up.sh and /etc/wireguard/wg-down.sh. The only difference is the default route line:
# WireGuard version
ip route add default dev wg0 table 100
Step 4: Start the VPN
For OpenVPN:
systemctl start openvpn@vpn-client
systemctl enable openvpn@vpn-client
For WireGuard:
wg-quick up wg0
systemctl enable wg-quick@wg0
Step 5: Verify It Works
Check that everything is in place:
# VPN interface exists
ip link show tun0 # or wg0
# Routing table 100 has our routes
ip route show table 100
# Policy rule is active
ip rule show | grep 10.0.0.0
# NAT rule exists
iptables -t nat -L POSTROUTING -n -v | grep tun0
Step 6: Test with a VM
Create a VM and attach it to vmbr2. Configure its network:
- IP: 10.0.0.50/24
- Gateway: 10.0.0.1
- DNS: 1.1.1.1
From inside the VM:
curl ifconfig.me
You should see your VPN provider’s IP, not your server’s real IP.
Now create another VM on vmbr1 (192.168.1.x) and run the same test—you’ll see your real IP.
The “I Can’t SSH to My VMs” Problem
Here’s a gotcha that trips up everyone the first time: you set this up, try to SSH from the Proxmox host to a vmbr2 VM, and it hangs.
Why? When the VM responds, the packet’s source address is 10.0.0.50. Our policy rule matches it and sends the response through the VPN instead of back to the host.
The fix is in our up script—those local routes in table 100:
ip route add 10.0.0.0/24 dev vmbr2 table 100
ip route add 192.168.1.0/24 dev vmbr1 table 100
These ensure traffic destined for local networks takes the direct path, not the VPN. If SSH still hangs, double-check these routes exist:
ip route show table 100 | grep vmbr2
Adding a Kill Switch
What if the VPN disconnects? By default, vmbr2 VMs would fall back to your real IP—probably not what you want.
Add this to your vmbr2 config in /etc/network/interfaces:
post-up iptables -I FORWARD -s 10.0.0.0/24 -o vmbr0 -j DROP
post-down iptables -D FORWARD -s 10.0.0.0/24 -o vmbr0 -j DROP
Now if the VPN goes down, vmbr2 traffic is blocked rather than leaked.
Monitoring
A simple cron job to restart the VPN if it dies:
cat > /usr/local/bin/vpn-monitor.sh << 'EOF'
#!/bin/bash
if ! ip link show tun0 &>/dev/null; then
systemctl restart openvpn@vpn-client
fi
EOF
chmod +x /usr/local/bin/vpn-monitor.sh
echo "*/5 * * * * /usr/local/bin/vpn-monitor.sh" >> /etc/crontab
Common Issues
VMs can’t reach the internet: Check VPN is connected (ip link show tun0), routing table exists (ip route show table 100), and NAT rule is in place.
Slow speeds: Try a different VPN server. WireGuard is generally faster than OpenVPN.
DNS not working: Configure VMs to use public DNS (1.1.1.1, 8.8.8.8) or your VPN provider’s DNS servers.
VPN keeps disconnecting: Add keepalive 10 60 to OpenVPN config, or ensure PersistentKeepalive = 25 in WireGuard.
Wrapping Up
Policy-based routing is one of those Linux features that seems complex until it clicks. The core concept is simple: different source addresses can use different routing tables, and each table can have its own default gateway.
With this setup, you can:
- Run a web server on your real IP while torrenting through a VPN
- Test geo-restricted APIs without affecting other services
- Isolate sketchy workloads from your main infrastructure
- Mix and match as needed—just move VMs between bridges
The configuration survives reboots, the VPN auto-reconnects, and your SSH sessions stay alive. What more could you ask for?