pathvector-dev

Originally published at https://blog.pathvector.dev/protocol-lab-dnat-40/ — part of the free Protocol Lab series.

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:8080 gets nothing — there is no service there.
  • A DNAT rule maps 203.0.113.1:8080 → the internal 10.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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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 — bundles iptables, conntrack, curl, and python3.

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

Enter fullscreen mode Exit fullscreen mode

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/dnat-40

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)"

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

5. Hit it again (it reaches the internal server)

docker exec clab-dnat-40-client curl -s http://203.0.113.1:8080/   # server

Enter fullscreen mode Exit fullscreen mode

6. Inspect the translation with conntrack

docker exec clab-dnat-40-gw conntrack -L | grep dport=8080

Enter fullscreen mode Exit fullscreen mode

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:8080 gives no answer.
  • iptables -t nat -L PREROUTING: DNAT tcp dpt:8080 to:10.0.0.2:80.
  • After DNAT: the client gets server back — 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:80 before 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 public 203.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

Enter fullscreen mode Exit fullscreen mode

If you used labctl.sh run dnat-40, the script runs destroy for you at the end.

Check Your Understanding

  1. What is DNAT? How does it differ from SNAT (Lab 20) in direction, purpose, and chain?
  2. Why does DNAT happen in PREROUTING?
  3. From which address does the client see the internal server's reply arriving? Why?
  4. What happens if the internal server's return path doesn't go through the gateway?
  5. How would you publish multiple services behind a single public IP?
  6. What is the relationship between DNAT and an L4 load balancer (Lab 33)?

References

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'

Enter fullscreen mode Exit fullscreen mode

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]

Enter fullscreen mode Exit fullscreen mode

  • The rule maps traffic for the public 203.0.113.1:8080 to the internal 10.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

Enter fullscreen mode Exit fullscreen mode


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.