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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
One bundles your whole toolchain, the other lets you build anything from parts. The right pick depends on how much glue you want to own.
GitHub Actions OIDC gets all the attention, but GitLab, Buildkite, and CircleCI issue the same signed tokens. Here's how to trust them without opening a hole.
Explore more articles in this category
The observability market is huge and the pricing is a minefield. This is the map to the tools that matter, what each is best at, and how to avoid a runaway bill.
Cloud bills grow quietly until someone asks why. This is the map for cutting spend without cutting reliability: where the money actually goes, the levers that work, and the tools worth paying for.
Datadog bills climb quietly until finance forwards the invoice. Here's the playbook we run to cut spend hard while keeping every signal that matters.
Evergreen posts worth revisiting.