Standing up a web server is easy. Letting the internet reach it, through a firewall, without exposing the rest of your network, is where the Cisco ASA earns its keep. When you publish a private host to a public address, you are doing destination NAT (often called policy NAT or, on the ASA, static object NAT for inbound services). The address a client types is not the address the server actually holds, and the ASA quietly rewrites one into the other on the way in.
This article is part of the Cisco ASA cluster. If you have already read static NAT for a DMZ server, this is the same idea taken deeper: not just the config that publishes the box, but the exact order the ASA processes the inbound packet, proven with packet-tracer against a real ASAv in a lab, and a real HTTP request from a Linux host that comes back with a genuine 200.
What Destination NAT Actually Means
Most NAT you meet first is source NAT: an inside host with a private address reaches out, and the firewall rewrites the source so the return traffic can find its way back. Destination NAT is the mirror image. The client already knows where it wants to go (a public address you have advertised), and the firewall rewrites the destination so the packet lands on the real, private server behind it. Nothing about the client changes; the target address does.
On the ASA this is not a separate feature with its own command. A single static NAT statement gives you both directions at once, because static translations are inherently bidirectional. You configure the mapping once, in the direction that reads naturally (real host to public host), and the ASA applies it as source NAT for traffic the server originates and as destination NAT (un-NAT) for traffic arriving for it. That symmetry is why the same nat ... static line that lets your DMZ server browse out also publishes it inbound, and it is why the inbound proof lives in the untranslate_hits counter rather than a separate rule.
The Setup: One DMZ Web Server, One Public IP
The lab is a single ASAv (9.24) with three interfaces: outside 203.0.113.10, inside 10.20.10.1, and dmz 172.20.30.1. Sitting on the DMZ is an nginx server at 172.20.30.80. That address is private and unroutable on the internet, so we publish it behind a public address, 203.0.113.80. Clients on the outside connect to 203.0.113.80 and never know the real host exists.
Here is the entire configuration. Two objects (the real host, the public host) and one static NAT statement inside the real host's object, plus the access rule that lets the traffic in:
object network DMZ-WEB
host 172.20.30.80
object network DMZ-WEB-PUB
host 203.0.113.80
object network DMZ-WEB
nat (dmz,outside) static DMZ-WEB-PUB
access-list OUTSIDE-IN extended permit tcp any host 172.20.30.80 eq www
access-group OUTSIDE-IN in interface outsideRead the NAT line as a sentence: for traffic arriving (dmz,outside), statically map the object DMZ-WEB (172.20.30.80) to DMZ-WEB-PUB (203.0.113.80). Because this is static, the mapping is bidirectional. Outbound, the server's source is rewritten to the public IP. Inbound, the destination is un-NATed from the public IP back to the real host. That inbound direction is the destination NAT we care about here.
Now look at the access list. It permits traffic to host 172.20.30.80, the real private address, not the public 203.0.113.80 a client actually targets. That is not a typo. On ASA 8.3 and later, interface ACLs reference the real, post-translation IP. It looks wrong until you understand the order of operations, which is exactly what the next section proves.
The Hero Capture: Un-NAT Happens Before the ACL
The single most useful tool on the ASA for understanding NAT is packet-tracer. It simulates a packet through the firewall and prints every phase it hits, in order. We inject a TCP packet from an outside client (192.168.99.100) to the public IP on port 80 and watch what the ASA does:
FW1# packet-tracer input outside tcp 192.168.99.100 12345 203.0.113.80 80
Phase: 1
Type: UN-NAT
Subtype: static
Result: ALLOW
Config:
object network DMZ-WEB
nat (dmz,outside) static DMZ-WEB-PUB
Additional Information:
NAT divert to egress interface dmz
Untranslate 203.0.113.80/80 to 172.20.30.80/80
Phase: 2
Type: ACCESS-LIST
Result: ALLOW
Config:
access-group OUTSIDE-IN in interface outside
access-list OUTSIDE-IN extended permit tcp any host 172.20.30.80 eq wwwThis is the whole lesson in two phases. Phase 1 is UN-NAT. Before the ASA does anything else with an inbound packet, it un-translates the destination: Untranslate 203.0.113.80/80 to 172.20.30.80/80. The public IP the client sent becomes the real private IP. The ASA also notes it must divert this flow to the dmz egress interface.
Phase 2 is the ACCESS-LIST check, and by the time it runs, the destination is already 172.20.30.80. That is why the ACL permits the real IP. The firewall is no longer looking at 203.0.113.80 when it consults the rule; it un-NATed first. Match the ACL against the real host, and the check passes. Match it against the public IP, and it would silently never fire (that failure mode is the whole subject of a companion article, linked below).
Keep this ordering in your head and ASA NAT stops being mysterious: un-NAT, then ACL. Everything else follows from it.
Proof It Actually Works: A Real HTTP 200
Simulation is good, but a real request closes the loop. From a Debian host acting as the internet client, we curl the public IP straight through the firewall and ask curl to print the status, the address it actually connected to, and the round-trip time:
j@llmbits:~$ curl -s -o /dev/null -w 'HTTP %{http_code} from %{remote_ip} in %{time_total}s\n' http://203.0.113.80/
HTTP 200 from 203.0.113.80 in 0.020190s
j@llmbits:~$ curl -s http://203.0.113.80/ | head -4
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>A real Linux host connected to 203.0.113.80, the ASA un-NATed the destination to 172.20.30.80, forwarded it to the DMZ, and nginx answered with a 200 and its welcome page in about 20 milliseconds. The client never saw the private address. That is destination NAT doing its job.
The Counters: show xlate and show nat detail
Once traffic is flowing you want to confirm which translation it hit and how often. Two commands do it. show xlate lists the live translation slots, and show nat detail shows the configured rule with its hit counters:
FW1# show xlate
NAT from dmz:172.20.30.80 to outside:203.0.113.80
flags s idle 0:00:23 timeout 0:00:00
FW1# show nat detail
Auto NAT Policies (Section 2)
1 (dmz) to (outside) source static DMZ-WEB DMZ-WEB-PUB
translate_hits = 0, untranslate_hits = 7
Source - Origin: 172.20.30.80/32, Translated: 203.0.113.80/32Two things to notice. First, this is an Auto NAT rule, so it sits in Section 2 of the NAT table (the section a rule lands in decides which rule wins when several could match, a topic worth its own read). Second, the counter that climbs for inbound connections is untranslate_hits, here at 7. Each time an outside client hits the public IP, the ASA un-translates the destination and the untranslate counter ticks up. The translate_hits counter, by contrast, moves when the server originates traffic outbound. If you publish a server and inbound connections work but the untranslate counter stays flat, the traffic is not reaching this rule at all, and you have a routing or ACL problem upstream, not a NAT problem.
The Honest Gotcha: The First Connection Can Fail
Here is something the tidy walkthroughs leave out. The very first time we ran the packet-tracer, before any real traffic had ever flowed, it did not end in ALLOW. It ended like this:
Drop-reason: (no-v4-adjacency) No valid V4 adjacency. Check ARP
table (show arp) has entry for nexthop.The NAT was correct and the ACL was correct. The problem was purely that the ASA had never talked to 172.20.30.80 and had no ARP entry for it, so it had no Layer 2 adjacency to forward the frame to. A single ping dmz 172.20.30.80 (100 percent success) warmed the ARP cache, and the very next packet-tracer built the flow cleanly to ALLOW.
Worth remembering in production: a brand-new published server can fail its first connection with a no-adjacency drop until the ASA ARPs the host. It resolves itself the instant any traffic (a ping, a health check, the first real client that retries) forces the ASA to learn the MAC. If you publish a server and the first curl times out but the second one works, this is almost certainly why, and it is not a NAT misconfiguration.
Object NAT vs Twice NAT for Publishing
We published this server with Auto NAT (a nat statement inside a network object). That is the right tool for a straightforward one-host, one-public-IP publish. When you need the translation to depend on both the source and the destination (for example, present a server as one public IP to the internet but a different address to a partner network), you reach for Twice NAT instead. The two approaches map cleanly to when you would use each:
nat inside object networknat (x,y) source ... destination ...For a plain inbound publish, Auto NAT is cleaner and lands exactly where you want it. If you later find yourself needing the destination to steer the translation, that is Twice NAT territory, covered in the twice NAT walkthrough.
Why the ACL Uses the Real IP (and What Happens If You Get It Wrong)
We flagged it twice: the access list permits 172.20.30.80, not 203.0.113.80, because un-NAT runs before the ACL. This trips up nearly everyone coming from pre-8.3 ASA and PIX, where the ACL referenced the mapped (public) IP. Carry that old habit forward on a modern ASA and you write a rule that looks reasonable, matches nothing, and leaves you staring at a climbing deny counter wondering why a permit you clearly configured is not working.
The before-and-after proof of that behaviour, the same packet allowed with a real-IP ACL and dropped with a mapped-IP ACL, gets its own detailed treatment in NAT and ACLs together on ASA 8.3+. If you take one habit from this article, make it this: on a modern ASA, your inbound access rules target the real address of the server, every time.
Key Takeaways
- Destination NAT publishes a private server to a public IP. A static object NAT statement (
nat (dmz,outside) static DMZ-WEB-PUB) maps 172.20.30.80 to 203.0.113.80 bidirectionally. - Un-NAT happens before the ACL. Phase 1 of the inbound packet is UN-NAT (
Untranslate 203.0.113.80 to 172.20.30.80); the access-list check in Phase 2 runs against the already-un-NATed real IP. - Your inbound ACL references the real IP. Permit
host 172.20.30.80, not the public 203.0.113.80, on ASA 8.3 and later. - Watch untranslate_hits. Inbound connections increment
untranslate_hitsinshow nat detail; a flat counter means the traffic never reached the rule. - The first connection can fail with no-v4-adjacency. A newly published server may drop its first packet until the ASA ARPs it; a single ping warms the adjacency and the flow builds.
- Verify with real traffic. A curl from a real client returning
HTTP 200proves the whole path end to end, which packet-tracer alone cannot.
For the full inbound and outbound picture, how NAT, routing, and access control fit together on the ASA, work through the rest of the Cisco ASA cluster, which threads these labs together in order.