Amazon VPC – Consider Avoiding Multiple NAT Gateways for a Single Subnet

·

By

Infracost

Amazon VPC – Consider Avoiding Multiple NAT Gateways for a Single Subnet

·

By

Infracost

The Amazon VPC multiple NAT gateways policy flags subnets that contain more than one NAT gateway, where one gateway can carry the same traffic. Each extra Amazon VPC NAT gateway bills hourly, about $33 per month in US East, whether or not it carries traffic. Use this policy to keep each subnet to a single NAT gateway, typically one per Availability Zone, unless bandwidth limits require otherwise.

Attribute

Detail

Cloud Provider

AWS

Resource Type

Amazon VPC NAT gateways (aws_nat_gateway)

Terraform Attribute

subnet_id on aws_nat_gateway, usually set through count or for_each

Compliant Value

No more than one aws_nat_gateway per subnet_id, typically one per Availability Zone

Cost Impact

About $0.045 per hour per NAT gateway in US East, roughly $394 per year each before data processing

Why This Policy Matters

How It Helps Reduce Cloud Costs

An Amazon VPC NAT gateway has two charges: an hourly charge for every hour the gateway exists, and a per-GB data processing charge. In US East, AWS lists both at $0.045, per hour and per GB processed.

The data processing charge follows bytes, not gateway count. Two NAT gateways in the same subnet process the same total traffic as one. The second NAT gateway only adds a second hourly charge.

AWS builds each NAT gateway with redundancy inside its Availability Zone. A second NAT gateway in the same subnet sits in the same Availability Zone, so it adds no zone-level resilience.

A second NAT gateway in the same subnet is paid redundancy that AWS already provides. This is why the policy targets gateway count per subnet rather than NAT gateway use in general.

Duplicate NAT gateways rarely come from a deliberate design choice. They usually come from Terraform code that creates one NAT gateway per private subnet, then spreads those gateways across a smaller number of public subnets.

Potential Savings

Every duplicate Amazon VPC NAT gateway removed saves its full hourly charge. At $0.045 per hour, that is about $32.85 per month, or roughly $394 per year, in US East. Infracost uses around $420 per year as a typical per-gateway figure, since hourly rates are higher in several other regions.

Each public NAT gateway also holds at least one Elastic IP address. AWS charges $0.005 per hour for every public IPv4 address, about $3.65 per month. Releasing the Elastic IP address of a removed NAT gateway stops that charge too.

This saving is fixed and predictable. It does not depend on traffic volume, which makes duplicate NAT gateways one of the easier network costs to forecast and remove.

The saving scales with how often a VPC pattern is copied. A shared Terraform module that creates three extra NAT gateways, deployed across 20 AWS accounts, carries 60 idle hourly charges, about $23,650 per year.

Implementation Guide

Infrastructure-as-Code Example (Terraform)

The following examples show a VPC with three Availability Zones, three public subnets, and six private subnets. The private subnets hold an application tier and a data tier in each Availability Zone.

Non-compliant configuration: one NAT gateway per private subnet, two per public subnet

resource "aws_eip" "nat" {
  count  = length(aws_subnet.private)
  domain = "vpc"
}

resource "aws_nat_gateway" "this" {
  count         = length(aws_subnet.private)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index % length(aws_subnet.public)].id

  tags = {
    Name = "nat-${count.index}"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "private" {
  count  = length(aws_subnet.private)
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.this[count.index].id
  }
}

resource "aws_route_table_association" "private" {
  count          = length(aws_subnet.private)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index].id
}
resource "aws_eip" "nat" {
  count  = length(aws_subnet.private)
  domain = "vpc"
}

resource "aws_nat_gateway" "this" {
  count         = length(aws_subnet.private)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index % length(aws_subnet.public)].id

  tags = {
    Name = "nat-${count.index}"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "private" {
  count  = length(aws_subnet.private)
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.this[count.index].id
  }
}

resource "aws_route_table_association" "private" {
  count          = length(aws_subnet.private)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index].id
}
resource "aws_eip" "nat" {
  count  = length(aws_subnet.private)
  domain = "vpc"
}

resource "aws_nat_gateway" "this" {
  count         = length(aws_subnet.private)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index % length(aws_subnet.public)].id

  tags = {
    Name = "nat-${count.index}"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "private" {
  count  = length(aws_subnet.private)
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.this[count.index].id
  }
}

resource "aws_route_table_association" "private" {
  count          = length(aws_subnet.private)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index].id
}

This code creates six NAT gateways for three public subnets, so every public subnet holds two. The count on aws_nat_gateway follows the private subnets, but NAT gateway placement only needs to follow the Availability Zones. This configuration increases cost without improving availability.

Compliant configuration: one NAT gateway per public subnet and Availability Zone

resource "aws_eip" "nat" {
  count  = length(aws_subnet.public)
  domain = "vpc"
}

resource "aws_nat_gateway" "this" {
  count         = length(aws_subnet.public)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = {
    Name = "nat-${count.index}"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "private" {
  count  = length(aws_subnet.public)
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.this[count.index].id
  }
}

resource "aws_route_table_association" "private" {
  count          = length(aws_subnet.private)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index % length(aws_route_table.private)].id
}
resource "aws_eip" "nat" {
  count  = length(aws_subnet.public)
  domain = "vpc"
}

