A VP of Engineering reached out after his board flagged AWS as a line item during a cost review. The company was 40 engineers, B2B SaaS, growing steadily. Their AWS bill had crept from $18k/month a year ago to $31k/month — and nobody had a clear explanation for where the extra $13k was going.
The board's assumption was that growth drove the increase. Our assumption, going in, was that growth accounted for about half of it. We were right.
In six weeks, the bill was $17.6k/month — a 43% reduction. Here's exactly where the money was, what we did, and what we chose not to touch.
The audit: mapping $31k of monthly spend
Before you can optimize anything, you need a clear picture of where money is actually going. AWS Cost Explorer is the starting point but it lies by omission — it shows you what's expensive, not why. We spend the first few days going service by service and asking a different question for each one: does this resource's cost match what it's doing?
The breakdown we found:
- EC2 (including EKS nodes): $11,400/month — 37% of total
- RDS: $7,200/month — 23%
- Data transfer: $4,800/month — 15%
- S3 + CloudFront: $3,100/month — 10%
- ElastiCache, SQS, Secrets Manager, misc: $4,500/month — 15%
The first thing we noticed: 78% of the bill was in four services. That's where we focused.
EC2 and EKS: the right-sizing problem
The Kubernetes cluster was running on m5.2xlarge nodes — 8 vCPU, 32 GiB RAM. There were 9 of them in the node group. Average CPU utilization across all nodes: 14%. Average memory utilization: 31%.
The nodes had been provisioned 18 months earlier during a period of rapid growth where the team was conservative about capacity. Nobody had revisited them. They were paying for 72 vCPUs and 288 GiB of RAM and using roughly 10 vCPUs and 89 GiB.
The fix wasn't "buy smaller instances" — it was "match instance type to actual workload profile." The application was memory-bound, not CPU-bound. We moved from m5.2xlarge to r6i.xlarge (4 vCPU, 32 GiB) and scaled the node group from 9 to 7 nodes. Same available memory. Half the CPU (which they weren't using). Node group cost dropped from $7,200/month to $3,800/month.
The second move was Reserved Instances. The cluster needed at minimum 5 nodes to run the production workload comfortably. We purchased 5 one-year Reserved Instances for the r6i.xlarge at an effective rate 38% below On-Demand, and kept 2 On-Demand nodes for burst headroom.
Total EC2/EKS savings: $5,600/month.
RDS: the database nobody had reviewed in a year
The production database was a db.r6g.2xlarge — 8 vCPU, 64 GiB RAM — running PostgreSQL 14 with Multi-AZ enabled. The read replica they'd provisioned for reporting workloads was a db.r6g.xlarge, never used by anything in production because the reporting queries had been moved to a data warehouse six months ago. The replica was still running.
Deleting the unused read replica was $680/month back instantly — a single command and about 10 minutes of verification.
The primary instance was harder to evaluate. The db.r6g.2xlarge was running at ~20% CPU and 35% memory during peak. The previous DevOps engineer had sized it conservatively after a database incident the year before. We respected that context — the incident had been real — but used CloudWatch metrics to model what downsizing to a db.r6g.xlarge would look like. At the 95th percentile CPU during their busiest month (end-of-quarter billing runs), the larger instance was at 41%. A db.r6g.xlarge would peak around 70% — acceptable headroom.
We did the resize during a Sunday maintenance window. The instance was down for 8 minutes. No incidents since.
We also added a Reserved Instance commitment on the primary: one year, all upfront, saving 42% over On-Demand.
Total RDS savings: $2,900/month.
Data transfer: the hidden tax
$4,800/month on data transfer is the kind of line item that looks like a rounding error against EC2 until you actually look at what's causing it.
The breakdown:
- Cross-AZ traffic: $1,900/month — EKS pods in one AZ making calls to RDS in another, repeatedly
- NAT Gateway: $1,600/month — application pulling public S3 data through a NAT Gateway instead of a VPC endpoint
- Inter-region: $800/month — CloudWatch logs being shipped to a secondary region for a compliance requirement that no longer existed
- Misc egress: $500/month — external API calls from application pods with no caching
Cross-AZ traffic is sneaky. AWS charges $0.01/GB in each direction for cross-AZ traffic between instances in the same VPC. At high volume, that adds up. The fix was configuring Kubernetes topology-aware routing so pods preferred endpoints in the same AZ as the caller. Not a perfect solution — it requires sufficient node count per AZ — but it reduced cross-AZ database calls by about 60%, cutting that line item to $760/month.
The NAT Gateway charge was a classic oversight. The application was pulling model artifacts from S3 every time a batch job started — fetching through the NAT Gateway because nobody had created a VPC endpoint for S3. Creating the S3 VPC endpoint costs nothing. It eliminated the NAT Gateway data processing charge on that traffic entirely.
The inter-region CloudWatch log shipping was shipping to a region for a "regulatory requirement" that had been superseded 8 months earlier by a policy change. One Slack message to the legal team confirmed it was safe to remove. $800/month, gone.
Total data transfer savings: $3,040/month.
S3 and CloudFront: lifecycle policies that didn't exist
S3 storage itself is cheap. The problem is objects accumulating in the wrong storage class because nobody set lifecycle policies. This company had 14 TB in S3 Standard that had not been accessed in over 90 days. At $0.023/GB, that's $322/month for cold data in a hot storage class.
We added lifecycle rules to transition objects not accessed in 30 days to S3 Intelligent-Tiering, and objects not accessed in 90 days to S3 Glacier Instant Retrieval. For data they were confident would never be hot again (old application logs, raw event streams older than a year), we moved directly to S3 Glacier Deep Archive at $0.00099/GB.
CloudFront cache hit rates were 67% — reasonable but not great. We tuned the cache behaviors for static assets (longer TTLs for versioned assets, proper Cache-Control headers from the origin) and got hit rates to 84%. That reduced origin data transfer, which fed back into the data transfer savings above.
Total S3/CloudFront savings: $680/month.
What we didn't touch — and why
Cost optimization conversations often push toward cutting things that shouldn't be cut. We chose not to:
- Reduce Multi-AZ on RDS — the $680/month savings wasn't worth the single-AZ risk. Their RTO on a database failure was 4 hours; Multi-AZ failover is typically 60–120 seconds. That's worth $680/month for a B2B SaaS where downtime directly affects customer SLAs.
- Move worker nodes to Spot — the team didn't have experience operating with Spot interruptions. Spot can save 60–70% on compute, but it requires infrastructure that handles unexpected node termination gracefully. Without that groundwork, Spot-induced incidents would cost more than the savings. We put it on the roadmap for Q3.
- Compress the EKS node autoscaler minimum — the minimum node count was set to prevent cold-start latency for bursty batch jobs. Lowering it would have saved $400/month and caused latency spikes during job queue buildup. Not worth it.
Good cost optimization is not "cut everything possible." It's "cut what doesn't correspond to value, and leave alone what does."
The results
- Total monthly spend: $31,000 → $17,600 (43% reduction)
- Annual savings: $160,800
- Time to first savings: 3 days (deleted unused RDS replica, removed cross-region log shipping)
- Time to full optimization: 6 weeks
- Production incidents caused by changes: 0
"My board asked what we were doing about cloud costs. A week later I had a detailed breakdown of exactly where every dollar was going and a plan to cut it by 40%. That conversation went very differently than the first one."
Where to start if you haven't done this yet
If you haven't done a cost audit in the last 6 months, the most likely overpayments are:
- Unused or oversized RDS instances — especially read replicas. Run
SELECT * FROM CloudWatch metricsagainst every instance and compare provisioned vs. used. - EC2 instances running at under 20% CPU for 30+ days — Cost Explorer has a "right-sizing recommendations" tab that does this automatically. It's conservative in its suggestions; you can usually go further.
- NAT Gateway data processing charges — if you're paying more than $200/month on NAT Gateways, check whether S3, DynamoDB, or other AWS services you access frequently have VPC endpoints configured.
- No Savings Plans or Reserved Instances on stable workloads — if your baseline compute has been stable for 6 months, you're paying On-Demand rates for Reserved Instance workloads. One-year, no-upfront Reserved Instances save 30–40% with no commitment risk.
- S3 objects in Standard that should be in Intelligent-Tiering — every S3 bucket older than 6 months probably has cold data in a hot storage class.
The median AWS account we audit has 30–45% recoverable waste. It's not negligence — it's the natural result of building fast and never going back to optimize. The optimization work is always worth doing; the question is when.