Stop Broken LAN Port Forwards: Practical nftables SNAT, DNAT, Masquerade, and Hairpin NAT
You published a port forward. External clients reach the service. Clients on the same LAN try the public hostname or WAN IP and hang. Or outbound traffic works until the ISP rotates your address and every hard-coded SNAT rule dies.
That is not mysterious routing. It is incomplete Network Address Translation (NAT) ownership in nftables: wrong chain type, missing hairpin SNAT, or SNAT pinned to an address that no longer exists.
This article is a practical operator guide to stateful nftables NAT on Linux:
-
masqueradefor dynamic WAN addresses -
snatfor stable public addresses and pools -
dnatfor port forwards and multi-service maps -
redirectfor local-only rewrites - hairpin NAT so LAN clients can use the public name/IP
- verification, persistence, and rollback
It is intentionally about address translation, not anti-spoof FIB checks, SYNPROXY, dynamic ban sets, or flowtable fastpath.
What you need
- Linux with nftables userspace (
nft) and kernel Netfilter NAT support - Root (or equivalent) to load rules and enable forwarding
- A host that forwards traffic (router/gateway), or a lab netns pair
- Kernel 4.18+ recommended so you do not need empty prerouting/postrouting NAT chains just to make reply traffic work
- Kernel 5.2+ if you want inet-family stateful NAT (IPv4+IPv6 in one table)
Check tools:
nft --version
sysctl net.ipv4.ip_forward
lsmod | grep -E 'nf_nat|nft_nat|nf_conntrack' || true
Enable IPv4 forwarding when the box is a router (runtime + persistent example):
sysctl -w net.ipv4.ip_forward=1
printf 'net.ipv4.ip_forward=1\n' > /etc/sysctl.d/99-ip-forward.conf
sysctl --system
Without forwarding, DNAT to a LAN host never leaves the gateway.
Mental model: NAT is not a filter chain
nftables wiki is explicit: the nat chain type has special semantics.
- The first packet of a flow looks up NAT rules and creates a NAT binding.
- Later packets in that flow do not re-walk NAT rules; the NAT engine reuses the binding.
- Putting a NAT statement in a
filterchain is an error.
Create dedicated NAT base chains on the right hooks and priorities:
| Goal | Hook | Typical priority keyword / value |
|---|---|---|
| Destination rewrite (DNAT / redirect) |
prerouting (and sometimes output) |
dstnat / -100
|
| Source rewrite (SNAT / masquerade) | postrouting |
srcnat / 100
|
Filter still matters. NAT rewrites addresses; filter decides whether the rewritten flow is allowed in forward / input. Treat them as two layers.
Packet path for a forwarded flow:
prerouting (DNAT) → forward (filter) → postrouting (SNAT/masquerade)
Locally generated traffic uses output then postrouting. Local delivery after DNAT-to-self uses input.
Lab topology (names used below)
Internet / "WAN"
|
eth0 (WAN) — gateway 203.0.113.10 (or DHCP)
eth1 (LAN) — gateway 192.168.1.1/24
|
LAN hosts — 192.168.1.0/24
web backend — 192.168.1.50:80
Replace interface names and prefixes with yours. Use RFC 5737 documentation addresses in examples if you paste configs into tickets.
1) Outbound Internet access: masquerade first
For a home/lab gateway whose WAN address can change, prefer masquerade. It is SNAT where the source address is taken from the egress interface automatically (available since Linux 3.18).
Minimal NAT table:
nft add table ip nat
nft 'add chain ip nat postrouting { type nat hook postrouting priority srcnat; policy accept; }'
nft add rule ip nat postrouting ip saddr 192.168.1.0/24 oifname "eth0" masquerade
Or as a file fragment (/etc/nftables.d/nat-masq.nft style):
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 192.168.1.0/24 oifname "eth0" masquerade
}
}
You still need a forward policy that allows LAN → WAN (and usually established/related back). The nftables home-router sample does both: allow private ingress to forward, then masquerade private sources out the world interface.
Sketch:
table ip filter {
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iifname "eth1" oifname "eth0" accept
}
}
When to use snat instead of masquerade
Use SNAT when the public address is stable and you want an explicit mapping (or a pool):
nft add rule ip nat postrouting ip saddr 192.168.1.0/24 oifname "eth0" snat to 203.0.113.10
Pools and ranges are supported:
# prefix pool
nft add rule ip nat postrouting snat to 203.0.113.10/31
# address range
nft add rule ip nat postrouting snat to 203.0.113.10-203.0.113.20
# optional TCP source-port range with address pool
nft add rule ip nat postrouting ip protocol tcp snat to 203.0.113.10-203.0.113.20:30000-40000
NAT flags (kernel 3.18+), combinable:
-
random— randomize source port mapping -
fully-random— full port randomization -
persistent— prefer the same address mapping per client
nft add rule ip nat postrouting ip saddr 192.168.1.0/24 oifname "eth0" \
snat to 203.0.113.10 fully-random
Rule of thumb: dynamic WAN → masquerade; fixed WAN or multi-IP egress → snat.
2) Inbound services: DNAT port forwards
DNAT belongs on prerouting with destination-NAT priority:
nft 'add chain ip nat prerouting { type nat hook prerouting priority dstnat; policy accept; }'
nft 'add rule ip nat prerouting iifname "eth0" tcp dport { 80, 443 } dnat to 192.168.1.50'
That rewrites destination to the LAN backend for new flows arriving on the WAN interface.
Forward filter for the rewritten destination
DNAT alone is not permission. Allow the post-DNAT path explicitly:
table ip filter {
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
# LAN out
iifname "eth1" oifname "eth0" accept
# WAN → published web backend (after DNAT, daddr is internal)
iifname "eth0" oifname "eth1" ip daddr 192.168.1.50 tcp dport { 80, 443 } accept
}
}
If you only match the pre-DNAT public address in forward, you will drop traffic you thought you published.
Redirect: DNAT to the local machine
redirect is a special DNAT that targets the local host (kernel 3.19+). It only makes sense in prerouting and output NAT chains.
# send inbound SSH on 22 to local 2222
nft add rule ip nat prerouting tcp dport 22 redirect to 2222
# send locally generated DoT (853/tcp) to a local proxy on 10053
nft add rule ip nat output tcp dport 853 redirect to 10053
Use redirect for local proxies and “listen elsewhere” tricks. Use dnat to <lan-ip> when the real service is another host.
3) Map-based multi-port DNAT (stop cloning rules)
If you maintain a wall of one-off DNAT lines, collapse them with maps.
Classic many-rule pattern becomes one DNAT statement with two maps (service port → backend IP, service port → backend port):
nft add rule ip nat prerouting dnat to \
tcp dport map { 8080 : 192.168.1.50, 8443 : 192.168.1.50, 9000 : 192.168.1.60 } \
: tcp dport map { 8080 : 80, 8443 : 443, 9000 : 9000 }
Named map with concatenated address and port (great for many UDP/TCP publishes):
nft add map ip nat published {
type inet_service : ipv4_addr . inet_service
}
nft add element ip nat published {
8080 : 192.168.1.50 . 80,
8443 : 192.168.1.50 . 443,
9000 : 192.168.1.60 . 9000
}
nft add rule ip nat prerouting iifname "eth0" \
dnat ip addr . port to tcp dport map @published
Anonymous maps work when the table is static. Named maps are better when you update publishes without rewriting the whole chain.
4) Hairpin NAT: why LAN clients cannot use the public IP
The failure mode
- LAN client
192.168.1.20connects to public203.0.113.10:443. - DNAT rewrites destination to
192.168.1.50:443. - Backend replies to
192.168.1.20directly (same L2/L3 LAN). - Client expected replies from
203.0.113.10, not192.168.1.50. - Stateful stack drops the asymmetric nonsense. Connection fails or stalls.
External clients never hit this path because their return traffic must reverse through the gateway’s NAT binding.
The fix: SNAT hairpinned LAN→LAN-via-public flows
After DNAT, if the packet still leaves toward the LAN and the original client was also on the LAN, SNAT the source to the gateway’s LAN address. The backend then replies to the gateway; the gateway un-SNATs and un-DNATs correctly toward the client.
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
# publish HTTPS on WAN address/name to LAN backend
tcp dport 443 dnat to 192.168.1.50
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
# normal Internet egress
ip saddr 192.168.1.0/24 oifname "eth0" masquerade
# hairpin: LAN client used public VIP/hostname, now going back into LAN
ip saddr 192.168.1.0/24 ip daddr 192.168.1.50 oifname "eth1" \
snat to 192.168.1.1
}
}
Notes operators miss:
- Match post-DNAT destination (
192.168.1.50), not only the public address. - Hairpin SNAT target is usually the LAN gateway IP on
eth1, not the WAN IP. - You still need forward rules that allow LAN→LAN via the gateway for that service if your forward policy is not “accept all from LAN”.
- Some designs avoid hairpin entirely with split-horizon DNS (internal name →
192.168.1.50). Hairpin is what you want when one hostname must work everywhere.
5) inet-family NAT (IPv4 + IPv6 together)
Since Linux 5.2, stateful NAT works in inet tables. When you specify addresses, mark the family:
nft add table inet nat
nft 'add chain inet nat prerouting { type nat hook prerouting priority dstnat; }'
nft 'add chain inet nat postrouting { type nat hook postrouting priority srcnat; }'
nft add rule inet nat prerouting dnat ip to 192.168.1.50
nft add rule inet nat prerouting dnat ip6 to 2001:db8:1::50
nft add rule inet nat postrouting oifname "eth0" masquerade
IPv6 “NAT” is often the wrong product goal (prefer native routing/NPTv6 carefully). If you do need family-unified rulesets, inet NAT is the supported path.
6) Full compact gateway example
Drop-in style ruleset combining masquerade, one published service, hairpin, and a tight forward policy:
#!/usr/sbin/nft -f
flush ruleset
define DEV_WAN = "eth0"
define DEV_LAN = "eth1"
define NET_LAN = 192.168.1.0/24
define IP_GW_LAN = 192.168.1.1
define IP_WEB = 192.168.1.50
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif "lo" accept
iifname $DEV_LAN tcp dport 22 accept
iifname $DEV_LAN udp dport 53 accept
iifname $DEV_LAN tcp dport 53 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
# LAN to Internet
iifname $DEV_LAN oifname $DEV_WAN accept
# published web (match post-DNAT)
iifname $DEV_WAN oifname $DEV_LAN ip daddr $IP_WEB tcp dport { 80, 443 } accept
# hairpin path LAN → gateway → LAN backend
iifname $DEV_LAN oifname $DEV_LAN ip daddr $IP_WEB tcp dport { 80, 443 } accept
}
}
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname $DEV_WAN tcp dport { 80, 443 } dnat to $IP_WEB
# optional: also DNAT when LAN clients target the WAN IP on the gateway
iifname $DEV_LAN ip daddr 203.0.113.10 tcp dport { 80, 443 } dnat to $IP_WEB
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr $NET_LAN oifname $DEV_WAN masquerade
ip saddr $NET_LAN ip daddr $IP_WEB oifname $DEV_LAN snat to $IP_GW_LAN
}
}
Load safely:
nft -c -f /etc/nftables.conf # syntax check
nft -f /etc/nftables.conf # apply
On Debian/Ubuntu, enable the packaged service once the file is correct:
systemctl enable --now nftables.service
systemctl status nftables.service --no-pager
7) Verify before you call it done
Rules and counters
nft list ruleset
nft list chain ip nat prerouting
nft list chain ip nat postrouting
nft list chain inet filter forward
Add counter on critical NAT/filter rules while commissioning so hits are obvious:
iifname "eth0" tcp dport 443 counter dnat to 192.168.1.50
Conntrack is the source of truth
# watch new DNATed HTTPS flows
conntrack -E -p tcp --dport 443
# or snapshot
conntrack -L -p tcp --dport 443 | head
You want to see original and reply tuples reflecting DNAT/SNAT, not only filter accepts.
Functional checks
# from outside (or a simulated WAN netns)
curl -v --connect-timeout 5 http://203.0.113.10/
# hairpin from a LAN host using the public IP/name
curl -v --connect-timeout 5 http://203.0.113.10/
# direct LAN path still works
curl -v --connect-timeout 5 http://192.168.1.50/
If external works and hairpin fails, your DNAT is fine and hairpin SNAT/forward is not.
iptables coexistence note
Before kernel 4.18, do not run iptables NAT and nft NAT together; unload iptable_nat if needed. On newer kernels both can exist; the first matching NAT mapping by priority wins. Prefer one NAT owner.
8) Persistence and change discipline
- Keep the authoritative rules in
/etc/nftables.conf(and includes). - Always
nft -c -fbefore apply. - Prefer atomic file loads over dozens of ad-hoc
nft add rulein shell history. - Save a rollback copy before experiments:
nft list ruleset > /root/nftables-backup-$(date +%F-%H%M).nft
- Document interface names and prefixes as
defines so the next edit does not invent a second topology dialect.
9) Rollback
# restore last known-good file
nft -f /root/nftables-backup-YYYY-MM-DD-HHMM.nft
# or empty everything (outage risk on remote gateways!)
nft flush ruleset
On a remote gateway, never flush ruleset without a verified out-of-band path. Load a known-good file instead.
To remove only NAT while debugging filters:
nft delete table ip nat
# or
nft flush chain ip nat prerouting
nft flush chain ip nat postrouting
Common failure checklist
| Symptom | Likely cause |
|---|---|
| DNAT “works” in rules but service unreachable |
ip_forward=0 or forward filter still drops post-DNAT path |
| Works from Internet, fails on LAN via public name/IP | missing hairpin SNAT (or no split DNS) |
| Outbound dies after WAN DHCP renew | hard-coded snat to old address; switch to masquerade
|
Error: Could not process rule: No such file or directory style NAT add |
NAT statement in a non-nat chain, or table/chain never created |
| Only first packet weirdness on old kernels | pre-4.18 NAT reply path requires both prerouting and postrouting NAT chains registered |
| Port forward hits wrong backend after map edit | stale named map element; list map with nft list map ip nat published
|
| Established sessions die on policy reload | expected for some flush/replace patterns; prefer additive map updates where possible |
What this is not
-
Not FIB reverse-path anti-spoof (
fibexpressions) - Not SYNPROXY / syncookie handshake offload
- Not dynamic set meters / temporary ban automation
- Not flowtable established-flow fastpath
- Not tc HTB shaping, BBR, MPTCP, or RSS steering
- Not a substitute for application TLS and backend authentication
NAT changes where packets appear to come from and go to. It does not make an open service safe.
Practical defaults I use
- Masquerade private ranges out the WAN interface; avoid brittle SNAT to DHCP addresses.
- Put all publishes in a named map early — even if there is only one service today.
- Implement hairpin SNAT whenever users will type the public hostname from inside.
- Match post-DNAT addresses in forward allow rules.
- Commission with counters + conntrack, not vibes.
- Keep NAT tables thin; keep policy in filter.
References
- nftables wiki — Performing Network Address Translation (NAT)
- nftables wiki — Multiple NATs using nftables maps
- nftables wiki — Simple ruleset for a home router
- nftables wiki — Netfilter hooks
- nftables wiki — Configuring chains
- Debian man page — nft(8)
Broken LAN access to your own public port forward is almost never “DNS being weird.” It is NAT missing the hairpin leg. Fix the bindings once, verify with conntrack, and keep publishes in maps so the next service does not mean another fragile one-liner.
Top comments (0)