How to Save Big on Your AWS Bill: Our Top 5 Tips
How to save on your AWS bill: delete idle resources, rightsize and move to Graviton, commit with Savings Plans, tier storage and cut NAT transfer costs.
Open the bill of any AWS account that’s been running for a few years and the same handful of problems tend to turn up. They’re rarely exotic: storage left behind by instances that were terminated long ago, a fleet sized for a launch-day spike that never came, and a NAT gateway charging for every byte that goes to S3.
The good news is that the big wins tend to come from a short list of moves, and you can make most of them without touching application code. The less good news is that the order matters. Buy a three-year commitment before you clean up, and you’ve locked in paying for the waste.
Below are the five things we’d do first on almost any account, how to find each one, and roughly what it’s worth. All prices are on-demand list prices in us-east-1 at the time of writing. Your region and any negotiated rates will change the numbers, but the order of work holds.
1. Delete what nobody is using
Idle and orphaned resources are the cheapest savings you’ll ever find, because removing them costs nothing but a bit of nerve. They pile up in every account that’s more than a year old: volumes left behind when an instance was terminated, snapshots from a migration in 2023, Elastic IPs reserved “just in case”, load balancers with no healthy targets.
Unattached EBS volumes
A volume in the available state isn’t attached to anything, but you pay for every provisioned gigabyte. gp2 is $0.10 per GB-month and gp3 is $0.08. Twenty-five 200 GB gp2 volumes left over from an old load test come to 5,000 GB, or $500 a month, for storage nobody reads.
# Read-only: list unattached volumes in the current region
aws ec2 describe-volumes \
--filters Name=status,Values=available \
--query "Volumes[].[VolumeId,Size,VolumeType,CreateTime]" \
--output table
Old snapshots and idle public IPs
Standard EBS snapshots cost $0.05 per GB-month. They’re incremental, so the bill is usually smaller than the volume sizes suggest, but a few years of nightly snapshots with no retention policy adds up. Public IPv4 addresses cost $0.005 per hour whether or not they’re in use, which is about $3.65 a month each. Fifty forgotten addresses come to roughly $180 a month.
# Read-only: snapshots you own that are a year old or more
# (first form is GNU date on Linux, the fallback is BSD date on macOS)
CUTOFF=$(date -u -d '-1 year' +%F 2>/dev/null || date -u -v-1y +%F)
aws ec2 describe-snapshots --owner-ids self \
--query "Snapshots[?StartTime<='$CUTOFF'].[SnapshotId,VolumeSize,StartTime,Description]" \
--output table
# Read-only: Elastic IPs not associated with anything
aws ec2 describe-addresses \
--query "Addresses[?AssociationId==null].[PublicIp,AllocationId]" \
--output table
Run these in every region you use, including the one someone tried once for a demo and then forgot.
When you’re sure, the clean-up is a few calls per volume. The last one is destructive and can’t be undone, so take a final snapshot and wait for it to finish before you delete anything:
VOL=vol-0123456789abcdef0
# Take a final snapshot and keep its ID
SNAP=$(aws ec2 create-snapshot --volume-id "$VOL" \
--description "final copy before delete" \
--query SnapshotId --output text)
# Wait for the snapshot to complete, and delete only if it did.
# DESTRUCTIVE: the delete-volume call cannot be undone.
aws ec2 wait snapshot-completed --snapshot-ids "$SNAP" \
&& aws ec2 delete-volume --volume-id "$VOL"
A large first snapshot can take longer than the waiter’s default timeout of about ten minutes. If the wait times out, nothing is deleted. Run the last command again once the snapshot shows as completed.
When not to: snapshots kept for audit or legal retention, and volumes that belong to a stopped instance someone genuinely restarts each quarter. Tag those with an owner and a reason, then move on. For the full list of usual suspects, see our guide to idle and orphaned AWS resources.
A related quick win: non-production environments that run around the clock. If a dev stack only needs to be up for 50 of the week’s 168 hours, an instance scheduler cuts its compute bill by roughly 70%. EBS storage keeps billing while the instances are stopped, so the saving on the whole stack is a bit lower.
2. Rightsize, then move to Graviton
Instances are usually picked once, at launch, by someone guessing. Nobody revisits the guess. AWS Compute Optimizer looks at the last 14 days of CloudWatch metrics by default and tells you which instances, Auto Scaling groups, EBS volumes and Lambda functions are over-provisioned. The core recommendations are free once you opt in.
# Read-only: Compute Optimizer's view of your EC2 instances
aws compute-optimizer get-ec2-instance-recommendations \
--query "instanceRecommendations[].[instanceArn,finding,recommendationOptions[0].instanceType]" \
--output table
Within a family, each size down roughly halves the hourly price, so a single step on an over-provisioned fleet is a big number. Don’t trust average CPU alone, though. Look at peak and memory too (memory needs the CloudWatch agent), and keep headroom for anything latency-sensitive.
Then look at the processor. AWS says Graviton instances cost up to 20% less than comparable x86 instances, and the list prices bear that out: an m7i.large is $0.1008 an hour and an m7g.large is $0.0816, about 19% cheaper for the same vCPU and memory. For interpreted or JVM workloads, the switch is often just a new AMI and an arm64 container build. Run it in staging under real load for a week before you touch production.
When not to: software licensed per x86 core, native dependencies without arm64 builds, or a team mid-migration that doesn’t need another variable this quarter.
While you’re in EBS, move gp2 to gp3. It’s 20% cheaper per GB and includes 3,000 IOPS and 125 MB/s as a baseline. The change happens online. The one catch: gp2 volumes above 1,000 GB get more than 3,000 baseline IOPS, so check what a big volume actually uses before you switch it, and provision extra IOPS on gp3 if needed.
# Read-only: gp2 volumes and their current IOPS
aws ec2 describe-volumes --filters Name=volume-type,Values=gp2 \
--query "Volumes[].[VolumeId,Size,Iops]" --output table
3. Commit to your steady baseline
Once the waste is gone, look at what’s left that runs all day, every day. That floor is what Savings Plans and Reserved Instances are for. At the time of writing, AWS lists these maximum discounts against on-demand:
| Commitment | Covers | Up to |
|---|---|---|
| Compute Savings Plans | EC2, Fargate, Lambda, any region or family | 66% |
| EC2 Instance Savings Plans | One instance family in one region | 72% |
| Database Savings Plans (1 year, no upfront) | RDS, Aurora, DynamoDB, ElastiCache and others | 35% serverless, 20% provisioned |
| SageMaker AI Savings Plans | SageMaker AI instance usage | 64% |
The “up to” figures for Compute, EC2 Instance and SageMaker AI plans assume three years, all upfront. For those three, one year with no upfront is the gentler starting point and still a meaningful discount. Database Savings Plans only come as one year with no upfront payment. AWS puts serverless usage at up to 35% off and provisioned database instances at up to 20%.
Size the commitment to the floor. If your On-Demand compute spend swings between $8 and $14 an hour, $8 is your floor. A Savings Plan commitment is measured at the discounted rate, so at roughly 30% off, that floor is about $5.60 an hour of commitment. Commit to the On-Demand average instead and you pay for unused commitment every quiet hour. We work through the conversion in commitment dollars are not On-Demand dollars.
# Read-only: AWS's own recommendation based on the last 30 days
aws ce get-savings-plans-purchase-recommendation \
--savings-plans-type COMPUTE_SP \
--term-in-years ONE_YEAR \
--payment-option NO_UPFRONT \
--lookback-period-in-days THIRTY_DAYS
Two notes. Cost Explorer API calls cost $0.01 each, so don’t loop them in a cron job. And if you get it wrong, a Savings Plan with an hourly commitment of $100 or less can be returned within seven days, as long as it’s still the same calendar month.
When not to: right before a big migration, re-architecture or shutdown, or when you haven’t done steps 1 and 2. Compute Savings Plans versus Reserved Instances is a whole topic of its own, and we cover it in Reserved Instances vs Savings Plans.
4. Put storage in the right class and let lifecycle rules do the work
S3 Standard is $0.023 per GB-month for the first 50 TB. Data that’s rarely read doesn’t need to be there:
| Storage class | Price per GB-month | Minimum duration |
|---|---|---|
| S3 Standard | $0.023 | none |
| S3 Standard-IA | $0.0125 | 30 days |
| S3 Glacier Instant Retrieval | $0.004 | 90 days |
| S3 Glacier Flexible Retrieval | $0.0036 | 90 days |
| S3 Glacier Deep Archive | $0.00099 | 180 days |
Take 50 TB of old application logs. In Standard, that’s about $1,180 a month. In Deep Archive, about $51. You’ll pay retrieval fees and wait hours if you need them back, which for logs you read twice a year is a fine trade.
The traps are small objects and short lives. Standard-IA and Glacier Instant Retrieval bill objects under 128 KB as if they were 128 KB, and moving data that gets deleted after two weeks into a class with a 90-day minimum costs more, not less. If you can’t predict access, S3 Intelligent-Tiering moves objects for you for a monitoring fee of $0.0025 per 1,000 objects a month. Objects under 128 KB aren’t monitored or charged that fee.
Every bucket should also have two boring lifecycle rules: abort incomplete multipart uploads after a few days, and expire noncurrent versions if versioning is on. Those two catch a surprising amount of invisible storage.
# Read-only: buckets with no lifecycle configuration
for b in $(aws s3api list-buckets --query "Buckets[].Name" --output text); do
aws s3api get-bucket-lifecycle-configuration --bucket "$b" >/dev/null 2>&1 \
|| echo "no lifecycle: $b"
done
# Read-only: CloudWatch log groups set to keep data forever
aws logs describe-log-groups \
--query "logGroups[?retentionInDays==null].[logGroupName,storedBytes]" \
--output table
(A permissions error also prints “no lifecycle”, so check any surprises by hand.)
5. Watch data transfer, especially through NAT gateways
Data transfer is the line item people understand least, and NAT gateways are where it bites hardest. In us-east-1, a NAT gateway costs $0.045 an hour, about $33 a month just to exist, plus $0.045 for every GB it processes. Push 10 TB a month through one and the processing charge alone is about $460.
The most common culprit is traffic to S3 or DynamoDB from private subnets. Gateway VPC endpoints for those two services have no hourly or data processing charge. Add one, update the route tables, and that traffic stops touching the NAT gateway. It’s often the best return on fifteen minutes of work we know of.
Other transfer costs worth knowing:
- Traffic between Availability Zones costs $0.01 per GB in each direction. Chatty services spread across zones pay it twice.
- Internet egress from us-east-1 starts at $0.09 per GB after the first 100 GB a month, which is free across services.
- Interface endpoints for other services cost $0.01 per hour per AZ plus $0.01 per GB, still far cheaper than NAT processing at volume.
# Read-only: last month's NAT gateway charges by usage type
# (GNU date first, BSD/macOS date as the fallback)
START=$(date -u -d "$(date -u +%Y-%m-01) -1 month" +%F 2>/dev/null || date -u -v1d -v-1m +%F)
END=$(date -u +%Y-%m-01)
aws ce get-cost-and-usage \
--time-period Start="$START",End="$END" \
--granularity MONTHLY \
--metrics UnblendedCost \
--filter '{"Dimensions":{"Key":"SERVICE","Values":["EC2 - Other"]}}' \
--group-by Type=DIMENSION,Key=USAGE_TYPE \
--query "ResultsByTime[0].Groups[?contains(Keys[0], 'NatGateway')].[Keys[0],Metrics.UnblendedCost.Amount]" \
--output table
When not to: don’t rip out NAT gateways that legitimately carry internet-bound traffic, and don’t collapse to one AZ to save on cross-AZ charges if your availability targets need more. Our deep dive on NAT gateway and data transfer costs covers VPC Flow Logs analysis for finding the heavy talkers.
The order we’d work in
| Order | Move | Effort | Risk |
|---|---|---|---|
| 1 | Delete idle and orphaned resources | Hours | Low, with a final snapshot |
| 2 | S3 and DynamoDB gateway endpoints | Minutes per VPC | Very low |
| 3 | gp2 to gp3, lifecycle rules, log retention | Hours | Low |
| 4 | Rightsizing and Graviton | Days to weeks | Medium, needs testing |
| 5 | Savings Plans on the cleaned-up baseline | An afternoon | Financial, sized to the floor |
FAQ
What is the fastest way to reduce my AWS bill?
Delete unattached EBS volumes, old snapshots and unused Elastic IPs, and add gateway endpoints for S3 and DynamoDB. Both are quick, low risk, and the savings show up on the next bill.
Should I buy Savings Plans before rightsizing?
No. Clean up and rightsize first, then commit to what’s left. Otherwise you’re committing to pay for capacity you’re about to remove.
Is it safe to delete unattached EBS volumes?
Usually, but not always. Check tags and creation dates, ask the owning team, and take a final snapshot of anything uncertain. A snapshot costs far less than the volume and gives you a way back.
How much does a NAT gateway cost per month in us-east-1?
About $33 a month per gateway in hourly charges, plus $0.045 per GB processed. Teams usually run one per AZ, so multiply accordingly.
Will moving to Graviton break my application?
Most interpreted and JVM workloads run unchanged on arm64 images. Native libraries, older binaries and per-core licensed software are where problems show up, so test in staging first.
Start with your oldest account
Pick the oldest dev account you have and run the read-only commands above in every region it uses. Next to each finding, write what it costs per month and who owns it. That list is a week of work, and it usually pays for itself before the week is out.
Kostrix runs several of these checks every day, among them unattached EBS volumes, unassociated Elastic IPs, gp2 volumes that should be gp3 and log groups with no retention, so the list stays current after the first clean-up.
