the-mgi/notes

Moving MongoDB off the NAT gateway

7 min readawsnetworkingmongodbcost
TL;DR

The NAT gateways were processing 437 GB a day. A little under half of that was MongoDB traffic taking the public route. Default flow logs will not tell you that. With pkt-srcaddr turned on, the biggest talker was a MongoDB IP address. A PrivateLink endpoint and wire compression took that path from $266 a month to $44.

At the end of April the NAT gateways were processing about 437 GB a day. That is $19.65 a day, or close to $600 a month, for one line on the bill.

A little under half of it was the MongoDB cluster.

The database is in Atlas. The pods are in private subnets. The connection string points at a public hostname. So every query goes out through the NAT gateway, and every reply comes back the same way. Nothing is broken. It just works, and it bills you on both legs.

Why NAT is expensive

A NAT gateway charges three ways:

  • $0.045 an hour per gateway, always on
  • $0.045 per GB processed, in both directions
  • normal internet egress on top

The middle one is the problem. It is 4.5 times what the same byte costs to cross an availability zone, and it applies on the way in as well as the way out. For traffic that never needed to leave AWS, that is money for nothing.

Flow logs will not tell you who

This is the part that cost me the most time, so it is worth saying plainly.

Turn on VPC flow logs with the default format and look at a NAT flow. The srcaddr field is the NAT gateway's own private address. The gateway already rewrote the packet. You learn that something went out to the internet. You already knew that.

The fields you need are pkt-srcaddr and pkt-dstaddr. Those are the real addresses, before translation. They are not in the default format. You have to ask for them:

terraform/modules/vpc/flow_logs.tf
resource "aws_flow_log" "vpc" {
  vpc_id                   = aws_vpc.base.id
  log_destination          = aws_s3_bucket.flow_logs.arn
  log_destination_type     = "s3"
  traffic_type             = "ALL"
  max_aggregation_interval = 60

  destination_options {
    file_format                = "parquet"
    per_hour_partition         = true
    hive_compatible_partitions = true
  }

  # pkt-srcaddr and pkt-dstaddr are the real peer, before NAT rewrites the header.
  # Without them a NAT flow has no name.
  log_format = "$${version} $${interface-id} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${protocol} $${packets} $${bytes} $${start} $${end} $${action} $${log-status} $${vpc-id} $${subnet-id} $${tcp-flags} $${type} $${pkt-srcaddr} $${pkt-dstaddr} $${region} $${az-id} $${flow-direction} $${traffic-path}"
}

Use Parquet with hourly partitions, and put a 30-day lifecycle rule on the bucket. Flow logs set to ALL get big fast. One 46-minute window here was 3.5 million rows.

The biggest talker

Once pkt-srcaddr is there, the question is just a GROUP BY. Add up inbound bytes by real source over an hour:

athena
SELECT pkt_srcaddr,
       SUM(bytes) / 1024.0 / 1024.0 AS mb
FROM   vpc_flow_logs
WHERE  flow_direction = 'ingress'
  AND  pkt_srcaddr NOT LIKE '10.%'
GROUP  BY pkt_srcaddr
ORDER  BY mb DESC
LIMIT  10;

The top row was a single public IP. It sent 699 MB in under five minutes. The ASN lookup came back:

FieldValue
ASN8011
OrgMongoDB Inc.

That was the database. Every result set was arriving over the public internet at $0.045 a GB.

The fix

Atlas can publish its cluster behind AWS PrivateLink. You create an interface endpoint in your own subnets and talk to it privately.

The road a query takes to the database

Before · over the NAT gateway

your AWS accountMongoDB's AWS accountpodapp subnet · privateNAT gatewaynat subnet · publicAtlas clusterpublic IPpublic internet$0.045 / GB · both ways

The cluster runs in MongoDB's own AWS account. Reaching it by its public address sends the traffic out of your VPC and across the internet to get there, and the gateway meters every byte in both directions.

After · over PrivateLink

your AWS accountMongoDB's AWS accountpodapp subnet · privateinterface endpointone ENI per zoneAtlas clusterendpoint serviceAWS PrivateLink$0.01 / GB processed

The same two accounts and the same cluster. PrivateLink carries the traffic straight between them, so it never reaches the internet, and the meter costs a fifth as much.

