How We Cut Our AWS Bill by 60% Without Losing a Single Feature
Our AWS bill hit $42,000 in a single month before anyone on the engineering team actually looked at it line by line. We were a 20-person startup at the time, growing, sure, but not growing at a rate that explained a bill that had roughly tripled in eight months. Finance flagged it, the CTO forwarded it to me with a one-line "can you take a look," and I spent the next six weeks turning that look into a full audit. We got the bill down to about $17,000 a month without cutting a single feature or losing any meaningful performance. Here's what actually moved the needle, roughly in the order I tackled it, because the order matters — some fixes only make sense once earlier ones are done.
Step one: turn on Cost Explorer and actually read it
This sounds too obvious to write down, but almost nobody does it. I went into Cost Explorer, grouped by service, then by linked account, then by tag, and just looked. The single biggest line item, at nearly $9,000/month, was EC2 — not surprising for a company running its own Kubernetes cluster. The second was data transfer, at around $6,500/month, which was surprising, because nobody had budgeted for that at all.
aws ce get-cost-and-usage \
--time-period Start=2025-01-01,End=2025-02-01 \
--granularity MONTHLY \
--metrics "UnblendedCost" \
--group-by Type=DIMENSION,Key=SERVICE
That data transfer number is what led me to the NAT gateway discovery, which turned out to be the single biggest fix in the whole project.
The NAT gateway tax nobody had questioned
We had three NAT gateways, one per AZ, which is the textbook "highly available" setup AWS documentation recommends. Each NAT gateway costs about $0.045/hour just to exist (roughly $32/month) plus $0.045 per GB processed. That per-GB charge is the killer. Every container in our private subnets pulling a Docker image, every service calling out to a third-party API, every log shipped to an external provider — all of it was routing through NAT gateways and getting charged per gigabyte, both ways in some cases.
We were pushing about 40TB/month through those gateways. At $0.045/GB that's $1,800/month just in NAT processing charges, on top of the base hourly cost. The fix had two parts. First, we added VPC endpoints for the AWS services we talked to constantly — S3, ECR, DynamoDB, CloudWatch Logs — so that traffic to those services stayed on AWS's internal network instead of routing out through NAT:
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0abc123 \
--service-name com.amazonaws.us-east-1.s3 \
--route-table-ids rtb-0def456 \
--vpc-endpoint-type Gateway
Gateway endpoints for S3 and DynamoDB are free — no hourly charge, no per-GB charge. That alone cut our NAT traffic by more than half, since a huge chunk of it was ECR image pulls and S3 access from inside the cluster. Second, for the true third-party traffic we couldn't route internally, we consolidated to a single NAT gateway with a well-monitored budget alert instead of three, accepting slightly reduced AZ redundancy on egress specifically — our services still failed over fine, they just briefly shared a NAT path during an AZ event, which was a trade-off worth making for roughly $1,100/month saved on base hourly cost alone.
Rightsizing: we were paying for capacity nobody used
CloudWatch had months of data just sitting there unused. I pulled average and p95 CPU and memory utilization per instance type across our EC2 fleet and found a pattern I now expect to find almost everywhere: half our m5.2xlarge nodes were running at 12-18% average CPU. Someone had picked that instance size during an early scaling scare and nobody had revisited it since.
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 \
--metric-name CPUUtilization \
--dimensions Name=InstanceId,Value=i-0123456789abcdef0 \
--start-time 2025-01-01T00:00:00Z \
--end-time 2025-02-01T00:00:00Z \
--period 3600 \
--statistics Average,Maximum
We moved the bulk of the fleet from m5.2xlarge to m5.xlarge, and moved a couple of genuinely bursty workloads to m5.large with cluster autoscaler picking up slack during traffic spikes instead of running oversized instances 24/7 "just in case." That single change cut EC2 compute spend by about $2,400/month with no measurable latency regression — we watched p99 response times closely for two weeks after the change specifically because I didn't trust it either.
Reserved capacity: only after rightsizing, never before
I want to be specific about ordering here because I've seen teams get this backwards and it costs them real money. Buying Reserved Instances or a Compute Savings Plan locks in a discount against a committed level of usage — typically 30-40% off on-demand pricing for a 1-year commitment with no upfront payment. If you buy that commitment before rightsizing, you've just locked in a discount on infrastructure you're about to shrink, and now you're stuck paying for capacity you don't need at a rate you can't easily change for a year.
We rightsized first, let usage settle for three weeks to get a stable baseline, then bought a Compute Savings Plan sized to our new steady-state, covering about 70% of baseline usage and leaving the bursty 30% on-demand. That saved another $1,900/month with essentially zero operational risk, since Savings Plans apply automatically to matching usage — no instance-type lock-in like classic RIs.
S3: lifecycle policies and the graveyard buckets
We found buckets nobody remembered creating, holding terabytes of old build artifacts, CI cache, and log exports going back over a year, all sitting in S3 Standard. A simple lifecycle policy fixed most of it:
{
"Rules": [
{
"ID": "archive-old-logs",
"Status": "Enabled",
"Filter": { "Prefix": "logs/" },
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER_IR" }
],
"Expiration": { "Days": 365 }
}
]
}
We applied similar rules to CI artifacts with a much more aggressive 14-day expiration, since nobody was pulling artifacts older than a build's own retry window anyway. S3 costs dropped by about $900/month, and honestly the bigger win was psychological — it turned "storage is basically free, don't worry about it" into "storage has a lifecycle by default," which changed how the team wrote new pipelines going forward.
Killing idle resources: the unglamorous sweep
The last pass was the least interesting and second-most profitable: unattached EBS volumes from terminated instances ($340/month), an idle RDS read replica someone spun up for a migration that finished six months earlier ($280/month), four Elastic IPs not attached to anything ($15/month, small but free to fix), and a load balancer left running for a decommissioned staging environment ($20/month plus its data processing charges). None of these were individually dramatic. Together they were about $700/month, and finding them just required going through every resource type in every region and asking "is this attached to something that's actually running."
What it added up to
Roughly $2,900/month from NAT gateway consolidation and VPC endpoints, $2,400/month from EC2 rightsizing, $1,900/month from the Savings Plan, $900/month from S3 lifecycle policies, and $700/month from idle resource cleanup — about $8,800/month from those five categories alone. The remaining gap to our final $17k number came from smaller cleanups across CloudWatch log retention, over-provisioned RDS instances, and a genuinely wasteful CloudFront configuration that wasn't caching anything because of a misconfigured Cache-Control header on our origin.
The part that stuck with me afterward wasn't any individual fix — it was that none of this required exotic tooling or a big platform rewrite. It required someone actually reading the bill, in order, month over month, and asking "why" about every line that looked bigger than it should. We now do that review monthly as a standing fifteen-minute habit instead of a six-week fire drill, and the bill hasn't crept back up since.
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.