Datacenter gave me exactly one usable public IP and I had four things that wanted it: a web panel, a Windows box for RDP, a game server, and the Proxmox UI itself. Buying more IPs wasn't happening. So the host keeps the IP and hands out ports — :80 and :443 go to a container, :3389 and the game ports go to a VM, everything else stays on the host.

This is plain destination NAT. The part nobody tells you is which three things silently break it: the Proxmox firewall's egress rules, ifupdown2 mangling your alias, and rules that vanish on reboot. All three cost me an evening.

TL;DR — the working shape:

# host holds the public IP on vmbr0, VMs live on a private bridge vmbr1 (10.10.10.0/24)
nft add rule ip nat prerouting iif eth0 tcp dport { 80, 443 } dnat to 10.10.10.2
nft add rule ip nat prerouting iif eth0 tcp dport 3389 dnat to 10.10.10.106
nft add rule ip nat postrouting oif vmbr1 ip saddr 10.10.10.0/24 masquerade

Then persist it as a systemd unit, because nft add rule on the CLI dies with the next boot.

The layout

Two bridges. vmbr0 is the real one, attached to the physical NIC, carrying the public IP. vmbr1 is internal only — no physical port, no uplink — and it's the LAN every guest sits on:

/etc/network/interfaces

auto vmbr0
iface vmbr0 inet static
    address 203.0.113.100/24
    gateway 203.0.113.1
    bridge-ports enp1s0
    bridge-stp off
    bridge-fd 0

auto vmbr1
iface vmbr1 inet static
    address 10.10.10.1/24
    bridge-ports none
    bridge-stp off
    bridge-fd 0

Guests get 10.10.10.x with gateway 10.10.10.1 — the host. Panel container on .2, Windows VM on .106. Nothing in the guests knows or cares that there's NAT in front of them.

Enable forwarding on the host, permanently:

echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-forward.conf
sysctl --system

Forget this and every rule below will look correct in nft list ruleset while doing absolutely nothing. It's the first thing to check when packets arrive and never leave.

The rules

Two directions to handle. Inbound: rewrite the destination on packets hitting the public IP on a port you've claimed. Outbound: masquerade so guests can reach the internet with the host's IP as the source.

nft add table ip nat
nft add chain ip nat prerouting  '{ type nat hook prerouting priority dstnat; }'
nft add chain ip nat postrouting '{ type nat hook postrouting priority srcnat; }'

# web → panel container
nft add rule ip nat prerouting iif enp1s0 tcp dport { 80, 443 } dnat to 10.10.10.2

# RDP + game ports → Windows VM
nft add rule ip nat prerouting iif enp1s0 tcp dport 3389 dnat to 10.10.10.106
nft add rule ip nat prerouting iif enp1s0 udp dport 30120 dnat to 10.10.10.106

# guests out to the world
nft add rule ip nat postrouting oif enp1s0 ip saddr 10.10.10.0/24 masquerade

Note iif enp1s0 — the physical interface, not vmbr0. Matching on the bridge works too, but pinning to the NIC means traffic that originates on the internal bridge never accidentally matches an inbound rule.

Ports you don't claim stay with the host, which is how the Proxmox UI keeps its :8006 and SSH keeps :22. Claim :8006 for a guest by accident and you've locked yourself out of the hypervisor — over IPMI or nothing. Don't.

Gotcha 1: pve-firewall drops your forwarded traffic

You'll add the DNAT rule, nft list ruleset shows it, the counter even increments — and the guest never sees a packet. That's pve-firewall, which installs its own chains and by default is not friendly to traffic you're routing through the host to a guest.

Check whether the firewall is on at all:

pve-firewall status

If it's active, the forwarded traffic needs to be allowed at the datacenter level, not just the guest's. In /etc/pve/firewall/cluster.fw:

[OPTIONS]
enable: 1
policy_forward: ACCEPT