PrivateLink charges two ways, and they behave differently:

  • $0.01 an hour for each zone the endpoint sits in. Fixed. Three zones comes to about $21.90 a month, whether a single byte flows or not.
  • $0.01 a GB processed. Metered, against $0.045 a GB on NAT. So every GB moved across saves $0.035.

The fixed part is the catch. At low volume you pay $21.90 a month to save very little, and the endpoint is a worse deal than NAT. So the question is how much traffic it takes before the metered saving covers the fixed fee. Divide one by the other:

break-even
$21.90 a month  /  $0.035 saved per GB  =  626 GB a month  =  about 21 GB a day

Under roughly 21 GB a day the endpoint costs more than it saves. Over it, every extra GB is money back. The cluster was doing ten times that before breakfast, so there was nothing to think about.

Setting it up

Three resources, and the order matters. Atlas makes the endpoint service. You point an interface endpoint at it. Then you give the endpoint ID back to Atlas so it accepts the connection.

terraform/modules/atlas/privatelink.tf
# 1. Atlas side. Returns the service name you need below.
resource "mongodbatlas_privatelink_endpoint" "atlas" {
  project_id    = var.atlas_project_id
  provider_name = "AWS"
  region        = "US_EAST_1"
}

resource "aws_security_group" "atlas_endpoint" {
  name        = "atlas-privatelink"
  description = "Pods to the Atlas endpoint"
  vpc_id      = var.vpc_id

  # A replica set needs 27017. A sharded cluster needs 1024-65535. Check first,
  # because getting this wrong shows up as an intermittent timeout.
  ingress {
    from_port       = 27017
    to_port         = 27017
    protocol        = "tcp"
    security_groups = [var.node_security_group_id]
  }
}

# 2. AWS side. One interface per app subnet, so a pod can always reach a local one.
resource "aws_vpc_endpoint" "atlas" {
  vpc_id             = var.vpc_id
  service_name       = mongodbatlas_privatelink_endpoint.atlas.endpoint_service_name
  vpc_endpoint_type  = "Interface"
  subnet_ids         = var.app_subnet_ids
  security_group_ids = [aws_security_group.atlas_endpoint.id]

  # Atlas gives you its own hostname, so there is no AWS private DNS name to take.
  private_dns_enabled = false
}

# 3. Give the endpoint id back to Atlas.
resource "mongodbatlas_privatelink_endpoint_service" "atlas" {
  project_id          = mongodbatlas_privatelink_endpoint.atlas.project_id
  private_link_id     = mongodbatlas_privatelink_endpoint.atlas.private_link_id
  endpoint_service_id = aws_vpc_endpoint.atlas.id
  provider_name       = "AWS"
}

terraform apply will sit still for a few minutes on step 1 and again on step 3. That is normal.

If you would rather do it by hand first, it is the same three steps:

shell
# 1. Atlas: create the endpoint service, then wait for AVAILABLE
$ curl -s --digest -u "$ATLAS_PUBLIC_KEY:$ATLAS_PRIVATE_KEY" \
    -X POST "https://cloud.mongodb.com/api/atlas/v2/groups/$PROJECT_ID/privateEndpoint/AWS/endpointService" \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/vnd.atlas.2023-01-01+json' \
    -d '{"providerName":"AWS","region":"US_EAST_1"}' | jq -r '.id'

$ curl -s --digest -u "$ATLAS_PUBLIC_KEY:$ATLAS_PRIVATE_KEY" \
    -H 'Accept: application/vnd.atlas.2023-01-01+json' \
    "https://cloud.mongodb.com/api/atlas/v2/groups/$PROJECT_ID/privateEndpoint/AWS/endpointService/$SERVICE_ID" \
    | jq -r '.status, .endpointServiceName'
AVAILABLE
com.amazonaws.vpce.us-east-1.vpce-svc-0a1b2c3d4e5f67890

# 2. AWS: the interface endpoint
$ aws ec2 create-vpc-endpoint \
    --vpc-endpoint-type Interface \
    --vpc-id "$VPC_ID" \
    --service-name com.amazonaws.vpce.us-east-1.vpce-svc-0a1b2c3d4e5f67890 \
    --subnet-ids "$SUBNET_A" "$SUBNET_B" "$SUBNET_C" \
    --security-group-ids "$SG_ID" \
    --no-private-dns-enabled \
    --query 'VpcEndpoint.VpcEndpointId' --output text
