Skip to content
BytePatterns

NAT Gateway vs Internet Gateway: Who Can Start the Connection

7 min readBytePatterns

NAT gateway vs internet gateway: two-way internet access for resources with public IPs, or outbound-only access for private subnets, and what each costs.

NAT gateway vs internet gateway in one line: an internet gateway is a free, horizontally scaled VPC attachment that gives two-way internet access to resources with a public IPv4 or IPv6 address in subnets whose route table points at it, while a NAT gateway is a paid, zonal service that lets resources in private subnets start connections out while nothing on the internet can start one in. They are not alternatives so much as partners: a public NAT gateway's traffic itself leaves through the internet gateway. Facts are from the Amazon VPC documentation listed at the end, as of October 2026.

Internet gatewayNAT gateway
Who can start a connectioneither side, to a resource's public IPonly the inside; unsolicited inbound is dropped
Where it livesattached to the VPC; route 0.0.0.0/0 to igw-…in one zone's public subnet with an Elastic IP; route to nat-…
Cost shapeno charge for the gateway; data transfer appliesper hour available plus per GB processed

The problem it solves

An app server has to download packages, call a payment API and send logs to a SaaS endpoint, yet it must never be reachable from the internet. In a public subnet with a public IP it can do all three, but anyone can also try its ports. In a private subnet with no route out it is safe and useless. The NAT gateway is the middle position, and the internet gateway is the door both use.

The intuition

An internet gateway is a door plus one-to-one address translation. You attach one to a VPC and add a route, usually 0.0.0.0/0 and ::/0, pointing at it; any subnet whose route table has that route is, by definition, public. That is not enough on its own: an instance needs a public IPv4 address or an Elastic IP. The instance itself only knows its private address, so the gateway rewrites the source to the public IP on the way out and the destination back to the private IP on the way in. The rewrite works for anyone in both directions, so the internet can open connections to that IP, and only security groups and network ACLs decide what gets through. The gateway is horizontally scaled and adds no bandwidth limit.

A NAT gateway is many-to-one, and one-way. A public NAT gateway sits in a public subnet with an Elastic IP. Private subnets send 0.0.0.0/0 to it. It maps each instance's source address to its own private address, and the internet gateway then maps that to the Elastic IP, so every private instance appears on the internet as one address. Replies are translated back because the NAT gateway remembers the flows it carried; a packet that matches no flow the inside started is dropped. It handles TCP, UDP and ICMP, starts at 5 Gbps and scales to 100 Gbps, and each IPv4 address supports up to 55,000 simultaneous connections to each unique destination, with up to eight addresses per gateway. You cannot attach a security group to it; the network ACL of its subnet applies.

A zonal NAT gateway belongs to one Availability Zone. If private subnets in three zones share it and its zone fails, all three lose the internet, so the documentation recommends one per zone, each zone routing to its own. A newer regional NAT gateway expands across zones automatically and needs no public subnet, which removes most of that bookkeeping. A private NAT gateway, with no Elastic IP, does the same translation towards other VPCs or on-premises networks.

IPv6 changes the question. IPv6 addresses are globally unique and public by default, so there is nothing to translate. For outbound-only IPv6, route ::/0 to an egress-only internet gateway: free, stateful, IPv6 only, and closed to connections started from outside. A NAT gateway touches IPv6 only through NAT64, letting IPv6-only workloads reach IPv4 services with DNS64.

When to use which

  • Load balancers, bastion hosts, anything the internet must reach: a public subnet routed to the internet gateway, with a public IP or Elastic IP.
  • App servers and databases that only call out: a private subnet routed to a NAT gateway, one per zone or a regional one.
  • Outbound-only IPv6: an egress-only internet gateway, not a NAT gateway.
  • Calls to S3 or DynamoDB from private subnets: a gateway endpoint, so that traffic skips the NAT gateway's per-gigabyte charge.

Watch it run

The animation from the VPC lesson draws both gateways on one stage, with the route tables as boxes of their own. The public table's 0.0.0.0/0 points at the internet gateway, so a user's request comes in through it to the load balancer and on to an app server in a private subnet. The app's outbound call follows its own table to the NAT gateway, then out through the internet gateway, and the reply returns the same way; the next frame shows that nothing outside can open a connection to the app. Watch the moment one packet crosses both gateways: that is the whole relationship.

VPC: Subnets, NAT & Firewalls

Step 1 of 11

A VPC is your private network in one Region. Each subnet lives in exactly one Availability Zone.

