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(
hosts.map(async (host): Promise<Check> => {
const addresses = await lookup(host, { all: true }).catch(() => []);
const ipv4 = addresses.filter((a) => a.family === 4).map((a) => a.address);
const ipv6 = addresses.filter((a) => a.family === 6).map((a) => a.address);
try {
await fetch(`https://${host}/`, { method: 'HEAD', signal: AbortSignal.timeout(4_000) });
return { host, ipv4, ipv6, reachable: true };
} catch (err) {
const error = err instanceof Error ? (err.cause as Error)?.message ?? err.message : String(err);
return { host, ipv4, ipv6, reachable: false, error };
}
}),
);
};
6. Deploy and verify
serverless deploy --param="feedUrl=https://feed.example.org/new-releases.json"
# Which destinations are reachable without a NAT gateway?
serverless invoke -f netcheck --data '{"hosts":["feed.example.org","dynamodb.eu-central-1.amazonaws.com"]}'
Expected result: hosts with entries under ipv6 report "reachable": true. S3 and DynamoDB are the exception: here they only have IPv4 addresses and are still reachable, because the gateway endpoint catches their traffic. All other hosts that only have IPv4 addresses end with an error such as connect ETIMEDOUT or an abort after four seconds. Exactly these hosts make up your list of destinations that would stop working without a NAT gateway. After that, invoke catalogSync once with serverless invoke -f catalogSync and check the log for the number of imported books.
7. Calculate your own NAT gateway costs
For your own numbers, the project includes a small script. Enter the prices for your region, then run it with the number of AZs and gigabytes per month. If you also want to keep an eye on the Lambda side of the bill, the Lambda cost calculator helps.
// scripts/nat-costs.ts
// Rough monthly estimate: NAT gateway vs. VPC endpoints.
// Prices are example values (USD, US East); enter the values for your region.
export interface Prices {
natPerHour: number;
natPerGb: number;
endpointPerHourAndAz: number;
endpointPerGb: number;
ipv4PerHour: number;
}
export const examplePrices: Prices = {
natPerHour: 0.045,
natPerGb: 0.045,
endpointPerHourAndAz: 0.01,
endpointPerGb: 0.01,
ipv4PerHour: 0.005,
};
export const HOURS_PER_MONTH = 730;
const round = (value: number): number => Math.round(value * 100) / 100;
// One zonal NAT gateway per AZ, each with one public IPv4 address.
export function natMonthly(azs: number, gb: number, p: Prices = examplePrices): number {
const base = azs * (p.natPerHour + p.ipv4PerHour) * HOURS_PER_MONTH;
return round(base + gb * p.natPerGb);
}
// One interface endpoint (e.g. for SQS) with one network interface per AZ.
export function interfaceEndpointMonthly(azs: number, gb: number, p: Prices = examplePrices): number {
return round(azs * p.endpointPerHourAndAz * HOURS_PER_MONTH + gb * p.endpointPerGb);
}
// From this monthly data volume on, an interface endpoint is cheaper
// than sending the same traffic through a NAT gateway you keep anyway.
export function breakEvenGb(azs: number, p: Prices = examplePrices): number {
const fixedCost = azs * p.endpointPerHourAndAz * HOURS_PER_MONTH;
return Math.round(fixedCost / (p.natPerGb - p.endpointPerGb));
}
if (import.meta.main) {
const azs = Number(process.argv[2] ?? 2);
const gb = Number(process.argv[3] ?? 600);
console.log(`NAT gateway (${azs} AZs, ${gb} GB): ${natMonthly(azs, gb)} USD/month`);
console.log(`Interface endpoint (${gb} GB): ${interfaceEndpointMonthly(azs, gb)} USD/month`);
console.log(`Break-even interface endpoint: ${breakEvenGb(azs)} GB/month`);
console.log('Gateway endpoint (S3, DynamoDB): 0 USD/month');
}
node scripts/nat-costs.ts 2 600
# NAT gateway (2 AZs, 600 GB): 100 USD/month
# Interface endpoint (600 GB): 20.6 USD/month
# Break-even interface endpoint: 417 GB/month
# Gateway endpoint (S3, DynamoDB): 0 USD/month
A note on security and costs: the egress-only internet gateway blocks inbound connections just as reliably as a NAT gateway. Control over where your function may connect still lies with the security group and network ACL. And you pay for data transfer to the internet in every variant; only the NAT fees go away.
Common mistakes when cutting NAT gateway costs
Endpoint created, but wrong route table. A gateway endpoint only works for subnets whose route table is associated with it. If your Lambda subnets have their own table, exactly that one must be listed in RouteTableIds. You can verify this in the route table: you will see a route with a prefix list pl-… as its destination.
Interface endpoint without private DNS. If “Private DNS” is turned off, the SDK keeps resolving the service's public name, and traffic flows through the NAT gateway just like before. You only notice this in the metrics, not in the code.
IPv6 enabled, but the destination has no IPv6. This is the most common reason for sudden timeouts after the change. Test every host with the netcheck function beforehand. This also applies to Lambda extensions from monitoring vendors that open their own connections.
A single NAT gateway for all AZs. This saves base fees but costs resilience: if the AZ hosting the gateway fails, functions in the other AZs lose internet access as well. In addition, AWS charges extra for traffic between AZs. That's fine for development environments, but I wouldn't do it in production.
Function placed in a public subnet. A Lambda function doesn't get a public IPv4 address there and therefore no internet access either. The AWS documentation says so explicitly, and it is still a classic.
FAQ
Why does traffic to S3 through a NAT gateway cost money?
The NAT gateway charges for every gigabyte it processes, no matter where it goes. Without a gateway endpoint, the path to S3 takes the detour through the gateway. With an endpoint, this fee disappears completely for S3 and DynamoDB in the same region.
Do my Lambda function's CloudWatch logs go through the NAT gateway?
No. The Lambda service itself hands logs from standard output to CloudWatch Logs; your code doesn't send them over the VPC network. It is different when your code or an extension sends data to external services on its own.
Is an egress-only internet gateway really free?
The gateway itself is, with neither an hourly nor a processing fee. You still pay regular data transfer to the internet, just as with a NAT gateway.
Does a regional NAT gateway save money?
Hardly. The hourly fee applies to every AZ in which the gateway is active, and the per-gigabyte fee stays the same. The advantage is operational: no public subnet, one route for all AZs and automatic expansion.
Since November 2026 there is another way out of the VPC: ElastiCache Serverless with a public endpoint. How to build a vector search from Lambda without a NAT gateway is something I show in ElastiCache vector search: hybrid search from Lambda.
Your next step
Open Cost Explorer today and check how much of your bill NatGateway-Bytes accounts for. If it is noticeable, start by creating the two gateway endpoints. It takes ten minutes, costs nothing and doesn't change how your functions behave. Only after that is it worth looking at IPv6.
I'd like to hear about your experience: what percentage of your AWS bill goes to NAT gateways, and have you ever removed one without replacing it?
Another cost item that is easy to miss: log groups that never expire. I show how to set a CloudWatch Logs retention policy for the whole account in a separate article.
Sources
- AWS: Amazon VPC pricing (NAT gateway, regional NAT gateway, public IPv4 addresses)
- AWS: AWS PrivateLink pricing (interface endpoints)
- AWS: Internet access for Lambda functions in a VPC (including IPv6)
- AWS: Gateway endpoints for S3 and DynamoDB
- AWS: Egress-only internet gateways
- AWS: Regional NAT gateways
- Serverless Framework: Functions (VPC configuration, ipv6AllowedForDualStack)
- Node.js: dns.setDefaultResultOrder
This post was created with AI assistance.
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)