DEV Community

Cover image for Can You Really Run a VPC with Zero Public Subnets Using Regional NAT Gateway? A Hands-On Verification
Yuuki Yamashita
Yuuki Yamashita

Posted on

Can You Really Run a VPC with Zero Public Subnets Using Regional NAT Gateway? A Hands-On Verification

Introduction

Regional NAT Gateway is a relatively new feature announced on November 20, 2025. It is available in all commercial AWS Regions (except AWS GovCloud (US) and China Regions).

Right after launch, the community got excited: "Now I don't have to create public subnets," "Route table management gets dramatically simpler." A lot of verification posts went around.

Roughly half a year has passed. The launch buzz has settled, and I wanted to put my own hands on it again. Specifically, I wanted to nail down the question: can you really stand up a VPC with zero public subnets? — not just by what the console looks like, but by CLI responses and actual outbound traffic from private EC2 instances. This article is that verification log.

This article is not an introduction to Regional NAT Gateway. It is a record of facts I verified myself. For comprehensive spec coverage, see the official documentation.

Test environment

Item Value
Region ap-northeast-1 (Tokyo)
Date 2026-06-28
AWS CLI aws-cli/2.32.6
VPC CIDR 10.20.0.0/16
Subnets Private only, 2 subnets (10.20.1.0/24 @ 1a, 10.20.2.0/24 @ 1c)
Internet Gateway Present (attached to VPC only; no route from any subnet)
Public subnets 0 (never created)
NAT Gateway Regional (Automatic mode), 1
EC2 Amazon Linux 2023 × 2 (one each in 1a / 1c, accessed via SSM Session Manager)

To make the verification honest, I imposed these constraints:

  • Never create a public subnet (Tier=private only, MapPublicIpOnLaunch=false)
  • Never add an IGW route to a subnet's route table (the moment you do, it's a public subnet)
  • Never specify a subnet ID when creating the NAT Gateway (that's the whole point of regional mode)

Architecture

The point: the 0.0.0.0/0 → IGW route only exists in the NAT GW's auto-generated route table. No subnet-attached route table mentions the IGW. That's exactly why we can claim "no public subnet exists."

Steps and verifications

1. Create the VPC and private subnets

Plain VPC, two private subnets. I don't explicitly set MapPublicIpOnLaunch (it defaults to false).

VPC_ID=$(aws ec2 create-vpc --cidr-block 10.20.0.0/16 \
  --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=rnat-validation}]' \
  --query 'Vpc.VpcId' --output text)
aws ec2 modify-vpc-attribute --vpc-id $VPC_ID --enable-dns-hostnames
aws ec2 modify-vpc-attribute --vpc-id $VPC_ID --enable-dns-support

aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.20.1.0/24 \
  --availability-zone ap-northeast-1a \
  --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=rnat-private-1a},{Key=Tier,Value=private}]'
aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.20.2.0/24 \
  --availability-zone ap-northeast-1c \
  --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=rnat-private-1c},{Key=Tier,Value=private}]'
Enter fullscreen mode Exit fullscreen mode

2. Attach (only) an Internet Gateway

This is the big departure from convention.
The old pattern: "Create an IGW → add 0/0 → IGW to the public subnet's route table."
With Regional NAT Gateway, you only attach the IGW to the VPC. Don't touch any subnet's route table.

IGW_ID=$(aws ec2 create-internet-gateway \
  --tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=rnat-igw}]' \
  --query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID
Enter fullscreen mode Exit fullscreen mode

3. Create the Regional NAT Gateway

The heart of the verification. Do not pass any subnet ID. Pass only --availability-mode regional and --vpc-id.

aws ec2 create-nat-gateway \
  --vpc-id $VPC_ID \
  --availability-mode regional \
  --tag-specifications 'ResourceType=natgateway,Tags=[{Key=Name,Value=rnat-regional}]'
Enter fullscreen mode Exit fullscreen mode

Response (excerpt):