vpce-0a1b2c3d4e5f67890

# 3. Atlas: hand the id back
$ curl -s --digest -u "$ATLAS_PUBLIC_KEY:$ATLAS_PRIVATE_KEY" \
    -X POST ".../privateEndpoint/AWS/endpointService/$SERVICE_ID/endpoint" \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/vnd.atlas.2023-01-01+json' \
    -d '{"id":"vpce-0a1b2c3d4e5f67890"}'

The last step is the connection string. Atlas gives the private route its own hostname. It is the same name with -pl-0 added to the cluster part, so the change is small and easy to miss in review:

connection string
# before — resolves to public IPs, goes out through the NAT gateway
mongodb+srv://appuser:PASSWORD@abc-cluster.9x7k2.mongodb.net/appdb?retryWrites=true&w=majority

# after — resolves to the endpoint interfaces inside the VPC
mongodb+srv://appuser:PASSWORD@abc-cluster-pl-0.9x7k2.mongodb.net/appdb?retryWrites=true&w=majority

Swap it in the secret, restart the pods, and check a flow log: pkt_srcaddr on those flows should now be a 10.x address instead of a public one.

What it did

The endpoint went in on 18 May. The applications moved on the 19th.

NAT gateway — GB processed per dayGB/day
0200400600PrivateLink28 Apr20 May14 Jun28 Apr — 445.3 GB/day29 Apr — 436.9 GB/day30 Apr — 433.7 GB/day1 May — 439.7 GB/day2 May — 440.2 GB/day3 May — 441.5 GB/day4 May — 419.4 GB/day5 May — 269.8 GB/day6 May — 312.7 GB/day7 May — 396.5 GB/day8 May — 387.4 GB/day9 May — 356.4 GB/day10 May — 381.7 GB/day11 May — 407.9 GB/day12 May — 409.5 GB/day13 May — 361.6 GB/day14 May — 394.6 GB/day15 May — 390.6 GB/day16 May — 371.5 GB/day17 May — 378.9 GB/day18 May — 355.1 GB/day19 May — 302.2 GB/day20 May — 147.7 GB/day21 May — 175.1 GB/day22 May — 183.4 GB/day23 May — 158.7 GB/day24 May — 176.7 GB/day25 May — 176.2 GB/day26 May — 192.4 GB/day27 May — 183.1 GB/day28 May — 167 GB/day29 May — 171.5 GB/day30 May — 174.6 GB/day31 May — 171.2 GB/day1 Jun — 179 GB/day2 Jun — 195.7 GB/day3 Jun — 174.2 GB/day4 Jun — 186.9 GB/day5 Jun — 196.4 GB/day6 Jun — 218.9 GB/day7 Jun — 225.8 GB/day8 Jun — 225.8 GB/day9 Jun — 217.7 GB/day10 Jun — 234.3 GB/day11 Jun — 208.1 GB/day12 Jun — 229.8 GB/day13 Jun — 225.1 GB/day14 Jun — 190.8 GB/day

Cost Explorer daily usage, 28 April – 14 June 2026. The endpoint went in on 18 May.

WindowNAT processedCost/day
5–18 May (before)369.6 GB/day$16.63
21–31 May (after)175.4 GB/day$7.89

That is 194 GB a day gone. Half the NAT processing, $8.74 a day, about $266 a month.

The bytes did not disappear

They moved to a different meter. First full month on the endpoint:

MeterVolumeCost/month
VpcEndpoint-Bytes210.4 GB/day$63.12
VpcEndpoint-Hours (3 AZs)2,160 hr$21.60
Total$84.72

The endpoint carries 210 GB a day. NAT lost 194 GB a day. That is close enough, and the gap is a month of normal growth between the two windows.

So the saving is not $266 a month. The endpoint costs $85 of its own. The real number is about $180 a month. Both figures are true. Only one of them is the saving.

Then the bytes got smaller

$0.01 a GB is cheap. It is not free, and the endpoint was still carrying 210 GB a day.

The MongoDB wire protocol can compress. It is off by default. Turning it on is one option:

lib/mongo.ts
// zstd needs @mongodb-js/zstd installed next to the driver. zlib is the fallback
// if the server does not offer zstd.
await mongoose.connect(uri, {compressors: ['zstd', 'zlib']});

You can also put ?compressors=zstd on the connection string and touch no code. It works in both directions and none of the queries change.

PrivateLink endpoint — GB processed per dayGB/day
0100200300zstd5 Aug16 Aug5 Sep5 Aug — 223.8 GB/day6 Aug — 225.9 GB/day7 Aug — 212 GB/day8 Aug — 205.1 GB/day9 Aug — 204.8 GB/day10 Aug — 211.3 GB/day11 Aug — 206.6 GB/day12 Aug — 194.4 GB/day13 Aug — 200 GB/day14 Aug — 196.4 GB/day15 Aug — 208.2 GB/day16 Aug — 104.9 GB/day17 Aug — 69 GB/day18 Aug — 68.2 GB/day19 Aug — 69 GB/day20 Aug — 65.4 GB/day21 Aug — 71.5 GB/day22 Aug — 68.8 GB/day23 Aug — 71.3 GB/day24 Aug — 74.4 GB/day25 Aug — 71.8 GB/day26 Aug — 85 GB/day27 Aug — 77.7 GB/day28 Aug — 68.5 GB/day29 Aug — 67 GB/day30 Aug — 68.9 GB/day31 Aug — 82.1 GB/day1 Sep — 73.9 GB/day2 Sep — 68.3 GB/day3 Sep — 73.3 GB/day4 Sep — 72.3 GB/day5 Sep — 69.3 GB/day

Same queries, same endpoint. Wire compression turned on 16 August 2026.

Compression went on 16 August. The endpoint dropped from 208 GB a day to 72 GB a day. That is 65% less traffic for exactly the same queries. BSON repeats every field name in every document, so it compresses very well.

The cost is CPU on both ends, and it barely registered. Pod CPU went up by one or two millicores, and on most pods the difference was too small to separate from normal noise. Worth watching on a heavier workload, but it did not show up as a problem here.

Three stages

The hourly charges belong in this picture too. Three NAT gateways cost about $100 a month before a single byte moves. The endpoint's three interfaces cost about $22 a month on the same terms.

Cost of the database path, per monthUSD/month
Hourly, just to existPer GB processed
NAT gatewayNAT gateway — Hourly, just to exist 100 USD/monthNAT gateway — Per GB processed 266 USD/month366PrivateLink endpointPrivateLink endpoint — Hourly, just to exist 22 USD/monthPrivateLink endpoint — Per GB processed 63 USD/month85PrivateLink + zstdPrivateLink + zstd — Hourly, just to exist 22 USD/monthPrivateLink + zstd — Per GB processed 22 USD/month44

Three NAT gateways at $0.045 an hour, against one endpoint with an interface in three zones at $0.01 an hour each. The metered part is the MongoDB traffic on each path.

The NAT gateways carry everything else, so their hourly charge stays on the bill either way. What moved is the rest of it: $266 a month of processing became $44 of endpoint, a saving of about $222 a month. If the database had been the only reason to run a NAT gateway, the $100 would have gone with it.

Per unit of data: a GB of query results used to cost $0.045 to receive. Now it costs $0.0069.

What this does not fix

The endpoint has one interface in each availability zone. That is correct, because a pod can always reach a local one. But nothing makes it prefer the local one. Private DNS is offPrivate DNS only works when the endpoint has an AWS service name to override, like s3.us-east-1.amazonaws.com. Atlas is a third-party service and hands out its own -pl-0 hostname in its own DNS zone, so there is nothing for AWS to override. The setting has to stay off, and with it goes any chance of the resolver preferring the local interface., so the driver sees all three addresses and picks one without caring where it is.

With three zones, roughly two thirds of queries leave the pod's zone. That lands on DataTransfer-Regional-Bytes at $0.01 a GB, charged on the way out and again on the way in.

Compression helped here too. Fewer bytes on the wire means fewer bytes crossing a zone, whichever meter picks them up.

What I would tell myself

Turn on pkt-srcaddr before you need it. Without it a NAT flow has no name.

External sources

Rates were current when this was written. AWS changes them, so check before you budget against them.