NAT Explained: How a Whole Private Network Hides Behind One Public IP
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.
There are far more devices in the world than public IPv4 addresses. NAT (Network Address Translation) is how a whole private network shares a single public address: a router rewrites the source address of outbound packets to its own public address, remembers the mapping, and reverses it for the replies. In this lab we watch that translation from both sides of the NAT at once.
Reading guide: rfc-notes/nat-source-translation.md
Prerequisites: TCP Lab 07: Handshake and Teardown, Lab 19: traceroute and TTL
Expected time: 45–60 minutes.
The Goal
This lab shows the translation from both sides:
- A private client (
192.168.10.1, RFC 1918) reaches a public server (203.0.113.1) through a masquerading NAT. - From the server's point of view, the connection comes from the NAT's public address
203.0.113.254— the client's private address is never seen. - The NAT's conntrack table shows the mapping it keeps in order to route replies back to the right inside host.
By the end, you should be able to explain this table:
| Vantage point | Source address of the connection |
|---|---|
| client (itself) | 192.168.10.1 (its own private address) |
| server / public wire | 203.0.113.254 (the NAT — private address hidden) |
| NAT conntrack | maps 192.168.10.1:port ⇄ 203.0.113.254:port |
What You Will Learn
- Why NAT exists (IPv4 address scarcity) and what "source NAT / masquerade" does.
- The difference between private (RFC 1918) and public (here, RFC 5737 documentation) addresses.
- That the server never sees the private client address — only the NAT's public one.
- How the NAT keeps a connection-tracking table to send replies to the right inside host.
- Why inbound connections to a private host need extra config (port forwarding) — NAT is asymmetric.
This lab does not cover:
- Destination NAT / port forwarding, hairpin NAT, or NAT traversal (STUN/TURN).
- Carrier-grade NAT or NAT64/IPv6 transition.
- The security implications of NAT vs a real firewall.
Where to Read in the RFCs
| RFC | Sections | What to focus on |
|---|---|---|
| RFC 2663 | 2–4 | NAT terminology (NAT/NAPT, inside/outside, binding) |
| RFC 3022 | 2–3 | Traditional NAT and NAPT (multiplexing using ports too) |
| RFC 1918 | 3 | Private address space (10/8, 172.16/12, 192.168/16) |
| RFC 5737 | 3 | 203.0.113.0/24 is documentation space (playing the "public" role here) |
The Big Picture
Three nodes: a client (private side), a NAT (with one private-facing and one public-facing interface), and a server (public side).
client ------ 192.168.10.0/24 ------ nat ------ 203.0.113.0/24 ------ server
192.168.10.1 192.168.10.254 203.0.113.1
(private) 203.0.113.254 (public) (public)
When the client connects to the server, the NAT rewrites the source of the outbound packets to 203.0.113.254 (MASQUERADE). The server sees the connection as coming from 203.0.113.254.
sequenceDiagram
participant C as client 192.168.10.1
participant N as nat (203.0.113.254)
participant S as server 203.0.113.1
C->>N: SYN src=192.168.10.1 dst=203.0.113.1
Note over N: rewrite src -> 203.0.113.254<br/>remember the mapping
N->>S: SYN src=203.0.113.254 dst=203.0.113.1
S-->>N: SYN,ACK dst=203.0.113.254
Note over N: look up mapping<br/>rewrite dst -> 192.168.10.1
N-->>C: SYN,ACK dst=192.168.10.1
192.168.10.0/24 is RFC 1918 private space, and 203.0.113.0/24 is RFC 5737 documentation space standing in for the "public" internet.
Note: Everything here uses private/documentation address space (RFC 1918 / RFC 5737), 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,python3, andtcpdump.
No additional images are required. The NAT itself is plain Linux iptables MASQUERADE (configured by run.sh).
Running the Lab
The quick path, which deploys, verifies, and tears down for you:
./scripts/labctl.sh run nat-20
Or step through it manually:
1. Move into the working directory
cd protocol-lab/examples/nat-20
2. Deploy and configure the NAT
sudo containerlab deploy -t nat-20.clab.yml
# nat: enable forwarding + source NAT on the public-facing interface
docker exec clab-nat-20-nat sysctl -w net.ipv4.ip_forward=1
docker exec clab-nat-20-nat iptables -t nat -A POSTROUTING -o eth2 -j MASQUERADE
# client reaches the public network via the nat
docker exec clab-nat-20-client ip route add 203.0.113.0/24 via 192.168.10.254
# the server has NO route to the private network — that's the whole point of NAT
3. Start a small HTTP server on the server
docker exec -d clab-nat-20-server sh -c "cd /tmp && python3 -m http.server 8080"
4. Connect from the client and watch the source address on the server side
# capture on the server side
docker exec -d clab-nat-20-server tcpdump -i eth1 -n "tcp port 8080"
# fetch from the client
docker exec clab-nat-20-client curl -s -o /dev/null -w "%{http_code}\n" http://203.0.113.1:8080/
In the server-side capture, the connection comes from 203.0.113.254 (the NAT's public address). The client's 192.168.10.1 never appears. The server's own access log also records 203.0.113.254 - - [...].
5. Inspect the NAT's translation table
docker exec clab-nat-20-nat conntrack -L | grep 8080
tcp ... src=192.168.10.1 dst=203.0.113.1 sport=... dport=8080 \
src=203.0.113.1 dst=203.0.113.254 sport=8080 dport=... [ASSURED]
The first half is the original tuple (the private client); the second half is the tuple expected on the reply (destination = the NAT's public address). This mapping is how the returning reply gets steered back to the right inside host.
Expected Output
- client:
HTTP 200(it reaches the public server). - server-side capture: source is
203.0.113.254;192.168.10.1is absent. - server's log: peer =
203.0.113.254. - NAT conntrack:
src=192.168.10.1 ... src=203.0.113.1 dst=203.0.113.254.
Why It Works
NAT is the pragmatic answer to "there aren't enough IPv4 addresses." Private addresses (RFC 1918) are reused all over the world, so they can't be sent onto the public network as-is. The NAT translates them into a public address at the exit.
- Source NAT / masquerade. Outbound packets have their source address (and, with NAPT, their source port too) rewritten to the NAT's public address.
MASQUERADEis the variant that automatically uses the IP of the egress interface. - Connection tracking. Rewriting alone isn't enough — the NAT would have no idea who a reply belongs to. It records the "original (src, sport) ⇄ translated" mapping in its conntrack table, and when a reply arrives, rewrites the destination back to the original inside host. By assigning different ports, many inside hosts can be multiplexed onto a single public IP (NAPT).
- What the server can't see. All the server ever receives is the NAT's public address. The inside private addresses and topology are hidden — from the outside, there's no telling how many hosts sit behind one public IP.
- Asymmetry. Inside-to-outside traffic flows automatically thanks to conntrack. But outside-to-inside (inbound) traffic has no matching entry, so it never reaches the inside on its own. Exposing a specific service requires configuring port forwarding (destination NAT) separately. This is why "hosts behind NAT are hard to reach from outside."
The key insight: NAT rewrites the source address and remembers the mapping, hiding an entire private network behind one public address.
Common Pitfalls
- Confusing NAT with a firewall. NAT's primary purpose is address translation. Being hard to reach from outside is a side effect; proper filtering is a separate job (a firewall).
- Expecting the server to see the client's private address. It only ever sees the NAT's public address.
- Forgetting conntrack. Without the mapping table, replies can't be returned. It's the heart of NAT.
- Assuming inbound "just works." Inside-to-outside passes, but outside-to-inside needs port forwarding (asymmetry).
- The private address ranges. 10/8, 172.16/12, 192.168/16 (RFC 1918). These must not appear on the public internet.
- Port exhaustion. NAPT multiplexes on source ports, so with a very large number of simultaneous connections, ports can run out.
Cleanup
sudo containerlab destroy -t nat-20.clab.yml --cleanup
If you used labctl.sh run nat-20, the script runs destroy for you at the end.
Check Your Understanding
- Why does NAT exist? What does source NAT (masquerade) rewrite?
- List the private address ranges (RFC 1918). Why can't they be used on the public internet as-is?
- What source address does the server see for the connection? Why can't it see the client's private address?
- How can the NAT return replies to the correct inside host? What does it remember?
- Why does inside-to-outside traffic pass while outside-to-inside is hard to reach? What is needed to expose a service?
- Why can many inside hosts share a single public IP (NAPT)?
References
- RFC 2663: IP Network Address Translator (NAT) Terminology and Considerations
- RFC 3022: Traditional IP Network Address Translator (Traditional NAT)
- RFC 1918: Address Allocation for Private Internets
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
- iptables-extensions manual (MASQUERADE)
Verified Run Log (2026-07-07)
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 / nat / server:
nicolaka/netshoot:latest(iptables,conntrack,python3,tcpdumpincluded)
Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run nat-20 performed deploy → verify → destroy, and verification.json returned "status": "verified".
The client reaches the public server
$ docker exec clab-nat-20-client curl -s -o /dev/null -w "%{http_code}\n" http://203.0.113.1:8080/
200
From the server, the connection comes from the NAT's public address (no private address in sight)
$ docker exec clab-nat-20-server tcpdump -n -r server-side.pcap
11:15:22 IP 203.0.113.254.45508 > 203.0.113.1.8080: Flags [S], ...
11:15:22 IP 203.0.113.1.8080 > 203.0.113.254.45508: Flags [S.], ...
The source is 203.0.113.254 (the NAT's public address). The client's private address 192.168.10.1 never appears in the server-side capture (grep count = 0). The server's own HTTP log also recorded 203.0.113.254.
The NAT's conntrack holds the translation
$ docker exec clab-nat-20-nat conntrack -L | grep 8080
tcp 6 ... src=192.168.10.1 dst=203.0.113.1 sport=45508 dport=8080 \
src=203.0.113.1 dst=203.0.113.254 sport=8080 dport=45508 [ASSURED]
The original tuple (source = the private client 192.168.10.1) paired with the tuple expected on the reply (destination = the NAT's public 203.0.113.254). That pairing is what steers the returning reply back to the right inside host. NAT rewrites the source and remembers the mapping — hiding the private network behind a single public address.
Cleanup
containerlab destroy -t nat-20.clab.yml --cleanup
That's source NAT: one public address at the edge, a rewrite on the way out, and a conntrack entry to undo it on the way back — an entire private network hidden behind a single IP.
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 go the other direction: destination NAT and port forwarding, letting the outside world reach a service that lives behind the NAT.