DEV Community

Cover image for Cut NAT gateway costs: cheaper Lambda in a VPC
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Cut NAT gateway costs: cheaper Lambda in a VPC

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Bar chart: USD 101.80 per month everything via NAT, 74.80 with gateway endpoints, 0 with IPv6 without NAT

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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
// 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 };
};
Enter fullscreen mode Exit fullscreen mode

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/);
});
Enter fullscreen mode Exit fullscreen mode

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 };
      }
    }),
  );
};
Enter fullscreen mode Exit fullscreen mode

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"]}'
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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

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)