{
  "NatGateway": {
    "NatGatewayId": "nat-195a71ed93ed09728",
    "VpcId": "vpc-0e97b9be61166b6f5",
    "ConnectivityType": "public",
    "AvailabilityMode": "regional",
    "AutoScalingIps": "enabled",
    "AutoProvisionZones": "enabled",
    "NatGatewayAddresses": [],
    "State": "pending"
  }
}
Enter fullscreen mode Exit fullscreen mode

AvailabilityMode: regional / AutoScalingIps: enabled / AutoProvisionZones: enabled. Creation in Automatic mode succeeded. The remarkable part: there is no SubnetId in the response at all.

After a few dozen seconds it became available. Here is describe-nat-gateways:

{
  "NatGatewayId": "nat-195a71ed93ed09728",
  "State": "available",
  "VpcId": "vpc-0e97b9be61166b6f5",
  "NatGatewayAddresses": [
    {
      "AllocationId": "eipalloc-0597c57299628bded",
      "PublicIp": "57.182.69.198",
      "AvailabilityZone": "ap-northeast-1d",
      "AvailabilityZoneId": "apne1-az2",
      "Status": "succeeded"
    }
  ],
  "AvailabilityMode": "regional",
  "AutoScalingIps": "enabled",
  "AutoProvisionZones": "enabled",
  "RouteTableId": "rtb-07a53bf85bfebb378"
}
Enter fullscreen mode Exit fullscreen mode

Two important facts here:

  1. A dedicated route table (RouteTableId: rtb-07a53bf85bfebb378) was automatically generated for the NAT Gateway.
  2. My subnets are in 1a and 1c, but the first EIP was placed in ap-northeast-1d. With no workload ENIs yet, AWS picks some AZ to plant a single "foothold."

4. Look inside the NAT GW's auto-generated route table

describe-route-tables on rtb-07a53bf85bfebb378:

{
  "RouteTableId": "rtb-07a53bf85bfebb378",
  "VpcId": "vpc-0e97b9be61166b6f5",
  "Associations": [
    {
      "RouteTableAssociationId": "rtbassoc-0cec892ccb8340f3b",
      "GatewayId": "nat-195a71ed93ed09728",
      "AssociationState": {"State": "associated"}
    }
  ],
  "Routes": [
    {"DestinationCidrBlock": "10.20.0.0/16", "GatewayId": "local", "State": "active"},
    {"DestinationCidrBlock": "0.0.0.0/0", "GatewayId": "igw-08bce51fdce77bd7c", "Origin": "CreateRoute", "State": "active"}
  ]
}
Enter fullscreen mode Exit fullscreen mode

What we can see:

  • This route table is edge-associated to the NAT Gateway itself, not to any subnet (Associations[].GatewayId is the NAT GW ID).
  • 0.0.0.0/0 → IGW has been auto-injected (Origin: CreateRoute).

This is the core of "no public subnet required." Subnets no longer need to carry an IGW-bound route, because the NAT GW itself owns a private route table that talks directly to the IGW.

5. The private subnets' route table

The subnet-side route table needs only 0.0.0.0/0 → NAT GW.

PRIV_RTB=$(aws ec2 create-route-table --vpc-id $VPC_ID \
  --tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=rnat-private-rtb}]' \
  --query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id $PRIV_RTB \
  --destination-cidr-block 0.0.0.0/0 --nat-gateway-id nat-195a71ed93ed09728
aws ec2 associate-route-table --route-table-id $PRIV_RTB --subnet-id subnet-...1a
aws ec2 associate-route-table --route-table-id $PRIV_RTB --subnet-id subnet-...1c
Enter fullscreen mode Exit fullscreen mode

The key point: one RTB serves both 1a and 1c. In the classic zonal NAT pattern you'd build a NAT per AZ, build a route table per AZ, and write a separate 0/0 → AZ-specific NAT for each — repeated for every AZ. With Regional NAT Gateway, adding AZs requires no RTB changes.

Three route tables in the VPC:

  • rnat-private-rtb: Explicit subnet associations = 2 subnets (one RTB covers both 1a and 1c)
  • An unnamed Main RTB (VPC default, unused)
  • An unnamed auto-generated RTB: Edge associations column shows nat-195a71ed93ed...

Having a NAT GW ID in the "Edge associations" column is the visual signature of this new feature.

6. Does a private subnet actually reach the internet?

