Cloud cost optimization works when you do it in a fixed order: make spend attributable, delete what is idle, right-size what is left, and only then buy commitments such as Savings Plans or Reserved Instances for the baseline that remains. Done in reverse, you lock discounts on to waste for a year. Most durable savings come from three mechanisms: clear ownership of spend, commitment discounts on a stable baseline, and architecture changes that stop data transfer and storage from growing faster than the business.
Key takeaways
- Order matters. Attribution, cleanup and right-sizing come before commitments, or you pay a discounted rate for resources you should have deleted.
- Commit to the trough, not the average. Unused Savings Plan commitment is billed whether you use it or not.
- Spot is safe for work that can lose an instance with two minutes' notice, if you diversify instance types and keep an on-demand base.
- Data transfer is an architecture problem. NAT Gateway processing, cross-AZ chatter and internet egress are fixed in the network design, not the billing console.
- Storage only grows unless something deletes it. Lifecycle rules, log retention and snapshot expiry need an owner.
- Savings stick only with guardrails: budgets, anomaly alerts and cost estimates in pull requests.
Why the order matters
Each step changes the input to the next one. Right-sizing an instance you should have deleted is wasted effort. Buying a Savings Plan sized on oversized instances commits you to paying for that oversizing for the whole term.
| Step | What you do | Why it comes before the next |
|---|---|---|
| 1. Attribute | Tags, cost allocation, owners | You cannot ask a team to fix spend nobody can trace to them |
| 2. Delete idle | Remove what nobody uses | Cheapest saving, zero risk to users |
| 3. Right-size | Match instance size and type to real usage | Shrinks the baseline you are about to commit to |
| 4. Commit | Savings Plans, Reserved Instances | Discounts the right-sized baseline only |
| 5. Spot and scheduling | Move flexible work to Spot, stop non-production at night | Handles what sits above the committed baseline |
| 6. Architecture | Storage tiers, data transfer, database model | Slowest to change, largest long-term effect |
| 7. Guardrails | Budgets, anomaly detection, PR cost checks | Keeps the savings from eroding |
The ten strategies below follow that order.
1. Make every dollar attributable
Before optimizing anything, make it possible to answer "whose is this?" for most of the bill. That means a small mandatory tag set (team, service, environment), enforced at creation, and activated as cost allocation tags so billing tools can group by them. Tags are not retroactive: spend from before activation stays unattributed.
aws ce update-cost-allocation-tags-status \
--cost-allocation-tags-status TagKey=team,Status=Active TagKey=service,Status=Active TagKey=environment,Status=ActiveShared costs (a Kubernetes cluster, a shared database, a NAT Gateway) need a split rule, not a tag. Cost Categories can allocate shared spend by rule, and split cost allocation data breaks ECS and EKS costs down by task or pod. The full governance side, including tag enforcement policies, showback and unit economics, is in our FinOps guide.
2. Delete idle resources
This is the cheapest money you will ever save. Idle resources survive because deleting them has no owner and feels risky. Run these and you will usually find something:
aws ec2 describe-volumes \
--filters Name=status,Values=available \
--query 'Volumes[].[VolumeId,Size,VolumeType,CreateTime]' \
--output table
aws ec2 describe-addresses \
--query 'Addresses[?AssociationId==null].[PublicIp,AllocationId]' \
--output table
aws ec2 describe-snapshots --owner-ids self \
--query 'sort_by(Snapshots,&StartTime)[].[SnapshotId,VolumeSize,StartTime,Description]' \
--output table
aws logs describe-log-groups \
--query 'logGroups[?retentionInDays==null].[logGroupName,storedBytes]' \
--output tableWhat each finds:
- Unattached EBS volumes. They outlive the instance that created them when delete-on-termination was off. Snapshot, then delete.
- Unassociated Elastic IPs and public IPv4 addresses. AWS charges for every public IPv4 address by the hour, attached or not, so public IPs on instances that do not need them are a cost too, not just idle ones.
- Old snapshots. Manual snapshots taken before a change and never deleted. Use Data Lifecycle Manager or AWS Backup with retention rules instead of manual snapshots.
- Log groups with no retention. CloudWatch Logs keeps data forever by default. Set retention on every group:
aws logs put-retention-policy --log-group-name /app/api --retention-in-days 30. - Load balancers with no healthy targets, idle RDS instances, forgotten test environments. Cost Explorer and Trusted Advisor flag many of these.
Before deleting anything, tag it with a deletion date and announce it. Then delete on that date. The two-step process costs a week and saves you from deleting the volume that turned out to be someone's only copy.
3. Right-size compute
Engineers size instances for the peak they imagine, then never look again. Right-sizing compares what you provisioned with what you use.
AWS Compute Optimizer does this from CloudWatch metrics once you opt in:
aws compute-optimizer get-ec2-instance-recommendations \
--query 'instanceRecommendations[].[instanceArn,finding,currentInstanceType,recommendationOptions[0].instanceType]' \
--output tableTwo caveats. Compute Optimizer cannot see memory unless the CloudWatch agent publishes it, so a memory-bound service can look oversized on CPU alone. And recommendations reflect the lookback window, so a service with a monthly batch peak needs a window that includes it.
Look at the instance family, not just the size. Moving to a current generation usually costs less for the same work, and Graviton (arm64) families often give better price-performance for code that builds cleanly for arm64. Most interpreted and JVM workloads move easily; anything with native dependencies needs a build and a load test first.
Do not forget EBS. Converting gp2 volumes to gp3 is an online change that lowers the per-GB price and decouples IOPS and throughput from volume size:
aws ec2 modify-volume --volume-id vol-0abc123 --volume-type gp34. Buy commitments for the baseline only
Commitment discounts are how cloud providers reward predictability. On AWS there are two mechanisms, and the difference is what you commit to.
| Compute Savings Plans | EC2 Instance Savings Plans | Reserved Instances | |
|---|---|---|---|
| You commit to | A dollar amount per hour | A dollar amount per hour in one instance family and region | A specific instance type (or family, for regional RIs), region and term |
| Covers | EC2 in any family or region, Fargate, Lambda | EC2 in that family and region | The matching service's reserved offering (EC2, RDS, ElastiCache, OpenSearch, Redshift) |
| Flexibility | Highest | Size, OS and AZ within the family | Lowest; Convertible RIs can be exchanged |
| Discount depth | Lower | Higher | Varies by type and term |
The mechanism that matters: you pay the committed amount every hour of the term. Usage up to the commitment is billed at the discounted rate; usage above it is billed at on-demand rates; commitment you do not use is still billed. That asymmetry is why you commit to the trough, the lowest usage you are confident will still exist next year, not to the average.
Ask AWS for a recommendation, then commit to less than it suggests on the first purchase:
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 SIXTY_DAYSThen watch two numbers monthly: utilization (how much of the commitment you used) and coverage (how much eligible usage the commitment covered).
aws ce get-savings-plans-utilization --time-period Start=2026-09-01,End=2026-10-01
aws ce get-savings-plans-coverage --time-period Start=2026-09-01,End=2026-10-01 --granularity MONTHLYUtilization below full means you over-bought. Low coverage with high utilization means there is room to add another layer. Buy in layers over months rather than one large purchase, so plans expire at different times and you can adjust. Compute Savings Plans do not cover managed databases and caches. Those use their own Reserved Instances or, since December 2025, Database Savings Plans, a one-year commitment that applies across services such as RDS, Aurora, DynamoDB and ElastiCache. Check what applies to each service before buying.
5. Use Spot for interruptible work
Spot Instances use spare EC2 capacity at a steep discount to on-demand. The price moves gradually with supply and demand, and AWS can reclaim the instance with a two-minute interruption notice. There is no bidding anymore; you pay the current Spot price.
Spot fits queue workers, CI runners, batch and data processing, and stateless web tiers that keep an on-demand base. It does not fit databases, single-instance services or anything that cannot finish or checkpoint within two minutes.
The rules that keep Spot boring: diversify across many instance types and every Availability Zone, use the price-capacity-optimized allocation strategy, and turn on capacity rebalancing so the group replaces at-risk instances before they are interrupted.
resource "aws_autoscaling_group" "workers" {
name = "queue-workers"
min_size = 0
max_size = 40
vpc_zone_identifier = var.private_subnet_ids
capacity_rebalance = true
mixed_instances_policy {
instances_distribution {
on_demand_base_capacity = 2
on_demand_percentage_above_base_capacity = 0
spot_allocation_strategy = "price-capacity-optimized"
}
launch_template {
launch_template_specification {
launch_template_id = aws_launch_template.worker.id
version = "$Latest"
}
override { instance_type = "m7g.large" }
override { instance_type = "m6g.large" }
override { instance_type = "c7g.xlarge" }
override { instance_type = "r7g.large" }
}
}
}On Kubernetes, Karpenter does the same diversification at the node level and consolidates underused nodes. Make sure your pods handle SIGTERM and have a termination grace period that fits inside the notice.
6. Scale with demand, and stop non-production at night
Autoscaling saves money only if it scales in. Target tracking on a metric close to user experience (requests per target, queue depth per worker) both protects latency and lets capacity fall when traffic does. We cover scaling metrics in more depth in our startup scaling guide for AWS.
Non-production environments rarely need to run nights and weekends. A scheduled action is the simplest control:
resource "aws_autoscaling_schedule" "dev_night" {
scheduled_action_name = "dev-scale-to-zero"
autoscaling_group_name = aws_autoscaling_group.dev_api.name
min_size = 0
max_size = 0
desired_capacity = 0
recurrence = "0 20 * * 1-5"
time_zone = "Asia/Kolkata"
}
resource "aws_autoscaling_schedule" "dev_morning" {
scheduled_action_name = "dev-scale-up"
autoscaling_group_name = aws_autoscaling_group.dev_api.name
min_size = 1
max_size = 2
desired_capacity = 1
recurrence = "0 8 * * 1-5"
time_zone = "Asia/Kolkata"
}One trap: a stopped RDS instance starts itself again after seven days. For databases that should stay off, schedule the stop, or snapshot and delete.
7. Put storage on a lifecycle
Storage costs creep because nothing deletes data by default. Every bucket and data store needs a rule that says how long data lives and where it moves as it ages.
resource "aws_s3_bucket_lifecycle_configuration" "app_logs" {
bucket = aws_s3_bucket.app_logs.id
rule {
id = "tier-then-expire"
status = "Enabled"
filter {
prefix = "logs/"
}
transition {
days = 30
storage_class = "STANDARD_IA"
}
transition {
days = 90
storage_class = "GLACIER_IR"
}
expiration {
days = 400
}
noncurrent_version_expiration {
noncurrent_days = 30
}
abort_incomplete_multipart_upload {
days_after_initiation = 7
}
}
}The last two blocks catch the invisible costs: old versions in versioned buckets and abandoned multipart uploads, neither of which shows up when you list the bucket normally. S3 Storage Lens shows both across the organization.
Know the fine print before tiering. Infrequent Access and Glacier classes have minimum storage durations, so deleting or moving an object early still bills the minimum. Transitions are charged per request, which makes tiering millions of tiny objects a loss. When access patterns are unknown, S3 Intelligent-Tiering moves objects for you for a small per-object monitoring fee; objects under 128 KB are not monitored and stay in the frequent tier.
8. Fix data transfer in the network design
Data transfer is the line item nobody designs for. The common sources:
- NAT Gateway processing. Every gigabyte from private subnets to S3, DynamoDB, ECR or the internet through a NAT Gateway pays a processing charge on top of the hourly charge.
- Cross-AZ traffic. Traffic between Availability Zones is billed in both directions. A chatty service in one AZ talking to a cache in another pays for its whole conversation.
- Internet egress. Serving files and API responses straight from EC2 or S3 to users.
Fixes, in order of effort. Add gateway VPC endpoints for S3 and DynamoDB; they have no charge and take traffic off the NAT path:
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
}Add interface endpoints for high-volume services such as ECR and CloudWatch Logs when their traffic justifies the endpoint's own hourly cost. Put CloudFront in front of anything users download; data transfer from AWS origins to CloudFront is not charged, and cached responses never reach the origin. Keep chatty service pairs zone-aware where your reliability design allows it. VPC Flow Logs, queried by destination, tell you where the bytes actually go.
9. Right-size Kubernetes requests, not just nodes
On Kubernetes, the bill follows requests, not usage. A pod that requests two CPUs reserves two CPUs on a node whether it uses them or not, and the autoscaler adds nodes to fit requests. Teams set generous requests to avoid OOM kills and throttling, then never revisit them.
Run the Vertical Pod Autoscaler in recommendation-only mode to get numbers without changing anything:
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: api-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: api
updatePolicy:
updateMode: "Off"Compare its recommendations with current requests, lower requests in small steps, and watch throttling and restarts. OpenCost or Kubecost attribute cluster cost to namespaces and workloads, and Karpenter's consolidation removes nodes that requests no longer justify.
10. Choose the database pricing model on purpose
Databases are often the largest line item and the riskiest to change. Three decisions matter most:
- Aurora Standard versus I/O-Optimized. Standard bills I/O per request; I/O-Optimized charges more for instances and storage and nothing for I/O. If I/O is a large share of your Aurora bill, compare the two using your own Cost Explorer data. You can switch between them.
- Provisioned versus serverless capacity. Aurora Serverless v2 and DynamoDB on-demand fit spiky or unpredictable load. Steady, predictable load is usually cheaper on provisioned capacity with commitments.
- Read replicas and caching before vertical scaling. Moving read traffic to replicas or a cache is often cheaper than the next instance size up, and it adds headroom instead of moving the ceiling.
Guardrails that keep savings from eroding
Cost programs that last add guardrails, because savings start to erode the week after the cleanup ends. Three are enough to start:
- AWS Budgets with forecasted alerts per account or team, sent to the team's channel, not a finance inbox.
- Cost Anomaly Detection monitors per service and per cost allocation tag, so a runaway job is noticed in hours, not at month end.
- Cost estimates in pull requests. Tools like Infracost comment the cost change of a Terraform diff on the PR, which puts the decision in front of the engineer making it.
The Well-Architected cost pillar is a useful checklist for the rest; see our guide to running an AWS Well-Architected review.
What goes wrong in production
Cost work has a failure mode the finance spreadsheet does not show: the outage caused by the saving. These are the ones engineers on call learn to fear.
Removing redundancy to save money. Turning off Multi-AZ, dropping to one NAT Gateway, or pinning everything to one AZ to avoid cross-AZ charges. The saving is real and so is the outage when that AZ has a bad day. Make the trade-off explicit and get it signed off by whoever owns the uptime target.
Scale-in that is too aggressive. Short scale-in cooldowns and high utilization targets look efficient until a traffic spike arrives faster than new capacity can boot. Scale out fast, scale in slowly.
Spot on stateful work. A database, a single long-running job with no checkpointing, or a Kubernetes node pool with no on-demand capacity for system pods. The interruption notice is two minutes; design for it or stay off Spot.
Lifecycle rules that delete data someone needed. An expiration rule on the wrong prefix is a silent, irreversible deletion. Review lifecycle rules like code, and test them on a copy of the bucket layout first.
Commitments bought on a spike. A one-year Savings Plan sized during a launch month outlives the launch. If you buy during growth, buy less and add later.
Deleting "unattached" resources that were not orphans. A detached volume can be a deliberate copy, and a stopped instance can be a cold standby. The tag-then-wait step exists for this.
Frequently Asked Questions
What is the fastest way to reduce a cloud bill?
Delete idle resources first: unattached volumes, old snapshots, unused public IP addresses, idle load balancers and log groups that never expire. It needs no architecture change and no commitment. Then right-size what remains before you buy any Savings Plans or Reserved Instances.
Are Savings Plans better than Reserved Instances?
For EC2, Fargate and Lambda, Compute Savings Plans are usually the better default because the commitment is a dollar amount per hour that follows you across instance families and regions. Compute Savings Plans do not cover managed databases and caches; for those, compare Reserved Instances with Database Savings Plans. Check which commitment types apply to each service before buying.
How much compute should I commit to with Savings Plans?
Commit to the lowest steady usage you are confident will still exist for the whole term, not your average and never your peak. Unused commitment is billed anyway, while usage above the commitment simply falls back to on-demand rates. Start small, watch utilization for a month, and add more in layers.
Is it safe to run production on Spot Instances?
Yes, for stateless or retryable work that can lose an instance with a two-minute warning, such as queue workers, CI runners and stateless web tiers behind a load balancer with an on-demand base. Diversify across several instance types and Availability Zones. Keep databases and single-instance services off Spot.
Why is my NAT Gateway bill so high?
A NAT Gateway charges per hour and per gigabyte it processes, so heavy traffic from private subnets to S3, DynamoDB or the internet adds up quickly. Gateway VPC endpoints for S3 and DynamoDB carry that traffic without NAT processing charges. Container image pulls and log shipping through NAT are other common sources.
How often should we review cloud costs?
Look at anomaly alerts daily, review spend by team weekly during fast growth, and review commitments and architecture-level costs monthly or quarterly. The daily and weekly parts should be automated alerts, not meetings. The monthly review is where you decide what to change.
Need help cutting your cloud bill?
We cut cloud spend the way engineers who carry the pager do it: attribution first, no savings that trade away uptime, and every change in code. If we build it, we are on the pager for it, so we do not ship savings that come back as incidents.