The same interactive animation as the lesson — step through it with the controls.

The code

A toy model, not the VPC data plane. Route lookup is a longest prefix match done with integer masks; the outbound path follows the subnet's table, and a NAT hop rewrites the source and then uses the NAT's own subnet's routes. Three packets: a web server in the public subnet, an app server in the private one, and the app server again after its table is pointed straight at the internet gateway:

def to_int(ip):
    return sum(int(x) << shift for x, shift in zip(ip.split("."), (24, 16, 8, 0)))

def lookup(table, dest):
    """Longest prefix match with integer masks: the most specific route wins."""
    best = None
    for cidr, target in table:
        net, length = cidr.split("/")
        mask = (0xFFFFFFFF << (32 - int(length))) & 0xFFFFFFFF
        if to_int(dest) & mask == to_int(net) & mask:
            if best is None or int(length) > best[0]:
                best = (int(length), target)
    return best[1] if best else "blackhole"

PUBLIC_RT = [("10.0.0.0/16", "local"), ("0.0.0.0/0", "igw")]
PRIVATE_RT = [("10.0.0.0/16", "local"), ("0.0.0.0/0", "nat")]
ROUTES = {"10.0.1.0/24": PUBLIC_RT, "10.0.2.0/24": PRIVATE_RT}
PUBLIC_IPS = {"10.0.1.10": "203.0.113.10", "10.0.1.99": "203.0.113.99"}  # web + NAT's EIP
NAT_IP = "10.0.1.99"                                   # the NAT lives in the public subnet

def outbound(src, subnet, dest):
    hops, sender = [src], src
    target = lookup(ROUTES[subnet], dest)
    if target == "nat":                                # NAT swaps the source address ...
        hops.append(f"nat (src {src} -> {NAT_IP})")
        sender, target = NAT_IP, lookup(PUBLIC_RT, dest)   # ... then uses its own subnet's routes
    if target == "igw":
        if sender not in PUBLIC_IPS:
            return hops + ["igw: dropped, no public IPv4"]
        return hops + [f"igw (src {sender} -> {PUBLIC_IPS[sender]})", dest]
    return hops + [target, dest]

print(outbound("10.0.1.10", "10.0.1.0/24", "198.51.100.7"))
print(outbound("10.0.2.20", "10.0.2.0/24", "198.51.100.7"))
PRIVATE_RT[1] = ("0.0.0.0/0", "igw")                           # the classic mistake
print(outbound("10.0.2.20", "10.0.2.0/24", "198.51.100.7"))
# ['10.0.1.10', 'igw (src 10.0.1.10 -> 203.0.113.10)', '198.51.100.7']
# ['10.0.2.20', 'nat (src 10.0.2.20 -> 10.0.1.99)', 'igw (src 10.0.1.99 -> 203.0.113.99)', '198.51.100.7']
# ['10.0.2.20', 'igw: dropped, no public IPv4']

The private server's packet is translated twice, once by each gateway, and leaves as the NAT's Elastic IP. Pointing the private table straight at the internet gateway does not give the server internet access; with no public address, its traffic goes nowhere. Now the inbound direction, where the two gateways differ most:

class ToyNat:
    """Connection tracking: a reply gets in only if the inside started that flow."""
    def __init__(self):
        self.flows = set()
    def out(self, inside, remote, port):
        self.flows.add((remote, port))                 # remember who we talked to
    def inbound(self, remote, port):
        return "delivered" if (remote, port) in self.flows else "dropped"

def igw_inbound(public_ip):
    """The IGW maps a public IPv4 back to its private address, for anyone."""
    return {pub: priv for priv, pub in PUBLIC_IPS.items()}.get(public_ip, "no such address")

nat = ToyNat()
nat.out("10.0.2.20", "198.51.100.7", 443)
print("reply from 198.51.100.7:443 ->", nat.inbound("198.51.100.7", 443))
print("scan from 192.0.2.66:22     ->", nat.inbound("192.0.2.66", 22))
print("to 203.0.113.10 via igw     ->", igw_inbound("203.0.113.10"))
# reply from 198.51.100.7:443 -> delivered
# scan from 192.0.2.66:22     -> dropped
# to 203.0.113.10 via igw     -> 10.0.1.10

Both models against brute force on random cases: the lookup against the ipaddress module, and the NAT against a scan of every flow sent so far:

import ipaddress, random