policy_forward is the one. Guest-level rules can't save you if the cluster policy is dropping in FORWARD. If you'd rather not think about it, pve-firewall stop && systemctl disable pve-firewall and put your filtering in nftables directly — that's what I did, since the host's job here is routing, and one firewall is easier to reason about than two. Just don't run with no filtering: see /ufw-firewall-rules-ubuntu/ for the mindset, and don't expose guest management ports you didn't mean to.

Gotcha 2: ifupdown2 and the :alias interface

The old trick for a second IP was an alias — enp1s0:0, vmbr0:1. On modern Proxmox with ifupdown2, aliases declared that way get half-applied: ip addr shows the address, but the bridge doesn't reliably own it after ifreload -a, and after a reboot it can be gone entirely while everything looks configured.

Don't use :alias notation. If a guest genuinely needs a second address, add it to the bridge as a second address line under the same iface, or just accept the NAT and move on. I stopped fighting it and let the host own the single IP outright — the port split makes the alias unnecessary anyway.

Gotcha 3: rules die on reboot

nft add rule is runtime state. A reboot wipes it and you'll be staring at a dead panel wondering what changed. Write the ruleset to a file and let systemd load it after the network is up.

/etc/nftables-ipsplit.conf:

#!/usr/sbin/nft -f
flush table ip nat

table ip nat {
    chain prerouting {
        type nat hook prerouting priority dstnat;
        iif "enp1s0" tcp dport { 80, 443 } dnat to 10.10.10.2
        iif "enp1s0" tcp dport 3389 dnat to 10.10.10.106
        iif "enp1s0" udp dport 30120 dnat to 10.10.10.106
    }
    chain postrouting {
        type nat hook postrouting priority srcnat;
        oif "enp1s0" ip saddr 10.10.10.0/24 masquerade
    }
}

/etc/systemd/system/ip-split.service:

[Unit]
Description=Public IP port split (DNAT to guests)
After=network-online.target pve-guests.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/sbin/nft -f /etc/nftables-ipsplit.conf
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
systemctl enable --now ip-split.service

After=network-online.target matters — load the rules before enp1s0 exists and nft errors out on the unknown interface, leaving you with no NAT and a unit that looks "enabled". The flush table ip nat at the top makes it idempotent: re-run it as many times as you like. If systemd units are new to you, /systemd-service-unit-file/ covers the anatomy.

Then actually reboot and verify. An untested reboot-persistence config is a config that doesn't persist.

Verifying

From outside the box:

curl -I http://203.0.113.100/          # should hit the panel container
nc -vz 203.0.113.100 3389              # should hit the Windows VM

On the host, watch the counters move:

nft list table ip nat -a       # 'packets N bytes M' per rule
conntrack -L | grep 10.10.10   # live translated connections

If the counter increments but the guest is silent: forwarding is off, or a firewall is dropping in FORWARD. If the counter never moves: the rule isn't matching — wrong iif, wrong protocol, or something upstream (provider firewall) is eating the port.

Hairpin, the thing that'll confuse you later

Guest A on 10.10.10.2 tries to reach your public IP on a port that maps to guest B. The packet leaves, hits the host, gets DNAT'd — and B replies directly to A on the internal LAN, with a source address A never expected. A drops it. Connection hangs.

Fix: masquerade the internal-to-internal case so B sees the request as coming from the host and replies back through it.

iif "vmbr1" ip saddr 10.10.10.0/24 ip daddr 10.10.10.0/24 masquerade

Or just tell your guests to use internal addresses for each other, which is faster and saves the NAT round-trip. I do the latter — hairpin NAT is a footgun you can mostly design around.

Is this worth it vs. buying an IP?

Honestly: if a second IP is €2/month, buy the IP. This setup earns its keep when extra IPs are expensive, slow to provision, or capped — and once it's running, it's genuinely stable. Mine has been forwarding web, RDP and game traffic for months without a hand touching it.

What you lose: any protocol that embeds its own IP in the payload (old FTP, some SIP, certain game query protocols) needs a helper or careful port mapping. And you can't run two things that both insist on :443 — that's what a reverse proxy in front is for. Put nginx or Caddy on the guest that owns 80/443 and fan out by hostname from there; combine the two and one IP goes a very long way.


Related posts