resource "aws_nat_gateway" "this" {
  count         = length(aws_subnet.public)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = {
    Name = "nat-${count.index}"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "private" {
  count  = length(aws_subnet.public)
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.this[count.index].id
  }
}

resource "aws_route_table_association" "private" {
  count          = length(aws_subnet.private)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index % length(aws_route_table.private)].id
}
resource "aws_eip" "nat" {
  count  = length(aws_subnet.public)
  domain = "vpc"
}

resource "aws_nat_gateway" "this" {
  count         = length(aws_subnet.public)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = {
    Name = "nat-${count.index}"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "private" {
  count  = length(aws_subnet.public)
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.this[count.index].id
  }
}

resource "aws_route_table_association" "private" {
  count          = length(aws_subnet.private)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index % length(aws_route_table.private)].id
}

The compliant version sets count on aws_nat_gateway, aws_eip, and the private aws_route_table from the public subnets. Each private subnet still routes to the NAT gateway in its own Availability Zone. This assumes private subnet isits in the same Availability Zone as public subnet i % 3 in both the non-compliant and compliant layouts.

Teams using the community terraform-aws-modules/vpc/aws module get the same result by setting enable_nat_gateway = true, single_nat_gateway = false, and one_nat_gateway_per_az = true.

Step-by-Step Fix Instructions

  1. List every available Amazon VPC NAT gateway and find subnets that hold more than one, using aws ec2 describe-nat-gateways.

  2. In Terraform, find each aws_nat_gateway resource and check how subnet_id and count or for_each are set. Look for a NAT gateway count derived from private subnets.

  3. Review Amazon CloudWatch NAT gateway metrics for at least two weeks: BytesOutToDestination, ActiveConnectionCount, and ErrorPortAllocation. Confirm one NAT gateway per Availability Zone can carry the peak load.

  4. Check whether any partner, SaaS vendor, or firewall allowlists the Elastic IP addresses of the NAT gateways you plan to remove.

  5. Change count on aws_nat_gateway, aws_eip, and the private aws_route_table to one per public subnet. Point each private subnet association at the route table in its own Availability Zone.

  6. Run terraform plan. Expect the extra aws_nat_gateway, aws_eip, and aws_route_table resources to be destroyed, and some aws_route_table_association resources to change.

  7. Apply during a low-traffic window. Long-lived outbound connections through a removed NAT gateway are dropped and must reconnect.

  8. Add Infracost to CI/CD so a new duplicate NAT gateway is flagged in the pull request before it merges.

Best Practices

  • Derive Amazon VPC NAT gateway count from Availability Zones or public subnets in shared Terraform modules, never from private subnets.

  • Route each private subnet to the NAT gateway in the same Availability Zone. Cross-AZ routing adds inter-AZ data transfer charges and a cross-zone failure path.

  • Add Amazon VPC gateway endpoints for Amazon S3 and Amazon DynamoDB. Gateway endpoints have no hourly charge and take that traffic off the NAT gateway data processing bill.

  • For non-production VPCs, a single NAT gateway per VPC is often acceptable. This trades zone resilience for lower cost, which suits development environments but rarely production.

  • This policy does not flag one NAT gateway per Availability Zone across different subnets. That layout is the AWS-recommended pattern for zone-independent production workloads.

Tools and Scripts

How does Infracost detect multiple NAT gateways in a single subnet?

The Amazon VPC multiple NAT gateways policy is available in Infracost, including in the free trial. When Infracost runs in CI/CD, it checks aws_nat_gateway resources in each pull request. It flags configurations that place more than one NAT gateway in the same subnet, in the PR comment, with the file and line to change.

This catches a duplicate NAT gateway before it starts billing, when the fix is a one-line change to count or subnet_id. Infracost also scans connected repositories for existing violations. Platform and FinOps teams can then burn down that backlog over time and track progress against it.

Teams can configure the policy to warn engineers or to block pull requests that fail it. That choice depends on how strictly the organization wants to enforce one NAT gateway per subnet.

To list subnets that hold more than one available NAT gateway in the current region with the AWS CLI:

aws ec2 describe-nat-gateways \
  --filter Name=state,Values=available \
  --query "NatGateways[].SubnetId" \
  --output text | tr '\t' '\n' | sort | uniq -c | awk '$1 > 1'
aws ec2 describe-nat-gateways \
  --filter Name=state,Values=available \
  --query "NatGateways[].SubnetId" \
  --output text | tr '\t' '\n' | sort | uniq -c | awk '$1 > 1'
aws ec2 describe-nat-gateways \
  --filter Name=state,Values=available \
  --query "NatGateways[].SubnetId" \
  --output text | tr '\t' '\n' | sort | uniq -c | awk '$1 > 1'

To see the NAT gateways in one of those subnets, with their Elastic IP addresses:

aws ec2 describe-nat-gateways \
  --filter Name=subnet-id,Values=subnet-0123456789abcdef0 \
  --query "NatGateways[].[NatGatewayId,State,NatGatewayAddresses[0].PublicIp]" \
  --output

