AWS Cost Optimization: 15 Strategies to Reduce Your AWS Bill (2026)
AWS bills can spiral out of control fast. A single forgotten NAT Gateway, oversized RDS instance, or uncompressed S3 bucket can cost thousands per month. This guide walks through 15 concrete, actionable strategies that real engineering teams use to cut AWS spend — with CLI commands you can run today.
1. Right-Sizing with AWS Compute Optimizer
AWS Compute Optimizer analyzes CloudWatch metrics and ML models to recommend optimal instance types for EC2, Lambda, ECS on Fargate, EBS volumes, and Auto Scaling groups. Most teams over-provision by 30–50% "to be safe" — Compute Optimizer quantifies exactly how much headroom you're wasting.
Enable it across your organization (requires AWS Organizations):
# Enable Compute Optimizer for all accounts in the organization
aws compute-optimizer update-enrollment-status \
--status Active \
--include-member-accounts
# Get EC2 instance recommendations for your account
aws compute-optimizer get-ec2-instance-recommendations \
--filters name=Finding,values=OVER_PROVISIONED \
--output table
# Export recommendations to S3 for bulk review
aws compute-optimizer export-ec2-instance-recommendations \
--s3-destination-config bucket=my-cost-exports,keyPrefix=compute-optimizer/ \
--include-member-accounts
The output shows current vs recommended instance type, projected monthly savings, and performance risk (Low/Medium/High). A finding of OVER_PROVISIONED with a Low performance risk means it's safe to downsize. A typical mid-size AWS account finds $500–$5,000/month in EC2 right-sizing alone.
For EBS volumes, focus on gp2 volumes over 1TB — they're almost always cheaper to migrate to gp3 which gives 3,000 IOPS baseline free (vs gp2 where you pay per IOPS above baseline):
# Find all gp2 volumes
aws ec2 describe-volumes \
--filters Name=volume-type,Values=gp2 \
--query 'Volumes[*].[VolumeId,Size,State]' \
--output table
# Convert a volume to gp3 (no downtime required)
aws ec2 modify-volume \
--volume-id vol-0abc12345def67890 \
--volume-type gp3 \
--iops 3000 \
--throughput 125
2. Reserved Instances vs Savings Plans vs Spot: Choosing the Right Commitment
This is the single biggest lever for compute cost reduction. A 1-year, no-upfront Savings Plan gives ~30% off On-Demand; a 3-year all-upfront gives ~60% off. But the wrong commitment type can lock you in unnecessarily. Here's a direct comparison:
| Feature | Reserved Instances (RI) | Savings Plans (Compute) | Savings Plans (EC2) | Spot Instances |
|---|---|---|---|---|
| Discount vs On-Demand | Up to 72% | Up to 66% | Up to 72% | Up to 90% |
| Commitment | Specific instance family + region | Any EC2, Lambda, Fargate | Specific instance family + region | None (can be interrupted) |
| Flexibility | Low — locked to instance type | High — any compute service | Medium — can change size/OS | Highest — no lock-in |
| Term | 1 or 3 years | 1 or 3 years | 1 or 3 years | None |
| Interruption Risk | None | None | None | Yes (2-min warning) |
| Best For | Stable, specific workloads (RDS, specific EC2 families) | Most general compute workloads | Stable EC2 with family flexibility | Batch, CI/CD, stateless workers |
| Sellable on Marketplace | Yes (Standard RI) | No | No | N/A |
Use Cost Explorer's Savings Plans recommendations to find the optimal hourly commitment:
# Get Savings Plans purchase recommendations
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 \
--query 'SavingsPlansPurchaseRecommendation.{HourlyCommitment:SavingsPlansDetails.HourlyCommitment,EstimatedMonthlySavings:SavingsPlansPurchaseRecommendationSummary.EstimatedMonthlySavingsAmount}'
3. Spot Instances for Batch Workloads
Spot Instances use spare EC2 capacity at up to 90% discount. The catch: AWS can reclaim them with 2 minutes' notice. This is fine for stateless, fault-tolerant workloads: CI/CD runners, ML training jobs, data processing pipelines, and video transcoding.
The key to reliable Spot usage is diversification — request multiple instance families and sizes so AWS has more pools to draw from:
# Launch a Spot instance with a price limit
aws ec2 request-spot-instances \
--spot-price "0.05" \
--instance-count 3 \
--type persistent \
--launch-specification '{
"ImageId": "ami-0abcdef1234567890",
"InstanceType": "m5.xlarge",
"KeyName": "my-key",
"SecurityGroupIds": ["sg-0123456789abcdef0"],
"SubnetId": "subnet-0123456789abcdef0"
}'
# Better: Use a Spot Fleet with multiple instance types
aws ec2 create-fleet \
--launch-template-configs '[{
"LaunchTemplateSpecification": {
"LaunchTemplateId": "lt-0123456789abcdef0",
"Version": "$Latest"
}
}]' \
--target-capacity-specification '{
"TotalTargetCapacity": 10,
"OnDemandTargetCapacity": 2,
"SpotTargetCapacity": 8,
"DefaultTargetCapacityType": "spot"
}' \
--spot-options 'AllocationStrategy=price-capacity-optimized' \
--type instant
price-capacity-optimized allocation strategy (not the default lowestPrice). It picks pools with both low price AND high available capacity, resulting in far fewer interruptions in practice.For Kubernetes workloads, Karpenter (the AWS-native node autoscaler) handles Spot diversification automatically. A single Karpenter NodePool can span 20+ instance types, achieving near-zero interruptions for most workloads.
4. S3 Lifecycle Policies
S3 Standard costs $0.023/GB/month. Glacier Instant Retrieval costs $0.004/GB/month — 83% cheaper. Most teams have gigabytes of logs, backups, and old artifacts sitting in Standard storage forever. Lifecycle policies automate the transition:
# Apply lifecycle policy to a bucket
aws s3api put-bucket-lifecycle-configuration \
--bucket my-application-logs \
--lifecycle-configuration '{
"Rules": [
{
"ID": "move-logs-to-ia",
"Status": "Enabled",
"Filter": {"Prefix": "logs/"},
"Transitions": [
{
"Days": 30,
"StorageClass": "STANDARD_IA"
},
{
"Days": 90,
"StorageClass": "GLACIER_IR"
},
{
"Days": 365,
"StorageClass": "DEEP_ARCHIVE"
}
],
"Expiration": {
"Days": 2555
},
"NoncurrentVersionExpiration": {
"NoncurrentDays": 30
}
}
]
}'
Also enable S3 Intelligent-Tiering for unpredictable access patterns — it automatically moves objects between tiers with no retrieval fees and no minimum duration for the Frequent/Infrequent tiers.
Don't forget to check for incomplete multipart uploads — they accumulate silently and cost full Standard storage rates:
# List incomplete multipart uploads
aws s3api list-multipart-uploads --bucket my-application-logs
# Add lifecycle rule to automatically abort incomplete uploads after 7 days
aws s3api put-bucket-lifecycle-configuration \
--bucket my-application-logs \
--lifecycle-configuration '{
"Rules": [{
"ID": "abort-incomplete-mpu",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}
}]
}'
5. Reducing Data Transfer Costs
Data transfer is the most misunderstood AWS cost. Key rules: data IN to AWS is free. Data between services in the same Availability Zone over private IP is free. Cross-AZ traffic is $0.01/GB each way. Data OUT to the internet is $0.09/GB for the first 10TB/month.
Common fixes:
- Use S3 Transfer Acceleration only when needed — it costs extra; use CloudFront instead for static assets
- Enable VPC endpoints for S3 and DynamoDB — free, eliminates NAT Gateway charges for those services
- Compress data before storing in S3 — gzip/zstd compression reduces both storage and transfer costs
- Use CloudFront — CloudFront to internet egress is ~$0.0085/GB vs $0.09/GB direct from EC2/S3
# Create a free S3 VPC endpoint to eliminate NAT Gateway charges for S3 traffic
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0123456789abcdef0 \
--service-name com.amazonaws.us-east-1.s3 \
--route-table-ids rtb-0123456789abcdef0 \
--vpc-endpoint-type Gateway
# Check current data transfer costs via Cost Explorer
aws ce get-cost-and-usage \
--time-period Start=2026-05-01,End=2026-06-01 \
--granularity MONTHLY \
--filter '{"Dimensions":{"Key":"USAGE_TYPE_GROUP","Values":["EC2: Data Transfer - Internet"]}}' \
--metrics BlendedCost
6. NAT Gateway: The Sneaky $30–$300/Month Bill
NAT Gateway charges $0.045/hour (≈$32/month) per gateway, plus $0.045/GB of processed data. A production environment with NAT Gateways in 3 AZs costs $96/month before any data transfer. Many teams have 5–10 NAT Gateways burning $200–$400/month for traffic that could go through free VPC endpoints.
Replace NAT Gateway traffic with VPC Interface Endpoints for AWS services you use heavily:
# Create interface endpoints for commonly used services
# These cost ~$7.20/month each but eliminate NAT Gateway data charges
# ECR (Docker image pulls from private subnets)
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0123456789abcdef0 \
--service-name com.amazonaws.us-east-1.ecr.dkr \
--vpc-endpoint-type Interface \
--subnet-ids subnet-priv-1a subnet-priv-1b \
--security-group-ids sg-vpce
# ECR API
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0123456789abcdef0 \
--service-name com.amazonaws.us-east-1.ecr.api \
--vpc-endpoint-type Interface \
--subnet-ids subnet-priv-1a subnet-priv-1b \
--security-group-ids sg-vpce
# Secrets Manager (eliminates high-volume secret fetch NAT traffic)
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0123456789abcdef0 \
--service-name com.amazonaws.us-east-1.secretsmanager \
--vpc-endpoint-type Interface \
--subnet-ids subnet-priv-1a subnet-priv-1b \
--security-group-ids sg-vpce
logs (CloudWatch Logs), monitoring (CloudWatch Metrics), ssm, and ec2messages. These services generate high-volume traffic from containerized apps. Each endpoint saves more than it costs in most production environments.7. AWS Trusted Advisor Checks
Trusted Advisor's Cost Optimization checks identify idle resources automatically. With Business/Enterprise Support, all checks are available. Key checks to act on:
- Idle EC2 Instances: CPU < 10% for 14 days
- Underutilized EBS Volumes: IOPS < 1 for 7 days (detached volumes)
- Unassociated Elastic IPs: $0.005/hour each — $3.60/month per idle EIP
- Idle RDS DB Instances: no connections for 7 days
- Underutilized Redshift Clusters
# List all Trusted Advisor cost optimization checks
aws support describe-trusted-advisor-checks \
--language en \
--query 'checks[?category==`cost_optimizing`].[id,name]' \
--output table
# Get results for idle EC2 instances check (ID: ifx4T3T7Tw)
aws support describe-trusted-advisor-check-result \
--check-id ifx4T3T7Tw \
--language en \
--query 'result.flaggedResources[*].[metadata[0],metadata[1],metadata[2]]' \
--output table
# Release all unassociated Elastic IPs
aws ec2 describe-addresses \
--query 'Addresses[?AssociationId==null].[AllocationId,PublicIp]' \
--output text | while read alloc_id ip; do
echo "Releasing $ip ($alloc_id)"
aws ec2 release-address --allocation-id $alloc_id
done
8. Cost Explorer: Finding Where Money Is Going
Cost Explorer's most powerful feature isn't the dashboard — it's the API. Use it to build custom reports, detect anomalies, and automate cost reporting to Slack or email.
# Top 10 services by cost last month
aws ce get-cost-and-usage \
--time-period Start=2026-05-01,End=2026-06-01 \
--granularity MONTHLY \
--metrics BlendedCost \
--group-by Type=DIMENSION,Key=SERVICE \
--query 'ResultsByTime[0].Groups | sort_by(@, &Metrics.BlendedCost.Amount) | reverse(@) | [:10].[Keys[0],Metrics.BlendedCost.Amount]' \
--output table
# Cost by tag (requires tags on resources)
aws ce get-cost-and-usage \
--time-period Start=2026-05-01,End=2026-06-01 \
--granularity MONTHLY \
--metrics UnblendedCost \
--group-by Type=TAG,Key=Environment \
--output json
9. AWS Budgets: Alerting Before You're Surprised
Budgets alert you before bills arrive. Set up both actual cost budgets and forecasted cost budgets — the forecasted budget triggers alerts when AWS predicts you'll exceed your limit by end of month, giving you time to act.
# Create a monthly cost budget with email + SNS alerts
aws budgets create-budget \
--account-id 123456789012 \
--budget '{
"BudgetName": "monthly-total-budget",
"BudgetLimit": {"Amount": "5000", "Unit": "USD"},
"TimeUnit": "MONTHLY",
"BudgetType": "COST",
"CostFilters": {}
}' \
--notifications-with-subscribers '[
{
"Notification": {
"NotificationType": "ACTUAL",
"ComparisonOperator": "GREATER_THAN",
"Threshold": 80,
"ThresholdType": "PERCENTAGE"
},
"Subscribers": [
{"SubscriptionType": "EMAIL", "Address": "team@example.com"},
{"SubscriptionType": "SNS", "Address": "arn:aws:sns:us-east-1:123456789012:cost-alerts"}
]
},
{
"Notification": {
"NotificationType": "FORECASTED",
"ComparisonOperator": "GREATER_THAN",
"Threshold": 100,
"ThresholdType": "PERCENTAGE"
},
"Subscribers": [
{"SubscriptionType": "EMAIL", "Address": "team@example.com"}
]
}
]'
10. Tagging Strategy for Cost Allocation
You can't optimize what you can't see. Cost allocation tags let you split bills by team, application, environment, and project. Activate tags in Billing Console, then enforce them with AWS Config or Tag Policies in Organizations.
Recommended minimum tag set:
Environment: prod / staging / dev / sandboxTeam: platform / backend / data / frontendApplication: billing-service / auth-service / etc.CostCenter: accounting code for chargeback
# Find untagged EC2 instances
aws ec2 describe-instances \
--query 'Reservations[*].Instances[?!Tags || length(Tags[?Key==`Environment`])==`0`].[InstanceId,State.Name]' \
--output table
# Bulk tag resources using Resource Groups Tagging API
aws resourcegroupstaggingapi tag-resources \
--resource-arn-list \
arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123 \
arn:aws:rds:us-east-1:123456789012:db:mydb \
--tags Environment=prod,Team=backend,Application=auth-service
# Create a Tag Policy in AWS Organizations to enforce tags
aws organizations create-policy \
--type TAG_POLICY \
--name "require-environment-tag" \
--description "Require Environment tag on all resources" \
--content '{
"tags": {
"Environment": {
"tag_value": {"@@assign": ["prod","staging","dev","sandbox"]},
"enforced_for": {"@@assign": ["ec2:instance","rds:db","lambda:function"]}
}
}
}'
11. Automated Idle Resource Cleanup
Build a Lambda function that runs weekly, finds idle resources, and either terminates them or sends a Slack notification for human review:
import boto3
from datetime import datetime, timedelta, timezone
def lambda_handler(event, context):
ec2 = boto3.client('ec2')
results = []
# Find stopped instances older than 30 days
response = ec2.describe_instances(
Filters=[{'Name': 'instance-state-name', 'Values': ['stopped']}]
)
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
for reservation in response['Reservations']:
for instance in reservation['Instances']:
# Check launch time as proxy for last-stopped (use State.TransitionReason for accuracy)
launch_time = instance['LaunchTime']
if launch_time < cutoff:
instance_id = instance['InstanceId']
name = next(
(t['Value'] for t in instance.get('Tags', []) if t['Key'] == 'Name'),
'unnamed'
)
results.append({
'InstanceId': instance_id,
'Name': name,
'LaunchTime': str(launch_time),
'Action': 'NOTIFY' # Change to TERMINATE after review period
})
# Find unattached EBS volumes
volumes = ec2.describe_volumes(
Filters=[{'Name': 'status', 'Values': ['available']}]
)
for vol in volumes['Volumes']:
if vol['CreateTime'] < cutoff:
results.append({
'VolumeId': vol['VolumeId'],
'SizeGB': vol['Size'],
'CreateTime': str(vol['CreateTime']),
'Action': 'NOTIFY'
})
print(f"Found {len(results)} idle resources")
# Send to SNS for Slack integration
sns = boto3.client('sns')
if results:
sns.publish(
TopicArn='arn:aws:sns:us-east-1:123456789012:idle-resources',
Subject=f'AWS Idle Resources: {len(results)} found',
Message=str(results)
)
return results
12–15. Additional Quick Wins
12. Lambda Cost Optimization
Lambda charges per GB-second. Use Lambda Power Tuning (open source tool) to find the optimal memory setting — sometimes lower memory = lower cost despite longer runtime; sometimes higher memory = lower cost due to faster execution.
13. RDS Cost Optimization
Use RDS Reserved Instances (1-year No Upfront = ~30% off). Enable auto-scaling for Aurora Serverless v2 for variable workloads. Use Multi-AZ only in production — dev/staging environments don't need it.
14. CloudWatch Logs Retention
CloudWatch Logs storage costs $0.03/GB/month. Without a retention policy, logs accumulate forever. Set retention to 30–90 days on all log groups:
# Set 30-day retention on all log groups missing a retention policy
aws logs describe-log-groups \
--query 'logGroups[?!retentionInDays].[logGroupName]' \
--output text | while read group; do
aws logs put-retention-policy \
--log-group-name "$group" \
--retention-in-days 30
echo "Set 30-day retention on $group"
done
15. Delete Unused Snapshots
EBS snapshots cost $0.05/GB/month. Old snapshots from terminated instances or automated backup policies accumulate silently:
# Find snapshots older than 90 days owned by your account
aws ec2 describe-snapshots \
--owner-ids self \
--query 'Snapshots[?StartTime<=`2026-03-01`].[SnapshotId,StartTime,VolumeSize,Description]' \
--output table
FAQ: AWS Cost Optimization
Q: My AWS bill increased 40% last month but I didn't add any new services. Where should I look first?
Open Cost Explorer and change the granularity to Daily, then look for the exact day the cost jumped. Filter by service and region. Common culprits: a NAT Gateway that was accidentally deleted and recreated (traffic now routes differently), a Lambda function with a bug causing infinite retries, a new CloudWatch Logs group with verbose debug logging, or an EC2 Auto Scaling group that scaled up and wasn't noticed. Cost Anomaly Detection should have caught this automatically — set it up immediately.
Q: Should I use Reserved Instances or Savings Plans for RDS?
Use RDS Reserved Instances — Savings Plans don't cover RDS. RDS RIs work at the DB instance class level. A 1-year, no-upfront RDS RI gives ~30% off; 3-year all-upfront gives ~60% off. If you're running PostgreSQL or MySQL and might switch between instance sizes, prefer Convertible RIs which allow exchanges. For Aurora, RIs apply to ACUs (Aurora Capacity Units) for Serverless v2, or to instance class for provisioned.
Q: What is data transfer actually costing me and how do I break it down?
Run this Cost Explorer query: group by Usage Type and filter to the last 3 months. Look for line items containing "DataTransfer". The key categories are: DataTransfer-Out-Bytes (to internet, expensive), DataTransfer-Regional-Bytes (cross-AZ, $0.01/GB each way), and DataTransfer-In-Bytes (free). Cross-AZ traffic is often the biggest surprise — every load balancer health check, every cross-AZ RDS call, every cross-AZ ECS container communication generates it.
Q: How do I stop getting charged for an S3 bucket I deleted?
If you're seeing charges for a deleted bucket, the charges are likely from the billing cycle before deletion. Check for: (1) Replication destination buckets that are still active, (2) CloudTrail S3 data events enabled on the bucket (billed per 100k events), (3) S3 Access Logs being written to another bucket that still exists, (4) incomplete multipart uploads in other buckets. S3 charges appear in the bill about 1–2 days after the activity, so a deletion on June 30 may still show July charges for June activity.
Q: What's the fastest way to reduce costs in the next 24 hours?
In priority order: (1) Release all unattached Elastic IPs — free money, zero risk. (2) Stop all dev/staging EC2 instances and RDS instances outside business hours using Instance Scheduler. (3) Delete unused EBS volumes in "available" state. (4) Set CloudWatch Logs retention policies on all log groups. (5) Check Trusted Advisor for idle resources. These five actions alone often save $200–$2,000/month depending on account size, and none of them affect production workloads.