A Linux gateway does two things with NAT: it lets an internal network go out to the Internet behind a single address (MASQUERADE), and it exposes an internal service on a public port (DNAT). This guide configures both directions on a Linux router with nftables, the kernel's modern firewall. You enable routing, write the MASQUERADE and DNAT rules, then check each translation with conntrack. All commands and outputs come from a real lab: a Linux gateway and an internal host (10.0.0.2) behind it. Audience: administrators setting up a router, a homelab or a DMZ.
If the terms SNAT, DNAT or PAT are new to you, first lay down the concepts with understanding NAT (in French). This guide is the hands-on part.
What you will learn
- Enable IP routing on the gateway.
- Share the outgoing connection of an internal network with MASQUERADE.
- Forward a public port to an internal service with DNAT.
- Check the translations with
conntrack. - Make the rules persistent across reboots.
Prerequisites
- A Linux gateway with two "sides": an interface towards the Internet (the WAN, here
eth0) and access to the internal network (the LAN, here10.0.0.0/24). - An internal host to reach (here
10.0.0.2, with a web service on port 80). - nftables installed (
sudo apt install nftablesorsudo dnf install nftables). sudorights.
The principle: two directions, two chains
NAT rewrites the addresses of packets that cross the gateway, and nftables does it in two distinct chains:
postrouting(outgoing): just before sending, the source address is rewritten. This is SNAT; its automatic form is MASQUERADE, which takes the IP of the outgoing interface.prerouting(incoming): as soon as it arrives, the destination address is rewritten. This is DNAT, used for port forwarding.
Remember the rule: MASQUERADE to go out, DNAT to come in.
Enable IP routing
By default, Linux does not route packets from one interface to another.
You must allow it with the ip_forward parameter, first on the fly then
in a persistent file:
sudo sysctl -w net.ipv4.ip_forward=1To make it permanent, write it to a dedicated file:
echo "net.ipv4.ip_forward = 1" | sudo tee /etc/sysctl.d/99-routeur.confWithout this step, no NAT rule will work: packets would be dropped instead of being routed, whatever the ruleset.
Share the connection: MASQUERADE
Goal: the machines of the internal network (10.0.0.0/24) must reach the
Internet behind the gateway's address. You create a NAT table and a
MASQUERADE rule in the postrouting chain.
sudo nft add table ip natsudo nft add chain ip nat postrouting '{ type nat hook postrouting priority 100 ; }'sudo nft add rule ip nat postrouting ip saddr 10.0.0.0/24 oifname "eth0" masqueradeThe rule reads: "for any packet coming from 10.0.0.0/24 and going out
through eth0, replace the source address with that of eth0". Let us
check that the internal host does get out:
# Run from the internal host 10.0.0.2curl -sS -o /dev/null -w "HTTP %{http_code}\n" http://1.1.1.1HTTP 301The internal host, which has no direct route to the Internet, did reach
1.1.1.1 through the gateway.
Forward a port: DNAT
The opposite goal: expose the internal web service (10.0.0.2:80) on the
gateway's public port 8080. You add a prerouting chain and a DNAT
rule.
sudo nft add chain ip nat prerouting '{ type nat hook prerouting priority -100 ; }'sudo nft add rule ip nat prerouting iifname "eth0" tcp dport 8080 dnat to 10.0.0.2:80The rule reads: "any packet arriving on eth0 for port 8080 is
forwarded to 10.0.0.2:80". Let us test from an external machine:
# From a client on the public networkcurl http://<gateway-ip>:8080/BONJOUR-DEPUIS-HOTE-INTERNEThe external client thinks it is talking to the gateway on 8080; it
actually receives the reply of the internal server on its port 80. This is
port forwarding.
One prerequisite governs the whole manoeuvre, and it has nothing to do with the rules: for DNAT to work, the internal host must send its replies back through the gateway, which must therefore be its default route. Otherwise the return trip bypasses the translation, and the external client rejects a reply coming from an address it never contacted. It is the most frequent cause of a DNAT that "does not answer" while the internal service did receive the request.
The complete ruleset
Your two rules live in a single ip nat table. Displaying it confirms
the whole configuration:
sudo nft list table ip nattable ip nat { chain postrouting { type nat hook postrouting priority srcnat; policy accept; ip saddr 10.0.0.0/24 oifname "eth0" masquerade } chain prerouting { type nat hook prerouting priority dstnat; policy accept; iifname "eth0" tcp dport 8080 dnat to 10.0.0.2:80 }}Check with conntrack
NAT is not magic: the kernel tracks each connection in a table
(conntrack) to rewrite the return traffic on the fly. It is the number
one diagnostic tool for NAT. For the outgoing connection (MASQUERADE):
sudo conntrack -L | grep 10.0.0.2tcp 6 ... src=10.0.0.2 dst=1.1.1.1 sport=60184 dport=80 src=1.1.1.1 dst=192.168.10.79 sport=80 dport=60184 [ASSURED]Read the two tuples: the original connection (src=10.0.0.2) and its
rewriting for the return trip (dst=192.168.10.79, the gateway's IP). The
source was indeed masqueraded.
For the incoming forwarding (DNAT), the same command filtered on the public port shows the rewritten destination:
sudo conntrack -L | grep dport=8080tcp 6 ... src=192.168.10.11 dst=192.168.10.79 sport=49334 dport=8080 src=10.0.0.2 dst=192.168.10.11 sport=80 dport=49334 [ASSURED]Here, the destination is rewritten: the client targeted
192.168.10.79:8080, the reply comes from 10.0.0.2:80. The translation
is proven in both directions.
Make the rules persistent
Rules added on the fly disappear at reboot. To keep them, write the
ruleset into the file loaded by the nftables service, preceded by a
flush ruleset, then enable the service. Without that first line, reloading
the file duplicates the rules: measured on Debian 13, a second
nft -f of a raw export takes the masquerade rule from 1 to 2 copies.
# Debian/Ubuntu: the service reads /etc/nftables.conf{ echo 'flush ruleset'; sudo nft list ruleset; } | sudo tee /etc/nftables.conf
# RHEL/AlmaLinux: the service reads /etc/sysconfig/nftables.conf{ echo 'flush ruleset'; sudo nft list ruleset; } | sudo tee /etc/sysconfig/nftables.conf
sudo systemctl enable --now nftablesnft list ruleset exports all loaded tables, including those of
firewalld or Docker if they are running: review the file before
enabling it, or only export your table with nft list table ip nat. At the
next boot, nftables.service replays this file, and the sysctl.d file
re-enables routing. The gateway is rebuilt identically.
Security and points of attention
A gateway that routes and forwards is a target: a few reflexes are a must, because NAT decides the translation, never the authorisation.
- Only route what must be routed. Add a
forwardchain of typefilterthat only accepts legitimate flows (from the LAN to the WAN, and the established return), rather than a globalacceptpolicy. - Restrict DNAT to a source when possible:
iifname "eth0" ip saddr <trusted-network> tcp dport 8080 dnat ...limits who can reach the internal service. - The exposed service remains responsible for its own security: DNAT only routes, it hardens nothing. Put exposed services in a DMZ (in French).
- Log dropped packets to spot attempts: a
log prefix "DROP "rule before the default policy.
Troubleshooting
These six symptoms are read from the left column, and it is worth telling
them apart before touching the rules: a packet that does not cross the
gateway and a packet that crosses but does not come back have opposite
causes. The last row is not a defect but a reflex: conntrack -L settles
it in one command.
| Symptom | Probable cause | Solution |
|---|---|---|
| The internal host cannot reach the Internet | Routing disabled | sysctl net.ipv4.ip_forward must be 1 |
| MASQUERADE has no effect | Wrong oifname interface | Check with ip route show default |
| DNAT does not answer | The internal host does not route through the gateway | Make the gateway the host's default route |
| It works then stops | A forward rule blocks the return | Allow the established,related state |
| Rules disappear at reboot | Ruleset not persisted, or written to a file the service does not read | Write to the unit's file (systemctl cat nftables), preceded by flush ruleset, then systemctl enable nftables |
| Doubt about the translation | Diagnosis | conntrack -L shows both tuples |
Put it into practice
Building the gateway yourself remains the best way to anchor both directions of NAT: this lab has you enable IP routing, write the MASQUERADE and DNAT rules with nftables, then make the ruleset persistent across reboots.
Key points
- MASQUERADE in
postroutingshares an outgoing connection behind the gateway's IP. - DNAT in
preroutingforwards an incoming port to an internal host. net.ipv4.ip_forward=1is the absolute prerequisite: without routing, no rule applies.conntrack -Lis the diagnostic tool: its two tuples show the translation in both directions.- The internal host must have the gateway as its default route, otherwise the return trip breaks NAT.
- Persist in the file read by the nftables service (
/etc/nftables.confon Debian,/etc/sysconfig/nftables.confon RHEL), preceded byflush ruleset, otherwise everything goes at reboot.
Knowledge check
Check that the essentials of this guide are acquired. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Troubleshoot Linux: start from the symptom
A new full quiz with random questions
Work again only on the questions you missed