NAT gateway costs are the line on the AWS bill that surprises me most often in serverless projects. The Lambda functions cost a few euros a month, and right next to them sits an amount for a network component that does little more than pass packets along. Ever since I deliberately added a NAT gateway in my article on the Claude IP allowlist, I wanted to run the numbers the other way round: when do you really need one, and how do you get rid of it when you don't?
So this is not a generic list of savings tips. You will learn how to measure where your traffic goes first and then change things on purpose: with gateway endpoints, with an honest break-even calculation for interface endpoints and with IPv6 as a way to reach the internet without any NAT at all. I walk through the change step by step with the Serverless Framework.
In short
A NAT gateway charges per hour and for every gigabyte that flows through it, even when the destination is S3 or DynamoDB in the same region. The free gateway endpoints usually give you the biggest lever, followed by IPv6 through an egress-only internet gateway. The catch: without NAT you can only reach destinations that speak IPv6 or are connected through an endpoint.
What you will learn in this article
- What NAT gateway costs are made of and what drives them
- How to use metrics and flow logs to find the traffic that goes through NAT
- At what data volume an interface endpoint pays off
- How to send Lambda to the internet over IPv6 without a NAT gateway using serverless.yml
- Which mistakes cause timeouts after the change
What NAT gateway costs are made of
A NAT gateway translates the private addresses of your subnets into a public IPv4 address, so functions without a public address of their own can still reach the internet. NAT stands for Network Address Translation. AWS bills two things separately for it: a fee for every hour the gateway exists and a fee for every gigabyte it processes. On top of that, since 2024 you pay for the public IPv4 address attached to the gateway.
| Item | Price (US East example) | What drives it |
|---|---|---|
| NAT gateway per hour | USD 0.045 | Number of gateways or AZs, around the clock |
| Data processed | USD 0.045 per GB | Every GB through the gateway, in both directions |
| Public IPv4 address | USD 0.005 per hour | One Elastic IP per gateway |
| Data transfer out to the internet | Standard rate, e.g. USD 0.09 per GB | Also applies without NAT |
Prices from AWS pricing examples as of November 9, 2026. Other regions such as Frankfurt differ, so check the pricing page for your region.
The base fee sounds harmless. Now do the math: two gateways for two Availability Zones (AZs, separate data center areas within a region) run 730 hours a month. Including the two IPv4 addresses, that is USD 73 before a single byte has moved. For a serverless application whose Lambda bill is in the single digits, that is a hefty fixed cost.
The second item is often the more expensive one. The per-gigabyte fee also applies to traffic to AWS services. If your function reads large files from S3 every day or writes a lot to DynamoDB, that traffic goes through the NAT gateway unless you do something about it. In other words, you pay a toll for data that never left AWS.
Do you need the VPC at all?
Before you optimize, ask the uncomfortable question: does the function have to run in a VPC at all? Without a VPC configuration, a Lambda function has internet access out of the box and reaches every AWS service through its public endpoints, with no NAT gateway involved. I keep seeing projects where functions were put into a VPC out of habit, “because it's more secure”.
You need a VPC when the function has to reach private resources, for example an RDS database, an ElastiCache cluster or an internal service behind a private load balancer. A second good reason is a fixed outbound address for IP allowlists at partners. If none of that applies, the cheapest change is to remove the VPC configuration. You control permissions through IAM anyway, not through the network.
Measure first: where does the NAT traffic go?
Changing things without numbers is guesswork. I work in three stages, from coarse to fine. All commands only need read access and run with the AWS CLI.
1. Find the costs in Cost Explorer
In Cost Explorer, filter by the service “EC2-Other” and group by “Usage Type”. Entries with NatGateway-Hours are the base fee, NatGateway-Bytes is the processed data. They are prefixed with a region code, for example EUC1- for Frankfurt. If the bytes share is small, rerouting traffic will hardly pay off, and the real question is whether you need the gateway at all.
2. Query the data volume per NAT gateway
Every NAT gateway publishes metrics in the AWS/NATGateway namespace. For costs, the most important ones are BytesOutToDestination (from your functions to the outside) and BytesInFromDestination (the responses). The following query adds up one month:
# Total outbound bytes of a NAT gateway in October aws cloudwatch get-metric-statistics \ --namespace AWS/NATGateway \ --metric-name BytesOutToDestination \ --dimensions Name=NatGatewayId,Value=nat-0abc1234def567890 \ --start-time 2026-10-01T00:00:00Z \ --end-time 2026-11-01T00:00:00Z \ --period 2678400 \ --statistics Sum The result is a single data point with the sum in bytes. Divide by 1,073,741,824 to get gigabytes. Repeat the query with BytesInFromDestination, because responses count towards the processing fee as well.
3. Break down destinations with VPC Flow Logs
The metric tells you how much, but not where to. For that, enable VPC Flow Logs on the NAT gateway's network interface for a few days and write them to CloudWatch Logs. Flow logs record source, destination and bytes of every connection. Then find the biggest destinations with CloudWatch Logs Insights:
fields @timestamp, srcAddr, dstAddr, bytes | stats sum(bytes) / 1073741824 as gb by dstAddr | sort gb desc | limit 20 Compare the destination addresses with the address lists AWS publishes. For S3 and DynamoDB there are managed prefix lists that you can fetch with the CLI. If a large share of your top destinations falls into them, you have already found the cheapest lever. Remember to switch the flow logs off again afterwards, because they cost money too.
# Show the prefix lists for S3 and DynamoDB in Frankfurt aws ec2 describe-managed-prefix-lists \ --region eu-central-1 \ --filters Name=prefix-list-name,Values=com.amazonaws.eu-central-1.s3,com.amazonaws.eu-central-1.dynamodb \ --query 'PrefixLists[].[PrefixListName,PrefixListId]' \ --output table # Print the address ranges of one list (ID from the table above) aws ec2 get-managed-prefix-list-entries --prefix-list-id pl-0123456789abcdef0 --region eu-central-1 Four ways to reduce NAT gateway costs
The options build on each other. I recommend checking them in this order, because the effort goes up while the benefit usually goes down.
1. Gateway endpoints for S3 and DynamoDB
A gateway endpoint is an entry in your route table that sends traffic to S3 or DynamoDB straight into the AWS network, bypassing the NAT gateway. It costs nothing per hour and nothing per gigabyte. I can't think of a good reason to leave it out of a VPC that runs Lambda functions. There are two limits you should know: it only exists for these two services, and it only covers resources in the same region. A bucket in another region is still reached through the NAT gateway.
2. Interface endpoints: calculate first, then build
For almost every other service, such as SQS, Secrets Manager or Bedrock, there are interface endpoints based on AWS PrivateLink. These are network interfaces in your subnets that make the service reachable privately. Unlike gateway endpoints, they cost money: roughly USD 0.01 per hour and AZ plus USD 0.01 per gigabyte processed.
That leads to a simple calculation. One endpoint in two AZs costs USD 14.60 a month in base fees. Per gigabyte you save USD 0.035 compared with NAT. As long as you keep the NAT gateway anyway, an interface endpoint therefore only pays off from about 417 GB per month and service. A typical function that drops a few messages into a queue will never get there.
The picture changes when endpoints let you drop the NAT gateway entirely. If you only need SQS, Secrets Manager and S3, for example, two interface endpoints at USD 29.20 in total compete with USD 73 in base fees for two NAT gateways. In that case the endpoint option clearly wins, and as a bonus the traffic never leaves the AWS network.
3. IPv6 through an egress-only internet gateway
An egress-only internet gateway is the IPv6 counterpart to the NAT gateway: it allows connections from the inside out and blocks anything trying to open a connection from the outside. According to the AWS documentation, the gateway itself is free, and you only pay for regular data transfer. For some time now, Lambda has been able to use IPv6 for outbound connections in dual-stack subnets, provided you explicitly turn it on.
The catch sits at the other end of the line. Over IPv6 you can only reach destinations that have an IPv6 address themselves, meaning an AAAA record in DNS. Many large APIs have one, but far from all. A service that only speaks IPv4 cannot be reached without a NAT gateway, and your function then runs into a timeout. That is why this option always comes with a test of every destination, which I show you further down.
4. Regional NAT gateway: more convenient, not cheaper
For some time now, NAT gateways have also been available in regional mode. Such a gateway doesn't need a public subnet, follows your workloads into new AZs automatically, and you maintain a single route instead of one per AZ. That is a real simplification. It changes little about the costs, though: AWS charges the hourly fee for every AZ in which the gateway is active. I therefore see regional mode as an operational win, not as a way to save money.
To show what this means in a concrete case, I calculated an example: two AZs, 600 GB per month to S3 and DynamoDB and 40 GB to an external API.
Monthly cost in the example with two AZs and US East prices. Data transfer out to the internet is added equally in all three variants.
Step by step: Lambda without a NAT gateway
Now let's build the cheapest option as an example. A fictional city library syncs its catalog every morning: a Lambda function loads a feed of new releases from an external server and writes the books to DynamoDB. The function lives in a VPC but has no NAT gateway. It reaches DynamoDB through a gateway endpoint and the feed server over IPv6.
🎬 Outbound traffic of a Lambda function without a NAT gateway – this animated diagram only runs on my site: watch it on sascha-ms.de
Prerequisites
- Node.js 22.18 or newer (runs TypeScript files directly) and npm
- Serverless Framework v4 (
npm i -g serverless) and an AWS account with permissions to create a VPC, Lambda and DynamoDB - A destination server with an IPv6 address for the feed (check with
dig AAAA your-host.example)
1. Create the project
mkdir library-catalog && cd library-catalog npm init -y npm pkg set type=module npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb npm install -D typescript @types/node mkdir src scripts The tsconfig.json is only there for type checking. Bundling is handled by Serverless Framework v4 with its built-in esbuild.
// tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noEmit": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "types": ["node"] }, "include": ["src", "scripts"] } 2. The serverless.yml with VPC, IPv6 and gateway endpoints
The file contains the complete network layer. In addition to its IPv4 range, the VPC gets an IPv6 block from Amazon. Both private subnets are dual-stack, so they have IPv4 and IPv6 ranges. That is mandatory, otherwise Lambda rejects the IPv6 option. The route table sends ::/0, meaning all IPv6 destinations, to the egress-only internet gateway. There is deliberately no IPv4 default route.
# serverless.yml service: library-catalog frameworkVersion: '4' provider: name: aws runtime: nodejs22.x architecture: arm64 region: eu-central-1 memorySize: 512 timeout: 30 environment: TABLE_NAME: !Ref CatalogTable FEED_URL: ${param:feedUrl, 'https://example.org/new-releases.json'} iam: role: statements: - Effect: Allow Action: - dynamodb:PutItem Resource: !GetAtt CatalogTable.Arn functions: catalogSync: handler: src/handler.handler events: - schedule: cron(15 5 * * ? *) vpc: ${self:custom.vpc} netcheck: handler: src/netcheck.handler vpc: ${self:custom.vpc} custom: vpc: securityGroupIds: - !Ref LambdaSecurityGroup subnetIds: - !Ref PrivateSubnetA - !Ref PrivateSubnetB ipv6AllowedForDualStack: true resources: Resources: Vpc: Type: AWS::EC2::VPC Properties: CidrBlock: 10.20.0.0/16 EnableDnsSupport: true EnableDnsHostnames: true VpcIpv6Block: Type: AWS::EC2::VPCCidrBlock Properties: VpcId: !Ref Vpc AmazonProvidedIpv6CidrBlock: true EgressOnlyGateway: Type: AWS::EC2::EgressOnlyInternetGateway Properties: VpcId: !Ref Vpc PrivateSubnetA: Type: AWS::EC2::Subnet DependsOn: VpcIpv6Block Properties: VpcId: !Ref Vpc AvailabilityZone: !Select [0, !GetAZs ''] CidrBlock: 10.20.1.0/24 Ipv6CidrBlock: !Select [0, !Cidr [!Select [0, !GetAtt Vpc.Ipv6CidrBlocks], 2, 64]] PrivateSubnetB: Type: AWS::EC2::Subnet DependsOn: VpcIpv6Block Properties: VpcId: !Ref Vpc AvailabilityZone: !Select [1, !GetAZs ''] CidrBlock: 10.20.2.0/24 Ipv6CidrBlock: !Select [1, !Cidr [!Select [0, !GetAtt Vpc.Ipv6CidrBlocks], 2, 64]] PrivateRouteTable: Type: AWS::EC2::RouteTable Properties: VpcId: !Ref Vpc Ipv6DefaultRoute: Type: AWS::EC2::Route Properties: RouteTableId: !Ref PrivateRouteTable DestinationIpv6CidrBlock: '::/0' EgressOnlyInternetGatewayId: !Ref EgressOnlyGateway SubnetARoutes: Type: AWS::EC2::SubnetRouteTableAssociation Properties: SubnetId: !Ref PrivateSubnetA RouteTableId: !Ref PrivateRouteTable SubnetBRoutes: Type: AWS::EC2::SubnetRouteTableAssociation Properties: SubnetId: !Ref PrivateSubnetB RouteTableId: !Ref PrivateRouteTable # Gateway endpoints: no hourly price, no fee per GB S3Endpoint: Type: AWS::EC2::VPCEndpoint Properties: VpcId: !Ref Vpc VpcEndpointType: Gateway ServiceName: !Sub com.amazonaws.${AWS::Region}.s3 RouteTableIds: - !Ref PrivateRouteTable DynamoDbEndpoint: Type: AWS::EC2::VPCEndpoint Properties: VpcId: !Ref Vpc VpcEndpointType: Gateway ServiceName: !Sub com.amazonaws.${AWS::Region}.dynamodb RouteTableIds: - !Ref PrivateRouteTable LambdaSecurityGroup: Type: AWS::EC2::SecurityGroup Properties: GroupDescription: Outbound HTTPS only for the catalog functions VpcId: !Ref Vpc SecurityGroupEgress: - IpProtocol: tcp FromPort: 443 ToPort: 443 CidrIp: 0.0.0.0/0 - IpProtocol: tcp FromPort: 443 ToPort: 443 CidrIpv6: '::/0' CatalogTable: Type: AWS::DynamoDB::Table Properties: BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: isbn AttributeType: S KeySchema: - AttributeName: isbn KeyType: HASH The key line is ipv6AllowedForDualStack: true. Without it, the function only uses IPv4 in dual-stack subnets, and every call to the internet goes nowhere. The security group only allows outbound HTTPS, with separate rules for IPv4 and IPv6. Through the IPv4 rule, the function reaches DynamoDB and S3 via the gateway endpoints.
3. Make Node.js try IPv6 first
If a host returns both IPv4 and IPv6 addresses, the order of the DNS answer decides which one Node.js tries first. In our VPC, the IPv4 attempt leads nowhere. A small module that is loaded first reverses the order:
// src/ipv6-first.ts // Must be loaded as the first module: without a NAT gateway, the only // way to the internet is IPv6. If a host has both address types, // Node.js should therefore try the IPv6 address first. import { setDefaultResultOrder } from 'node:dns'; setDefaultResultOrder('ipv6first'); 4. Validate the feed and write the handler
The feed validation lives in its own file so you can test it without AWS. Incomplete entries are silently dropped, while a broken feed raises an error.
// src/feed.ts export interface Book { isbn: string; title: string; publicationYear: number; } const ISBN_13 = /^97[89]\d{10}$/; // Validates the new-releases feed and drops incomplete entries. export function parseFeed(data: unknown): Book[] { if (!Array.isArray(data)) { throw new Error('Feed is not a list'); } const books: Book[] = []; for (const entry of data) { if (typeof entry !== 'object' || entry === null) continue; const { isbn, title, publicationYear } = entry as Record<string, unknown>; const digitsOnly = typeof isbn === 'string' ? isbn.replace(/-/g, '') : ''; if (!ISBN_13.test(digitsOnly)) continue; if (typeof title !== 'string' || title.trim() === '') continue; if (typeof publicationYear !== 'number' || !Number.isInteger(publicationYear)) continue; books.push({ isbn: digitsOnly, title: title.trim(), publicationYear }); } return books; } // src/handler.ts import './ipv6-first.ts'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; import { parseFeed } from './feed.ts'; const tableName = process.env.TABLE_NAME ?? ''; const feedUrl = process.env.FEED_URL ?? ''; // The function reaches DynamoDB through the free gateway endpoint, // not through a NAT gateway. const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); export const handler = async (): Promise<{ imported: number }> => { // The feed server must be reachable over IPv6 (AAAA record in DNS). const response = await fetch(feedUrl, { signal: AbortSignal.timeout(8_000) }); if (!response.ok) { throw new Error(`Feed responded with HTTP ${response.status}`); } const books = parseFeed(await response.json()); for (const book of books) { await db.send(new PutCommand({ TableName: tableName, Item: book })); } console.log(JSON.stringify({ msg: 'Import finished', imported: books.length })); return { imported: books.length }; }; The tests run with the built-in Node.js test runner, without any extra library:
// src/feed.test.ts import { test } from 'node:test'; import assert from 'node:assert/strict'; import { parseFeed } from './feed.ts'; test('keeps valid books and removes hyphens from the ISBN', () => { const books = parseFeed([ { isbn: '978-3-16-148410-0', title: ' The City Library ', publicationYear: 2026 }, ]); assert.deepEqual(books, [ { isbn: '9783161484100', title: 'The City Library', publicationYear: 2026 }, ]); }); test('drops entries without a valid ISBN, title or year', () => { const books = parseFeed([ { isbn: '12345', title: 'Too short', publicationYear: 2026 }, { isbn: '9783161484100', title: '', publicationYear: 2026 }, { isbn: '9783161484100', title: 'No year' }, null, ]); assert.equal(books.length, 0); }); test('throws an error if the feed is not a list', () => { assert.throws(() => parseFeed({ books: [] }), /not a list/); }); 5. Test that every destination is reachable
Before you remove a NAT gateway from an existing application, you want to know which destinations are reachable over IPv6. That is what the second function in the project is for. It resolves each host, shows the address types and attempts an HTTPS call.
// src/netcheck.ts import './ipv6-first.ts'; import { lookup } from 'node:dns/promises'; interface Check { host: string; ipv4: string[]; ipv6: string[]; reachable: boolean; error?: string; } // Shows for each host which address types it has and whether an HTTPS call works. // Hosts without an IPv6 address are unreachable in a VPC without a NAT gateway. export const handler = async (event: { hosts?: string[] }): Promise<Check[]> => { const hosts = event.hosts ?? []; return Promise.all( Source:https://dev.to/sascha-ms/cut-nat-gateway-costs-cheaper-lambda-in-a-vpc-2nl7Related posts

PHP array_map() Function: Concepts and Practical Examples
My DSA Journey: Learning to Think Like a Programmer
SearchValues<T> in .NET 8/9 (.NET & C#)