I SSH'd into the 1a EC2 with SSM Session Manager and hit https://checkip.amazonaws.com.

=== my source IP seen by the internet ===
57.182.69.198
=== resolve test ===
HTTP/2 405
=== route ===
default via 10.20.1.1 dev ens5 proto dhcp src 10.20.1.78 metric 512
10.20.0.2 via 10.20.1.1 dev ens5 proto dhcp src 10.20.1.78 metric 512
10.20.1.0/24 dev ens5 proto kernel scope link src 10.20.1.78 metric 512
=== metadata az ===
ap-northeast-1a
Enter fullscreen mode Exit fullscreen mode
  • The EC2's local IP is 10.20.1.78 (in 1a).
  • The source IP seen from the internet is 57.182.69.198, matching the NAT GW's EIP.
  • HTTP/2 405 is a normal response from https://www.amazon.co.jp/ to curl -I (amazon.co.jp doesn't allow HEAD; meaning TCP/TLS is established = external reachability is fine).

I ran the same script from the 1c EC2 — same 57.182.69.198. The 1c EC2 is in ap-northeast-1c, but the NAT GW currently has a single foothold in ap-northeast-1d. The docs say "until the expansion completes, traffic is processed in an existing AZ," and we can see that with our own eyes.

7. A formal proof that no public subnet exists

"Subjectively, I didn't create any" is too weak. Let's prove it formally via the API.

Subnets:

aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC_ID" \
  --query 'Subnets[].{Subnet:SubnetId,AZ:AvailabilityZone,CIDR:CidrBlock,MapPublicIpOnLaunch:MapPublicIpOnLaunch,Tier:Tags[?Key==`Tier`]|[0].Value}'
Enter fullscreen mode Exit fullscreen mode
AZ CIDR MapPublicIpOnLaunch Tier
ap-northeast-1c 10.20.2.0/24 false private
ap-northeast-1a 10.20.1.0/24 false private

Then count how many route tables are directly associated to a subnet AND have 0.0.0.0/0 pointing to an IGW — that is exactly the definition of "public subnet."

aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC_ID" --output json | \
  jq -r '[.RouteTables[]
    | select(.Routes[]? | select(.DestinationCidrBlock=="0.0.0.0/0"
        and (.GatewayId//"" | startswith("igw-"))))
    | .Associations[]
    | select(.SubnetId != null)
    | .SubnetId] | "public_subnet_count=" + (length|tostring)'
Enter fullscreen mode Exit fullscreen mode

Result:

public_subnet_count=0
Enter fullscreen mode Exit fullscreen mode

Zero route tables carry an IGW-bound 0/0 while being attached to a subnet. The only RTB with an IGW route, rtb-07a53bf85bfebb378, is edge-associated to the NAT Gateway itself — not to any subnet.

Inside the NAT GW's auto-generated RTB. The Routes tab shows 0.0.0.0/0 → igw-... injected as Create Route. The Edge associations tab lists the NAT GW. Explicit subnet associations are, as expected, "-" (none).

For completeness, the subnets list:

Only rnat-private-1a and rnat-private-1c. The VPC column shows both belong to the same VPC. Plain and simple.

8. Multi-AZ auto-expansion behavior

After launching an EC2 in 1c, I polled describe-nat-gateways once per minute to watch the expansion. The official docs say new-AZ expansion takes up to 60 minutes.

while true; do
  date -u +%H:%M:%SZ
  aws ec2 describe-nat-gateways --nat-gateway-ids nat-195a71ed93ed09728 \
    --query 'NatGateways[0].NatGatewayAddresses[].{AZ:AvailabilityZone,IP:PublicIp}' \
    --output text
  sleep 60
done
Enter fullscreen mode Exit fullscreen mode

Timeline:

Time (UTC) Elapsed Addresses Breakdown
08:33:27 0:00 1 1d=57.182.69.198 (Succeeded)
08:33:27 0:00 ↑ EC2 launched in 1c (expansion trigger)
08:35:01 +1:34 1 still 1d only
08:49:14 +15:47 3 1d=...198 (Succeeded) / 1c=null (Associating) / 1a=null (Associating)
08:55:19 +21:52 3 1d=...198 / 1c=52.193.167.188 / 1a=54.249.235.126, all Succeeded
09:33:54 +60:27 2 1a=...126 / 1c=...188 only. The 1d-side EIP, with no ENI, was auto-contracted.

Entries added in ~16 minutes, EIP allocation finished in ~22 minutes. Much faster than the 60-minute upper bound (just this single observation, of course).
I only added an EC2 in 1c, yet 1a was also expanded at the same time. That's because 1a already had its own EC2 — the NAT GW expands to every AZ that has a workload ENI, not just the AZ that triggered the event.

And about 60 minutes after launch, the EIP in 1d (which has no ENI) was automatically contracted. 1a and 1c stayed because they have ENIs.
This is the live observation of "contracts from the Availability Zone that has no active workloads" — auto-expansion is matched by auto-contraction.

During expansion (Associating):

I caught the moment when apne1-az2 (1d) was already Succeeded while apne1-az1 (1c) and apne1-az4 (1a) were still Associating.

After expansion:

All three AZs got distinct EIPs, all Succeeded.

Then I re-checked source IPs from each AZ's EC2:

EC2 AZ Private IP Source IP seen from internet Matching NAT GW EIP (same AZ)
ap-northeast-1a 10.20.1.78 54.249.235.126 54.249.235.126 (apne1-az4)
ap-northeast-1c 10.20.2.x 52.193.167.188 52.193.167.188 (apne1-az1)

After expansion, each AZ's EC2 uses the EIP placed in its own AZ (zonal affinity is established). The same single NAT GW let me observe both the pre-expansion "cross-AZ processing" state and the post-expansion "zonal affinity" state.

Assessment

Notes from the verification.

What I liked

  • The setup commands are genuinely short. Not having to specify a subnet when creating the NAT GW had more impact than I expected.
  • A whole class of mistakes ("oops, I put a private instance in a public subnet") becomes structurally impossible. The security upside is real.
  • No more "create-NAT-rewrite-route-table" dance when you add an AZ.
  • A single NAT GW ID covers every AZ's subnets — even your IaC stops needing per-AZ loops.

What to watch out for

  • AZ expansion is officially "up to 60 minutes" — in my run, ~22 minutes. Not for "process traffic from another AZ right now" requirements (until expansion completes, traffic is cross-AZ processed).
  • Private NAT use cases (VPC→VPC NAT) are not supported. You still need zonal NAT for that.
  • Migration from existing zonal NAT involves connection reset. Plan a maintenance window.
  • Pricing is on the same model as zonal NAT (hourly + data processing). "Consolidating into one NAT" does not lower the unit cost.

What I haven't tried yet (candidates for next time)

  • Behavior with Transit Gateway in the route path
  • How fast it follows a high rate of expansion/contraction events in a short window
  • Real-world operational feel of running zonal and regional side-by-side in the same VPC

Closing

Half a year after launch, building this from scratch made one thing concrete: "a VPC with no public subnets" is no longer a special construction — it's the path of least resistance.

When designing a new VPC, the natural ordering is now: "Think Regional NAT GW first. Fall back to zonal only when something forces it (private NAT needed, 60-minute expansion is unacceptable, etc.)."

The day "zero public subnets" stops being a clever trick and becomes the default isn't far off.
This verification gave me solid footing to say that.

References


Resources used in this verification (for cleanup reference)

Type ID
VPC vpc-0e97b9be61166b6f5
Subnet (1a) subnet-003c9aaac5dbd023a
Subnet (1c) subnet-0ff0bfe6aa12d2960
IGW igw-08bce51fdce77bd7c
Regional NAT GW nat-195a71ed93ed09728
Private RTB rtb-01d2fb6653a7e4f09
Auto-generated RTB (NAT GW edge) rtb-07a53bf85bfebb378
Security Group sg-0654f18b5120145dd
EC2 (1a) i-00aa80bb16f6f92a2
EC2 (1c) i-0f88674d033fdd78d
IAM Role / Profile rnat-ssm-role / rnat-ssm-profile
EIP allocation eipalloc-0597c57299628bded

Top comments (0)