DNAT Explained: Publishing an Internal Service Through One Public Address and Port
This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.
In Lab #20, source NAT (masquerade) let many inside hosts reach out through one public address. This lab is the inbound complement: destination NAT — better known as port forwarding — which lets the outside reach a chosen inside service through a public address:port.
Reading guide: rfc-notes/dnat-port-forwarding.md
Prerequisite: Lab 20: NAT — Source Address Translation
Expected time: 35–50 minutes.
The Goal
An internal server sits on a private network the client cannot address directly. The gateway owns a public address:
- Before any rule, the client's request to the public
203.0.113.1:8080gets nothing — there is no service there. - A DNAT rule maps
203.0.113.1:8080→ the internal10.0.0.2:80. - Now the client reaches the server through the public address (it never learns the internal IP), and conntrack un-NATs the reply so it appears to come from
203.0.113.1:8080.
By the end, you should be able to explain this table:
| client → 203.0.113.1:8080 | |
|---|---|
| before DNAT | nothing (no published service) |
| after DNAT → 10.0.0.2:80 | reaches the internal server |
What You Will Learn
- What DNAT (destination NAT / port forwarding) is and how it differs from SNAT (Lab 20).
- Why DNAT happens in PREROUTING (before routing) while SNAT happens in POSTROUTING.
- How conntrack un-NATs the reply so the client only ever sees the public address.
- Why the internal host must return through the gateway.
- How this relates to L4 load balancing (Lab 33, DNAT to a pool) and firewalling (Lab 36).
This lab does not cover:
- Hairpin NAT / NAT reflection (inside clients using the public IP).
- 1:1 NAT (netmap) or full cone / restricted cone behaviour.
- Combining DNAT with a firewall policy (mentioned, not built).
Where to Read in the RFCs
| Reference | What to focus on |
|---|---|
| RFC 3022 | Basic NAT / NAPT (port translation); where DNAT fits |
| RFC 2663 | NAT terminology (inside/outside) |
| RFC 7857 | conntrack timeouts and states |
| RFC 5737 | Confirming 203.0.113.0/24 used here is documentation-only space |
The Big Picture
Three nodes: the client (outside), the gw (public 203.0.113.1, internal 10.0.0.1), and the server (internal, private 10.0.0.2).
client (203.0.113.2) --- eth1 [ gw ] eth2 --- server (10.0.0.2, private, :80)
public 203.0.113.1
client → 203.0.113.1:8080 --DNAT--> 10.0.0.2:80
flowchart LR
C["client<br/>GET 203.0.113.1:8080"] --> G["gw<br/>PREROUTING DNAT<br/>→ 10.0.0.2:80"]
G -->|"routed to internal"| S["server 10.0.0.2:80<br/>→ 'server'"]
S -.->|"reply src un-NATed<br/>to 203.0.113.1:8080"| G
G -.-> C
The lab uses 203.0.113.0/24 (RFC 5737) as the public side and 10.0.0.0/24 internally — all of it in a closed local environment.
Note: Everything here uses documentation / private address space (RFC 5737 / RFC 1918), so nothing in this lab touches the real internet.
What You Need
Recommended environment:
- Linux / WSL2 / a Linux VM
- Docker
- containerlab
Images used:
nicolaka/netshoot:latest— bundlesiptables,conntrack,curl, andpython3.
No additional images are required.
Running the Lab
The quick path, which deploys, verifies, and tears down for you:
./scripts/labctl.sh run dnat-40
Or step through it manually:
1. Move into the working directory
cd protocol-lab/examples/dnat-40
2. Deploy and start the internal server
sudo containerlab deploy -t dnat-40.clab.yml
docker exec -d clab-dnat-40-server python3 /responder.py server
3. Hit the public port before DNAT (it doesn't answer)
docker exec clab-dnat-40-client curl -s --max-time 4 http://203.0.113.1:8080/ || echo "(nothing published)"
4. Publish the service with DNAT
docker exec clab-dnat-40-gw iptables -t nat -A PREROUTING -d 203.0.113.1 -p tcp --dport 8080 -j DNAT --to-destination 10.0.0.2:80
docker exec clab-dnat-40-gw iptables -t nat -L PREROUTING -n -v
5. Hit it again (it reaches the internal server)
docker exec clab-dnat-40-client curl -s http://203.0.113.1:8080/ # server
6. Inspect the translation with conntrack
docker exec clab-dnat-40-gw conntrack -L | grep dport=8080
The flow toward dst=203.0.113.1 dport=8080 shows a return leg of src=10.0.0.2 sport=80 — the rewrite is right there in the state table.
Expected Output
- Before DNAT: the public
203.0.113.1:8080gives no answer. iptables -t nat -L PREROUTING:DNAT tcp dpt:8080 to:10.0.0.2:80.- After DNAT: the client gets
serverback — via the public address. - conntrack: the destination has been rewritten to
10.0.0.2:80.
Why It Works
DNAT (destination NAT / port forwarding) means "the outside reaches a chosen inside service through a public address:port."
- Contrast with SNAT. SNAT (Lab 20) rewrites the source of outbound traffic (many inside hosts → one public IP) and runs in POSTROUTING. DNAT rewrites the destination of inbound traffic (public → a specific internal service) and runs in PREROUTING. Direction, purpose, and chain are all mirror images.
- PREROUTING — before routing. The incoming packet's destination is rewritten to
10.0.0.2:80before the routing decision is made. The gateway then routes the rewritten packet like any other, forwarding it out the internal interface. That's how a packet addressed to the public IP ends up at an internal host, and why the client never needs to know the internal IP. - conntrack reverses it. A DNATed flow gets a single conntrack entry recording both the forward and return directions. When the internal server's reply (
src=10.0.0.2:80) passes back through the gateway, conntrack rewrites the source back to the public203.0.113.1:8080(un-NAT). To the client, the reply appears to come from the public address it originally contacted. - The return path is a requirement. If the internal server's default route does not point at the gateway, the reply bypasses it and never gets un-NATed. This lab sets the server's default gateway to the gw for exactly that reason.
- What it's for. One public IP can publish multiple internal services on different ports (80 → web, 8080 → another web, and so on). Who is allowed to reach them is a firewall's job (Lab 36). An L4 load balancer (Lab 33) is essentially DNAT spread across multiple backends.
The key insight: rewrite the destination to the internal service at the entrance (PREROUTING), and let conntrack restore the public address on the reply. It's the inbound counterpart of SNAT.
Common Pitfalls
- Confusing it with SNAT. DNAT rewrites the destination (inbound, PREROUTING); SNAT rewrites the source (outbound, POSTROUTING).
- Forgetting the return path. If the internal server doesn't reply through the gateway, nothing gets un-NATed and the connection breaks.
- Assuming no firewall is needed. DNAT only delivers packets. Restricting who can reach the published service is a separate firewall concern.
- Assuming the ports match. Public and internal ports can differ — public 8080 → internal 80 here (that's NAPT).
- Assuming hairpin works. Inside hosts reaching the service via the public IP need extra NAT (reflection).
- conntrack modules. The nat/conntrack machinery must be available (netshoot has it).
Cleanup
sudo containerlab destroy -t dnat-40.clab.yml --cleanup
If you used labctl.sh run dnat-40, the script runs destroy for you at the end.
Check Your Understanding
- What is DNAT? How does it differ from SNAT (Lab 20) in direction, purpose, and chain?
- Why does DNAT happen in PREROUTING?
- From which address does the client see the internal server's reply arriving? Why?
- What happens if the internal server's return path doesn't go through the gateway?
- How would you publish multiple services behind a single public IP?
- What is the relationship between DNAT and an L4 load balancer (Lab 33)?
References
- RFC 3022: Traditional IP Network Address Translator (Traditional NAT)
- RFC 2663: IP Network Address Translator (NAT) Terminology and Considerations
- RFC 7857: Updates to Network Address Translation (NAT) Behavioral Requirements
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
Verified Run Log (2026-07-08)
This lab has been confirmed reproducible on real hardware.
Environment:
- Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
- Docker 29.1.3
- containerlab 0.77.0
- client / gw / server:
nicolaka/netshoot:latest(iptables,conntrack,curl,python3)
Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run dnat-40 performed deploy → verify → destroy, and verification.json returned "status": "verified".
Unreachable before DNAT, internal server after
before: '<unreachable>'
after: 'server'
Before the DNAT rule, nothing is published on 203.0.113.1:8080 and the client gets no response. After adding the rule, the same public address:port reaches the internal server (private 10.0.0.2:80) and returns server. The client never used the internal IP at any point.
The port-forward rule and conntrack's translation
# iptables -t nat -L PREROUTING
DNAT tcp -- 0.0.0.0/0 203.0.113.1 tcp dpt:8080 to:10.0.0.2:80
# conntrack
src=203.0.113.2 dst=203.0.113.1 sport=33104 dport=8080
src=10.0.0.2 dst=203.0.113.2 sport=80 dport=33104 [ASSURED]
- The rule maps traffic for the public
203.0.113.1:8080to the internal10.0.0.2:80— remapping the port from 8080 to 80 at the same time. - The first conntrack line is the forward leg (client → public address); the second is the return leg (internal server → client). On the way back, the gateway rewrites the source to the public
203.0.113.1:8080(un-NAT) before delivering the reply, so to the client it looks like the public address answered. This is exactly the inbound mirror of SNAT (Lab 20).
Cleanup
containerlab destroy -t dnat-40.clab.yml --cleanup
That's DNAT: one rule at the gateway's entrance rewrites the destination, ordinary routing carries the packet inside, and conntrack quietly restores the public address on the way out — port forwarding, demystified.
Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.
Next up, we'll look at combining NAT with a real firewall policy, so a published service is reachable by exactly the clients you intend.