Port forwarding with nftables relies on Destination Network Address Translation (DNAT) to redirect incoming traffic destined for a specific port on your firewall’s public IP address to a different IP address and port within your internal network.

Let’s set up a scenario: Your firewall has a public IP 192.0.2.1 on its external interface eth0 and an internal IP 10.0.0.1 on its internal interface eth1. You want to forward incoming traffic on port 8080 of your public IP to an internal web server at 10.0.0.100 on port 80.

First, we need to define our table and chain for NAT operations. The nat table is used for this purpose, and we’ll typically use the prerouting chain to intercept packets as they arrive before routing decisions are made.

nft add table ip nat
nft add chain ip nat prerouting { type nat hook prerouting priority -100 \; }

Now, we add the DNAT rule. This rule tells nftables to change the destination IP and port for packets that match our criteria.

nft add rule ip nat prerouting iifname "eth0" tcp dport 8080 dnat to 10.0.0.100:80

Here’s what’s happening:

  • ip nat prerouting: We’re operating in the nat table, prerouting chain, for IPv4.
  • iifname "eth0": This matches packets arriving on the external interface eth0. This is crucial to ensure we only translate traffic coming from the outside.
  • tcp dport 8080: This matches TCP packets destined for port 8080. If you were forwarding UDP, you’d use udp dport 8080.
  • dnat to 10.0.0.100:80: This is the actual translation. The destination IP address is changed to 10.0.0.100, and the destination port is changed to 80.

To make this persistent across reboots, you’ll need to save your nftables rules. The common way is to dump them to a file and have a service load them on startup.

nft list ruleset > /etc/nftables.conf

And ensure the nftables service is enabled:

systemctl enable nftables
systemctl start nftables

What most people miss is that for the internal server to respond correctly, the firewall needs to perform Source NAT (SNAT) on the return traffic. Without SNAT, the internal server will send its reply back to the firewall’s internal IP (10.0.0.1), and the client on the internet will see the reply coming from 10.0.0.1, not the public IP (192.0.2.1). This will cause the connection to break because the client expects the reply to come from the IP it initiated the connection to.

To address this, you’ll add a SNAT rule in the postrouting chain. This rule masquerades the source IP of outgoing packets from your internal network to use the firewall’s public IP.

nft add table ip nat
nft add chain ip nat postrouting { type nat hook postrouting priority 100 \; }
nft add rule ip nat postrouting oifname "eth0" ip saddr 10.0.0.0/24 masquerade
  • ip nat postrouting: We’re in the nat table, postrouting chain, for IPv4.
  • oifname "eth0": This matches packets leaving the firewall on the external interface eth0.
  • ip saddr 10.0.0.0/24: This matches packets originating from your internal network (assuming 10.0.0.0/24 is your internal subnet).
  • masquerade: This is a shorthand for SNAT where the source IP is replaced with the IP of the outgoing interface (eth0).

The next hurdle you’ll often encounter is ensuring that the firewall’s own filter rules allow this forwarded traffic to pass.

Want structured learning?

Take the full Nftables course →