aws ec2 describe-nat-gateways \
  --filter Name=subnet-id,Values=subnet-0123456789abcdef0 \
  --query "NatGateways[].[NatGatewayId,State,NatGatewayAddresses[0].PublicIp]" \
  --output

aws ec2 describe-nat-gateways \
  --filter Name=subnet-id,Values=subnet-0123456789abcdef0 \
  --query "NatGateways[].[NatGatewayId,State,NatGatewayAddresses[0].PublicIp]" \
  --output

Examples of Impact

Illustrative example: one VPC with a per-subnet NAT pattern. A platform team runs a VPC with three Availability Zones and six private subnets. The VPC code creates six Amazon VPC NAT gateways, two per public subnet. Reducing to three NAT gateways saves about $1,180 per year in hourly charges, plus about $130 in Elastic IP address charges.

Illustrative example: a module copied across accounts. The same VPC module is deployed in 20 AWS accounts, each with three duplicate NAT gateways. Fixing the module once removes 60 NAT gateways, about $23,650 per year in hourly charges and about $2,630 in Elastic IP address charges.

The fix in a shared module pays back in every account that uses it.

(These are illustrative scenarios using published US East list prices, not specific customer accounts.)

Considerations and Caveats

A second Amazon VPC NAT gateway in the same subnet is rarely justified, but a few constraints deserve a check before removal.

  • Bandwidth: An Amazon VPC NAT gateway supports 5 Gbps and scales automatically up to 100 Gbps. AWS suggests splitting resources across subnets, with a NAT gateway in each, for more than that.

  • Connection limits: Each NAT gateway IPv4 address supports up to 55,000 simultaneous connections to each distinct destination. Adding secondary IPv4 addresses, up to 8 in total, raises that limit without a second NAT gateway.

  • Allowlisted IP addresses: Removing a NAT gateway changes the egress IP address for some workloads. Update external allowlists before applying the change.

  • Data processing is unchanged: This policy removes hourly and Elastic IP address charges. NAT gateway data processing charges depend on traffic and stay the same.

  • Regional NAT gateways: AWS now offers a regional NAT gateway mode (availability_mode = "regional" on aws_nat_gateway), billed per Availability Zone hour. This policy targets zonal NAT gateways placed with subnet_id.

Related Policies and Concepts

  • Amazon VPC - consider avoiding multiple VPC endpoints for the same service: the companion Amazon VPC policy that removes duplicate interface endpoints, the same reuse-before-create discipline applied to private connectivity.

  • EC2 - consider disabling associate public IP address in launch templates: a related policy that keeps instances in private subnets, which usually reach the internet through a NAT gateway.

  • ECS - prevent services from being publicly accessible: a related policy for Amazon ECS services, which commonly run in private subnets and send outbound traffic through NAT gateways.

  • How to Enforce Cloud Cost Policies in Your CI/CD Pipeline: a planned Infracost resource article on catching cost policy violations like duplicate NAT gateways at the pull request stage.

Frequently Asked Questions (FAQs)

Is this policy supported in Infracost?

Yes. The Amazon VPC multiple NAT gateways policy is supported in Infracost and available in the free trial. Infracost flags aws_nat_gateway resources that place more than one NAT gateway in the same subnet in pull requests, before the extra gateway starts billing.

Can this policy be customized?

Yes. Teams can configure how Infracost enforces the Amazon VPC multiple NAT gateways policy. For example, a finding can warn engineers or block the pull request, and the policy can be limited to new resources only.

Does Infracost automatically fix violations?

No. Infracost reports duplicate Amazon VPC NAT gateways in the pull request, with the file and line to change. The team reviews and applies the fix, because egress IP addresses and bandwidth needs differ by workload.

Is this policy cloud-agnostic?

No. The Amazon VPC multiple NAT gateways policy targets AWS through the aws_nat_gateway Terraform resource. Azure NAT Gateway and Google Cloud NAT use different resources, attributes, and pricing models.

How often should I review Amazon VPC NAT gateway placement?

Teams running Infracost in CI/CD get an Amazon VPC NAT gateway check on every pull request that touches an aws_nat_gateway resource. For existing VPCs, a quarterly review of NAT gateway count per subnet is a reasonable cadence.

Does removing a duplicate NAT gateway reduce availability?

No, when the duplicate Amazon VPC NAT gateway sits in the same subnet as another one. AWS builds each NAT gateway with redundancy inside its Availability Zone, so a second gateway in the same subnet adds no zone-level resilience.

How much does an extra Amazon VPC NAT gateway cost?

An extra Amazon VPC NAT gateway costs about $0.045 per hour in US East, roughly $394 per year, before data processing. Its Elastic IP address adds about $3.65 per month. Hourly rates are higher in several other regions.

Create Free Account

This policy is supported in Infracost and available in the free trial. Sign up today and scan your code using our entire library of FinOps policies.

Get started
with Infracost

© 2026 Infracost Inc

Manage cookies

Get started
with Infracost

© 2026 Infracost Inc

Manage cookies

Get started
with Infracost

© 2026 Infracost Inc

Manage cookies