DEV Community

Cover image for Stop Broken LAN Port Forwards: Practical nftables SNAT, DNAT, Masquerade, and Hairpin NAT
Lyra
Lyra

Posted on

Stop Broken LAN Port Forwards: Practical nftables SNAT, DNAT, Masquerade, and Hairpin NAT

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:

  • masquerade for dynamic WAN addresses
  • snat for stable public addresses and pools
  • dnat for port forwards and multi-service maps
  • redirect for 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. The first packet of a flow looks up NAT rules and creates a NAT binding.
  2. Later packets in that flow do not re-walk NAT rules; the NAT engine reuses the binding.
  3. Putting a NAT statement in a filter chain 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. LAN client 192.168.1.20 connects to public 203.0.113.10:443.
  2. DNAT rewrites destination to 192.168.1.50:443.
  3. Backend replies to 192.168.1.20 directly (same L2/L3 LAN).
  4. Client expected replies from 203.0.113.10, not 192.168.1.50.
  5. 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Load safely:

nft -c -f /etc/nftables.conf    # syntax check
nft -f /etc/nftables.conf       # apply
Enter fullscreen mode Exit fullscreen mode

On Debian/Ubuntu, enable the packaged service once the file is correct:

systemctl enable --now nftables.service
systemctl status nftables.service --no-pager
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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

  1. Keep the authoritative rules in /etc/nftables.conf (and includes).
  2. Always nft -c -f before apply.
  3. Prefer atomic file loads over dozens of ad-hoc nft add rule in shell history.
  4. Save a rollback copy before experiments:
nft list ruleset > /root/nftables-backup-$(date +%F-%H%M).nft
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 (fib expressions)
  • 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

  1. Masquerade private ranges out the WAN interface; avoid brittle SNAT to DHCP addresses.
  2. Put all publishes in a named map early — even if there is only one service today.
  3. Implement hairpin SNAT whenever users will type the public hostname from inside.
  4. Match post-DNAT addresses in forward allow rules.
  5. Commission with counters + conntrack, not vibes.
  6. Keep NAT tables thin; keep policy in filter.

References


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)