AWS Bill Too High? 7 Fixes With Copy-Paste Terraform
AWS bill climbing? Spot, Karpenter, Graviton and NAT fixes with copy-paste Terraform. Or get a fixed-price $250 audit with results in 48 hours.
On this page
- TL;DR
- Why Your AWS Bill Is Too High
- Fix 1: Move Stateless Workloads to Spot
- Fix 2: Replace the Cluster Autoscaler with Karpenter
- Fix 3: Migrate to Graviton
- Fix 4: Route Internal Traffic Around the NAT Gateway
- Fix 5: Shut Down Idle Non-Prod Outside Working Hours
- Fix 6: Rightsize Instances Sitting at Low CPU
- Fix 7: Clean Up Orphaned EBS Volumes and Snapshots
- Keep It From Coming Back: Shift Cost Checks Left
- The Operational Reality (What Breaks)
- The Payoff
- Frequently Asked Questions
TL;DR
If your AWS bill keeps climbing while traffic doesn't, the waste is almost always architectural, not financial. These are the seven fixes I work through on every account, roughly in order of payoff:
- Spot Instances for stateless and batch work.
- Karpenter instead of the Cluster Autoscaler.
- Graviton (ARM) with multi-arch images.
- NAT Gateway → VPC Endpoints for traffic to S3, ECR and DynamoDB.
- Idle non-prod shutdown outside working hours.
- Rightsizing instances that sit at low average CPU.
- Orphaned EBS volumes and snapshots cleanup.
How much these save depends entirely on where your spend sits, so measure first: Cost Explorer grouped by service and usage type shows which of the seven matter for you.
Want someone to do this on your account? Book a free 15-minute AWS bill review and I'll tell you the top 3 fixes on the call. If you want it in writing, the AWS Cost Audit is $250 fixed: written findings plus Terraform patches, delivered in 48 hours.
Dev and staging billed around the clock for a 40-hour work week.
Processing charges on traffic that never needed to leave AWS.
Instances sized for a peak that rarely comes.
Unattached volumes and old snapshots nobody owns.
How big is it on your bill? The free AWS cost waste calculator estimates it in about a minute.
moarkid.com — AWS Cost Optimization
Why Your AWS Bill Is Too High
Look at your AWS dashboard right now: what is your average CPU utilization across EC2 or EKS? If it is low, you are paying for capacity you do not use.
Why? Because engineers are afraid of downtime. To survive a traffic spike, they over-provision: m5.4xlarge when an m5.xlarge would do, minReplicas: 10 when 2 is enough. Dev and staging run 24/7 for a team that works 40 hours a week. Private subnets pay NAT Gateway fees on traffic that never leaves AWS.
The usual response is a finance one: a mandate to "tag everything" and a spreadsheet review at the end of the month. Tagging is necessary for cost allocation, but it is purely reactive. By the time someone groups the tags and notices a team burned thousands on idle instances, the money is gone. Cloud cost is an engineering problem, and the fixes below are engineering fixes.
If you want a number before reading further, drop your figures into the AWS cost waste calculator. It estimates what rightsizing, Spot and NAT fixes would save on your current bill.
Before
Mostly idle nodes. On-Demand, fixed replicas, sized for the worst day.
After
Fuller, cheaper nodes. Just-in-time nodes, Spot capacity, ARM price-performance.
Ceilings published by AWS. What you actually save depends on your workload.
moarkid.com — AWS Cost Optimization
Fix 1: Move Stateless Workloads to Spot
Most companies run 100% of their workloads On-Demand. That is the retail price.
AWS Spot Instances sell spare capacity at up to 90% off On-Demand. The catch: AWS can reclaim the instance with a 2-minute warning.
The engineering fix: don't put your production database on Spot. But stateless microservices, background workers (Sidekiq/Celery), CI runners and batch jobs belong there. Route stateful workloads to On-Demand nodes and stateless ones to Spot, and diversify across many instance types and AZs so a single reclamation wave doesn't take out a whole service.
For the committed-spend side (Savings Plans vs Reserved Instances vs Spot, and how to layer them), see the AWS Compute Cost Playbook.
Fix 2: Replace the Cluster Autoscaler with Karpenter
The Kubernetes Cluster Autoscaler scales Auto Scaling Groups. If you need one more small pod, it adds another copy of whatever instance type the ASG was built with, even if you only need a fraction of it.
Karpenter is an open-source node provisioner. Instead of static ASGs, it watches unschedulable pods, works out the CPU and memory they need, and launches a fitting instance type directly through the EC2 API. The scheduling decision is fast (seconds); the EC2 instance still needs its normal launch and boot time before pods land on it. The win is not raw speed, it is fit: Karpenter picks from dozens of instance types and consolidates underused nodes, so you stop paying for half-empty machines.
Here is a Karpenter v1 NodePool that prefers Spot, allows both architectures, and consolidates underused nodes, with the EC2NodeClass it references:
karpenter-nodepool.yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: kubernetes.io/arch
operator: In
values: ["arm64", "amd64"]
- key: kubernetes.io/os
operator: In
values: ["linux"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c", "m", "r"]
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["5"]
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
limits:
cpu: 1000
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 1m
---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: default
spec:
role: "KarpenterNodeRole-${CLUSTER_NAME}" # replace with your cluster name
amiSelectorTerms:
- alias: al2023@latest # Amazon Linux 2023
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: "${CLUSTER_NAME}"
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: "${CLUSTER_NAME}"When both spot and on-demand are allowed, Karpenter prefers Spot and falls back to On-Demand when Spot capacity isn't available. Pin workloads that must never run on Spot with a nodeSelector on karpenter.sh/capacity-type: on-demand.
Fix 3: Migrate to Graviton
If everything runs on x86, you are leaving money on the table. AWS Graviton (ARM) instances are priced lower per hour than their x86 equivalents: AWS says they cost up to 20% less than comparable x86 instances. Go, Python, Node.js and Java all run on ARM, so for most services the migration is a build change: produce a multi-architecture image and let the scheduler place it on Graviton nodes.
Dockerfile
# Official Node images are published for both amd64 and arm64,
# so the same Dockerfile builds for x86 and Graviton.
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]The Dockerfile alone doesn't make the image multi-arch. The build does:
build-multi-arch.sh
# One-time: create a builder that can target multiple platforms
docker buildx create --name multiarch --use
# Build both architectures and push a single multi-arch manifest to ECR
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t 123456789012.dkr.ecr.us-east-1.amazonaws.com/api:1.4.0 \
--push .With the NodePool above allowing arm64, Karpenter can now place those pods on Graviton nodes whenever they are the cheaper fit.
Fix 4: Route Internal Traffic Around the NAT Gateway
The NAT Gateway is the classic architectural cost trap. AWS charges about $0.045 per GB processed through it, on top of the hourly fee. If private EC2 instances or EKS pods pull images from ECR or read data from S3, that traffic goes through the NAT Gateway and you pay a premium for traffic that never leaves AWS.
The engineering fix: send that traffic through VPC Endpoints instead. Gateway endpoints for S3 and DynamoDB have no hourly or per-GB charge:
vpc-endpoints.tf
# S3 traffic from private subnets bypasses the NAT Gateway
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = aws_route_table.private[*].id
}ECR, CloudWatch Logs, STS and other services need Interface Endpoints, which have their own (much smaller) hourly and per-GB fees. The full walkthrough, including when Interface Endpoints are not worth it, is in Eliminate AWS NAT Gateway Overcharges, and the Terraform module is on GitHub: aws-vpc-endpoints-terraform.
Fix 5: Shut Down Idle Non-Prod Outside Working Hours
Your engineers work about 40 of the 168 hours in a week. Dev, staging and QA environments that run 24/7 are idle roughly three quarters of the time.
The engineering fix: a scheduled Lambda (triggered by EventBridge) that stops tagged EC2 instances, scales ECS services to zero and stops RDS instances in the evening and on weekends, then starts them before the team arrives. Resources opt in with tags, so anything that must stay up overnight simply isn't tagged.
The full Terraform and Python are in The Idle EC2 Reaper and the aws-ec2-reaper repository.
Fix 6: Rightsize Instances Sitting at Low CPU
Spot, Karpenter and Graviton change how you buy compute. Rightsizing changes how much you buy. An instance averaging 20% CPU with no memory pressure is usually one size too big, and each size down within an instance family roughly halves the price.
The engineering fix:
- Pull at least two weeks of CPU and memory metrics (CloudWatch, or AWS Compute Optimizer, which does this analysis for you once enabled).
- Resize toward a healthy target, around 60% average CPU, and leave headroom for spikes via autoscaling rather than via permanently oversized machines.
- In Kubernetes, fix the pod
requestsfirst. Karpenter sizes nodes from requests, so inflated requests produce inflated nodes.
To see which resources actually drive your bill before you start, parse your Cost & Usage Report with Python and sort by cost.
Fix 7: Clean Up Orphaned EBS Volumes and Snapshots
Even with good architecture, waste accumulates. Unattached EBS volumes, old snapshots and unassociated Elastic IPs keep billing long after the instance they belonged to is gone.
Find unattached volumes with the AWS CLI:
find-orphans.sh
# EBS volumes not attached to any instance
aws ec2 describe-volumes \
--filters Name=status,Values=available \
--query 'Volumes[].{ID:VolumeId,SizeGiB:Size,Created:CreateTime}' \
--output table
# Snapshots you own, oldest first
aws ec2 describe-snapshots --owner-ids self \
--query 'sort_by(Snapshots,&StartTime)[].{ID:SnapshotId,SizeGiB:VolumeSize,Started:StartTime}' \
--output tableThe engineering fix: automate it. A weekly job (a Lambda, or a visual workflow in n8n) that:
- Lists EBS volumes with
status = available. - Keeps only the ones unattached for more than 14 days.
- Takes a final snapshot of each (just in case).
- Deletes the volume.
- Posts what it deleted, and the monthly savings, to Slack.
Apply the same idea to snapshots with an age-based retention policy (Amazon Data Lifecycle Manager handles this natively).
Keep It From Coming Back: Shift Cost Checks Left
The seven fixes cut the bill. This stops it creeping back up. Add Infracost to your CI/CD pipeline so every Terraform pull request shows its cost impact before it is merged:
.github/workflows/infracost.yml
name: Infracost
on: [pull_request]
jobs:
infracost:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Infracost
uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Run Infracost
run: infracost breakdown --path . --format json --out-file infracost.json
- name: Comment on PR
run: |
infracost comment github --path infracost.json \
--repo "$GITHUB_REPOSITORY" \
--github-token "${{ github.token }}" \
--pull-request "${{ github.event.pull_request.number }}" \
--behavior updateIf someone changes an RDS instance from db.t3.medium to db.r5.24xlarge, the PR gets a comment with the projected monthly increase. You catch it before terraform apply, not on next month's bill.
- 1Measure — parse the CUR, find the real cost drivers.
- 2Re-architect — rightsize, Spot, Graviton, kill idle.
- 3Automate — Terraform + policies so waste can't creep back.
- 4Repeat — every release is a chance to save more.
moarkid.com — FinOps Engineering
The Operational Reality (What Breaks)
Cost optimization is never entirely risk-free. Here is what breaks:
- Spot reclamations. When AWS takes a Spot instance back, your application has 120 seconds. Your code must handle
SIGTERM: drain in-flight requests and refuse new ones. Without that, Spot will drop connections. - Multi-arch builds. A Python app that depends on an unmaintained C extension may not build on ARM. Test the
arm64image in CI before you cut over. - The "oops" deletion. Automated cleanup can delete a volume that was only detached for maintenance. Always snapshot before you delete.
- Idle shutdown surprises. Stopped RDS instances take several minutes to start. Schedule the morning start well before the first engineer logs in.
- The savings trap. Spending 40 hours of engineering time to save $50 a month is negative ROI. Work from the top of your bill down.
The 10-point AWS cost checklist
The checks I run first on an AWS bill, each with the AWS CLI command that finds the waste and what to do about it. You can also read it on the site.
The Payoff
Cost optimization is not about being cheap; it is about capital efficiency. Every dollar spent on idle CPU is a dollar you can't spend on hiring or runway. Treat the bill as an architecture problem, fix the seven leaks above, and put a cost check in every pull request so they stay fixed.
Once you know where the money goes, the next step is making it visible to finance. I wrote a guide on syncing AWS billing into Odoo ERP so your CFO gets cost data without bothering engineering.
Not sure which of the seven applies to you? That's what the free review is for: we look at your bill together and you leave with the top 3.