def lookup_reference(table, dest):
    """Brute force with the ipaddress module: every matching route, longest prefix."""
    hits = [(ipaddress.ip_network(c).prefixlen, t) for c, t in table
            if ipaddress.ip_address(dest) in ipaddress.ip_network(c)]
    return max(hits)[1] if hits else "blackhole"

random.seed(3)
ok = True
for _ in range(2000):
    table = {}
    for i in range(random.randint(0, 6)):
        length = random.choice([0, 8, 16, 20, 24, 28, 32])
        net = ipaddress.ip_network(f"10.{random.randint(0, 3)}.{random.randint(0, 3)}.0/{length}", strict=False)
        table[str(net)] = f"t{i}"                      # one target per destination
    dest = f"10.{random.randint(0, 3)}.{random.randint(0, 3)}.{random.randint(0, 3)}"
    ok &= lookup(list(table.items()), dest) == lookup_reference(list(table.items()), dest)
    nat, sent = ToyNat(), []
    for _ in range(random.randint(0, 8)):
        flow = (f"198.51.100.{random.randint(1, 4)}", random.choice([80, 443]))
        if random.random() < 0.5:
            nat.out("10.0.2.20", *flow)
            sent.append(flow)
        else:
            ok &= (nat.inbound(*flow) == "delivered") == any(f == flow for f in sent)
print(ok)  # True

The complexity

  • Internet gateway: no charge for the gateway itself; you pay data transfer for the instances that use it. An egress-only internet gateway is likewise free.
  • NAT gateway: charged for each hour it is available and for each gigabyte it processes, on top of data transfer, and cross-zone traffic to it adds more. One per zone multiplies the hourly part.
  • Lookup: the toy scans the table per packet, linear in routes; whatever the order, the most specific route wins.

Where it goes wrong

  • NAT gateway in a private subnet. Its own route table has no path to the internet gateway, so every outbound connection times out. A public NAT gateway goes in a public subnet.
  • Expecting inbound through NAT. There is no port forwarding. To publish a private service, put a load balancer in a public subnet in front of it.
  • One zonal NAT gateway for every zone. One zone failure cuts all private subnets off, and the other zones pay cross-zone charges every day. Use one per zone or a regional NAT gateway.
  • S3 and DynamoDB traffic through NAT. Backups and data pipelines quietly run up the per-gigabyte charge. Add gateway endpoints.
  • IPv6 left open. A subnet with ::/0 to the internet gateway exposes IPv6 instances even if IPv4 goes through NAT. Use an egress-only internet gateway.
  • Port exhaustion. Thousands of connections from many instances to one destination can exceed 55,000 per address; the ErrorPortAllocation metric shows it, and secondary IPv4 addresses on the gateway fix it.

When it shows up in interviews

As the networking step of a design question, "the app servers must not be public but need to call a third-party API", or as the follow-up "what does that NAT gateway cost you, and what happens when its zone goes down?". A good answer separates routing from translation. Subnets, security groups and NACLs are covered in public vs private subnets, security groups vs NACLs.

How to say it in an interview

"An internet gateway is a free, horizontally scaled attachment on the VPC; a subnet whose route table sends 0.0.0.0/0 to it is public, and instances there with a public IP get two-way internet, with the gateway doing one-to-one translation. A NAT gateway lets private instances start connections out while blocking anything unsolicited coming in; it sits in a public subnet with an Elastic IP, its traffic still leaves through the internet gateway, and it is zonal and billed per hour and per gigabyte. So I put one per zone, or a regional one, send S3 and DynamoDB through gateway endpoints, and use an egress-only internet gateway for IPv6."

Frequently asked questions

What is the difference between a NAT gateway and an internet gateway?

An internet gateway connects a VPC to the internet in both directions for resources with public IP addresses. A NAT gateway lets resources in private subnets, which have no public IP, start outbound connections while blocking connections started from the internet. A public NAT gateway sends its own traffic out through the internet gateway.

Does a NAT gateway need an internet gateway?

A public NAT gateway does: it lives in a public subnet whose route table sends internet traffic to the internet gateway, which translates the NAT's address to its Elastic IP. A private NAT gateway, used towards other VPCs or on-premises networks, does not, and traffic it sends to an internet gateway is dropped.

Can a NAT gateway be used for inbound traffic?

No. Connections must be initiated from inside the VPC; the NAT gateway only lets replies to those connections back in and drops unsolicited inbound traffic. To expose a service in a private subnet, put a load balancer in a public subnet in front of it.

Sources