How to Cut Your AWS Bill by 40% — A Practical Checklist
A field-tested checklist for cutting an AWS bill, ordered by return on effort. Start with the free wins, end with the ones that need a meeting.
On this page
Every AWS bill I've inherited has had the same shape: a wall of line items nobody has read in months, and about a third of it going to things that do nothing. The good news is that the biggest wins are also the easiest. You don't need a FinOps team to claw back 40 percent. You need an afternoon, the CLI, and the discipline to actually delete things.
This is the order I work in. It's sorted by return on effort, not by how the console groups it. Do the top items first; they're free and safe. The bottom ones need a bit more thought.
1. Delete orphaned resources (start here, it's free money)
Nothing on AWS gets cleaned up on its own. Detach a volume, kill an instance, tear down a stack halfway, and the leftovers keep billing forever. This is always the first pass and it's usually the fattest.
Unattached EBS volumes:
aws ec2 describe-volumes \
--filters Name=status,Values=available \
--query 'Volumes[*].[VolumeId,Size,CreateTime]' --output table
Anything in available status is a volume attached to nothing, still charged per GB-month. Snapshot it if you're nervous, then delete.
Old snapshots and the AMIs sitting on top of them:
aws ec2 describe-snapshots --owner-ids self \
--query 'Snapshots[?StartTime<=`2025-01-01`].[SnapshotId,VolumeSize,StartTime]' \
--output table
Unused Elastic IPs bill you when they're not attached, which is a special kind of insult:
aws ec2 describe-addresses \
--query 'Addresses[?AssociationId==`null`].[PublicIp,AllocationId]' \
--output table
Idle load balancers are another quiet drain. List them, then check whether any target group behind them has healthy targets:
aws elbv2 describe-load-balancers \
--query 'LoadBalancers[*].[LoadBalancerName,LoadBalancerArn]' --output table
An ALB with zero registered targets is roughly $16/month doing nothing. I've found a dozen at a time in accounts that ran a lot of short-lived environments.
2. Right-size EC2 and RDS
Once the dead stuff is gone, look at what's alive and oversized. Turn on Compute Optimizer (it's free) and let it watch for a couple of weeks, then pull its verdict:
aws compute-optimizer get-ec2-instance-recommendations \
--query 'instanceRecommendations[*].[instanceArn,currentInstanceType,recommendationOptions[0].instanceType]' \
--output table
If you'd rather trust your own eyes, the CloudWatch signal that matters is sustained CPU and memory. A box that never crosses 15 percent CPU across a fortnight is two sizes too big:
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 --metric-name CPUUtilization \
--dimensions Name=InstanceId,Value=i-0abc123 \
--start-time 2026-06-28T00:00:00Z --end-time 2026-07-12T00:00:00Z \
--period 86400 --statistics Average Maximum
RDS is where the real money hides, because people over-provision databases out of fear and then never revisit. Check CPUUtilization, FreeableMemory, and connection counts before you drop an instance class. Moving one db.r5.2xlarge down to an xlarge is real monthly savings, and RDS right-sizing is the single line item I've seen move a bill the most.
3. Tame logging and observability ingestion
This one surprises people. Log ingestion and retention can quietly become 10 to 20 percent of a bill, and almost none of it is ever queried.
First, find log groups with no retention set, because "never expire" is the default and it means you pay to store 2019 forever:
aws logs describe-log-groups \
--query 'logGroups[?retentionInDays==`null`].[logGroupName,storedBytes]' \
--output table
Set something sane. Ninety days for app logs, a year for anything you need for audits:
aws logs put-retention-policy \
--log-group-name /aws/lambda/my-func --retention-in-days 90
Do the same for S3 access logs and any log bucket: a lifecycle rule that expires objects after N days beats paying Standard storage for logs no human will read. And if you're shipping everything to a third-party observability tool, the ingestion bill there is often bigger than the AWS one. Sample your high-volume, low-value logs before they leave the account.
4. Commit to your baseline with Savings Plans
Everything above is about using less. This one is about paying less for what you'll use anyway. If you have a steady floor of compute — and almost everyone does — you're leaving 20 to 30 percent on the table by running it all On-Demand.
Look at Cost Explorer's Savings Plans recommendation, take the Compute Savings Plans (they flex across instance families, Fargate, and Lambda), and commit to your P50 baseline, not your peak. Under-commit on purpose. A one-year, no-upfront plan sized to the bottom of your usage graph is the low-risk move. Don't buy a three-year plan for a workload you might re-architect next quarter.
This is a deeper topic than one checklist item, so if you want the full treatment, read our cloud cost optimization guide.
5. Fix egress and NAT gateway patterns
Data transfer is the line item everyone forgets and nobody can read. Two specific culprits:
NAT Gateways charge per hour and per GB processed. If you've got private subnets pulling large images or hitting S3 through the NAT, you're paying transfer fees to reach a service in the same region. Add a VPC Gateway Endpoint for S3 and DynamoDB (they're free) and route that traffic around the NAT entirely.
Cross-AZ traffic is the other silent tax. Chatty services split across availability zones pay per GB in both directions. It adds up faster than most teams believe. Keep tightly-coupled services zone-aware where you can.
6. S3 storage class and lifecycle
Most S3 buckets are all Standard, all the time, because nobody set a policy. Push cold data down the tiers. If your access patterns are unpredictable, Intelligent-Tiering does it automatically and is usually the right default. For known-cold data, a lifecycle rule to Glacier is cheaper.
aws s3api put-bucket-lifecycle-configuration \
--bucket my-bucket \
--lifecycle-configuration file://lifecycle.json
Also run s3api list-multipart-uploads on your busy buckets. Failed multipart uploads leave orphaned parts that you pay for and can't see in the console.
7. Stop non-prod on nights and weekends
Your dev, staging, and QA environments do not need to run 168 hours a week when your engineers work maybe 50. Stopping them nights and weekends cuts those environments' compute by roughly 65 percent, and it's a one-line scheduled action.
aws ec2 stop-instances --instance-ids i-0abc123 i-0def456
Tag non-prod instances, then wire an EventBridge schedule to a Lambda that stops the tagged set at 8pm and starts them at 8am. It pays for itself the first night.
The call we'd make
Run items 1 through 3 this week. They're free, reversible, and they'll get you most of the way to 40 percent on their own. Do the Savings Plan once you trust your baseline, not before. Leave egress and NAT for when you've got a slow afternoon and a diagram, because that's the one that needs you to actually understand the traffic. The mistake I see teams make is starting with the Savings Plan — committing to pay for waste you haven't cleaned up yet. Delete first, right-size second, then commit to what's left.
GitLab CI/CD Best Practices in 2026: Pipelines You Can Trust
A production-focused GitLab CI/CD guide: include/extends templates, rules and workflow, needs DAGs, cache vs artifacts, protected environments, masked/protected variables and Vault, built-in security scanning, and review apps — with copy-paste examples.
Fixing DNS Resolution Failures in Kubernetes
A pod can't reach a service by name, the app throws connection errors, and everyone blames the network. Usually it's DNS, and usually it's fixable.
More from Cloud
Explore more articles in this category
Azure OpenAI's Sweden Central Outage: A Health Check Postmortem
A slow database made a backend service look dead, and the health check that was supposed to protect it killed it faster. The lesson has nothing to do with AI.
GKE Pod Snapshots: Cold Starts Drop 89%, If Your Nodes Match
Google's benchmarks show a 70B model restoring in 37 seconds instead of minutes. The catch is a hash and a hardware match that silently refuses to restore when either is off.
AWS Lost a Region for Good: Multi-AZ Is Not Disaster Recovery
AWS says it cannot restore data held only in Bahrain (me-south-1) or in one UAE zone. Multi-AZ gave availability, not recovery, and only cross-region copies survived.
You might have missed
Evergreen posts worth revisiting.