Skip to main content

AWS Cost Reduction

How to reduce your AWS bill: 12 fixes with real thresholds.

Want to reduce your AWS bill? I built CostPatrol, a scanning engine with 128 optimization rules. Its five published scans on costpatrol.io show what it found in real accounts, finding by finding. Below are 12 common AWS money pits, with the exact thresholds, dollar amounts, and CLI commands to cut costs yourself.

If you would rather have me run it on your account, I offer a free 48-hour AWS diagnostic for teams already on AWS. The knowledge below is yours either way.

Free·48-hour delivery·NDA protected·Read-only access
128
Optimization Rules
Across 38+ AWS services
5
Published Scans
Every finding with its fix command
Days
Anomaly Detection
Not at month-end
$0
Free CostPatrol Scan
Under $10K/mo AWS spend

FROM COSTPATROL'S 128 RULES

The 12 biggest AWS money pits

The thresholds below are the ones CostPatrol's rules use. Prices are AWS list prices for us-east-1, checked on 12 October 2026.

1

Idle and oversized EC2 instances

EC2·Savings: 40-100%

The single biggest source of AWS waste. CostPatrol flags an EC2 instance as idle when its maximum CPU stays below 5% for 14 consecutive days. Oversized instances get flagged when average CPU is 5-20% and peak CPU never exceeds 50% over 14 days.

Idle instances should be stopped or terminated. Oversized instances should be downsized one step. An m5.xlarge at $0.192/hr downsized to m5.large at $0.096/hr saves $70/month. If the instance is completely idle, that is $140/month in instance hours going to zero.

The generation tax

Previous-generation instance types cost more than they look. If you are running m4, t2, c4, or r4 instances, the current generation (m6i, t3, c6i, r6i) is only 4-15% cheaper per hour. Most of the gain is performance per dollar, not the hourly price. Graviton (ARM) variants like m6g or t4g cost about 20% less again. A common upgrade path:

  • m4.large ($0.10/hr) to m6i.large ($0.096/hr) to m6g.large ($0.077/hr) = 23% total savings
  • t2.medium ($0.0464/hr) to t3.medium ($0.0416/hr) to t4g.medium ($0.0336/hr) = 28% total savings
  • r4.xlarge ($0.266/hr) to r6i.xlarge ($0.252/hr) to r6g.xlarge ($0.2016/hr) = 24% total savings

There is no performance penalty going from Intel to Graviton for most workloads. The exceptions are applications that depend on x86-specific instructions, certain commercial databases, or Windows. Everything else (Linux, containers, web servers, APIs) runs the same or faster.

Non-production scheduling

Development and staging instances that run 24/7 waste about 65% of their compute cost if nobody uses them outside business hours. An m5.large running 24/7 costs $70/month. Scheduled to run only 12 hours a day, 5 days a week, it costs about $25/month. CostPatrol spots non-production instances by their environment tag (env=dev, env=staging and similar) or their name, and flags the savings opportunity. AWS Instance Scheduler or a simple EventBridge rule with a Lambda function handles this.

Spot Instances for fault-tolerant workloads

Spot Instances cost up to 90% less than On-Demand. The tradeoff: AWS can reclaim them with 2 minutes notice. This is fine for batch processing, CI/CD runners, data pipelines, worker queues, and any workload that can handle interruption. An m5.large On-Demand at $0.096/hr costs ~$70/month. The same instance on Spot was $0.0397/hr on 12 October 2026, about $29/month. That is 59% less, and the Spot price moves with demand. Use Spot Fleet or EC2 Auto Scaling with mixed instance policies to maintain capacity across instance types and AZs.

Detailed monitoring: $2.10/month you probably do not need

EC2 Detailed Monitoring sends metrics at 1-minute intervals instead of the default 5-minute. It costs $2.10/month per instance. Unless you are actively debugging or running auto-scaling that needs 1-minute granularity, basic monitoring is enough. On 50 instances, that is $105/month for data nobody looks at.

Fix it yourself

# Find idle instances (max CPU < 5% over 14 days)
# GNU date (Linux, CloudShell). On macOS use: date -u -v-14d +%Y-%m-%dT%H:%M:%S
aws cloudwatch get-metric-statistics \
  --namespace AWS/EC2 \
  --metric-name CPUUtilization \
  --dimensions Name=InstanceId,Value=i-0abc123 \
  --start-time $(date -u -d '-14 days' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 3600 --statistics Maximum

# Stop idle instance
aws ec2 stop-instances --instance-ids i-0abc123

# Downsize: change instance type (stop first)
aws ec2 stop-instances --instance-ids i-0abc123
aws ec2 modify-instance-attribute \
  --instance-id i-0abc123 \
  --instance-type m5.large
aws ec2 start-instances --instance-ids i-0abc123

# Find all previous-generation instances
aws ec2 describe-instances \
  --query 'Reservations[].Instances[?starts_with(InstanceType, `m4`) || starts_with(InstanceType, `t2`) || starts_with(InstanceType, `c4`) || starts_with(InstanceType, `r4`)].{ID:InstanceId,Type:InstanceType,State:State.Name}' \
  --output table

# Disable detailed monitoring
aws ec2 unmonitor-instances --instance-ids i-0abc123
2

GP2 volumes, unattached storage, and snapshot bloat

EBS·Savings: 20-100%

Every GP2 volume on your account costs 25% more than it would on GP3. GP3 costs $0.08/GB vs GP2 at $0.10/GB, and GP3 includes a baseline of 3,000 IOPS and 125 MB/s throughput at no extra charge. GP2 has to earn its IOPS through volume size (3 IOPS per GB). A 1,000GB GP2 volume costs $100/month. The same volume on GP3 costs $80/month. Same 3,000 IOPS, $20/month saved per volume, zero downtime to migrate.

The migration is an online operation. You run one command, wait for the modification to complete (usually minutes), and the volume is now GP3. No reboot, no detach, no downtime. If you have 20 GP2 volumes averaging 200GB each, that is 20 x 200 x $0.02 = $80/month savings with zero effort. This is the single easiest win on any AWS account.

GP3 over-provisioned IOPS

GP3 gives you 3,000 IOPS and 125 MB/s baseline for free. Additional IOPS cost $0.005 per IOPS/month. If you provisioned extra IOPS when creating the volume but your workload never exceeds baseline, you are paying for nothing. CostPatrol flags gp3 volumes provisioned above that baseline when their p95 IOPS and throughput over 14 days stay under 30% of what you pay for. A volume with 6,000 IOPS provisioned but peaking at 1,500 wastes 3,000 x $0.005 = $15/month.

Unattached volumes

When you terminate an EC2 instance, EBS volumes can survive if "Delete on Termination" was not set. A 500GB GP3 volume sitting unattached costs $40/month doing nothing. Stopped EC2 instances still pay for their attached EBS volumes plus any Elastic IPs ($3.65/month each). A stopped instance with a 100GB gp3 volume and an EIP costs $11.65/month for a server doing nothing.

Snapshot bloat

Snapshot storage costs $0.05/GB/month. Old snapshots from AMIs you no longer use, dev environments that were deleted months ago, or manual snapshots someone took "just in case" quietly accumulate. Snapshots you only keep for disaster recovery can be archived at $0.0125/GB/month. That is 75% less per GB, but an archived snapshot is stored in full and billed for at least 90 days. Fast Snapshot Restore (FSR) is an even bigger hidden cost at $0.75 per hour for each snapshot in each AZ, $547.50/month per pair. If you enabled FSR during a migration and forgot to turn it off, that single setting could be costing more than the volume itself.

Low-I/O volumes on the wrong tier

Not every volume needs SSD performance. Volumes used for log storage, backups, or cold data that see minimal I/O can be moved from GP3 ($0.08/GB) to st1 ($0.045/GB) or sc1 ($0.015/GB). A 500GB log volume on GP3 costs $40/month. On sc1 it costs $7.50/month.

Fix it yourself

# Migrate GP2 to GP3 (zero downtime, online operation)
aws ec2 modify-volume --volume-id vol-0abc123 --volume-type gp3

# Find ALL GP2 volumes on the account
aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'Volumes[*].{ID:VolumeId,Size:Size,AZ:AvailabilityZone,State:State}' \
  --output table

# Find unattached volumes
aws ec2 describe-volumes \
  --filters Name=status,Values=available \
  --query 'Volumes[*].{ID:VolumeId,Size:Size,Type:VolumeType,Created:CreateTime}' \
  --output table

# Find snapshots older than 90 days
# GNU date (Linux, CloudShell). On macOS use: date -u -v-90d +%Y-%m-%d
CUTOFF=$(date -u -d '-90 days' +%Y-%m-%d)
aws ec2 describe-snapshots --owner-ids self \
  --query "Snapshots[?StartTime<'$CUTOFF'].{ID:SnapshotId,Size:VolumeSize,Created:StartTime}" \
  --output table

# Delete unattached volume
aws ec2 delete-volume --volume-id vol-0abc123

# Disable Fast Snapshot Restore
aws ec2 disable-fast-snapshot-restores \
  --availability-zones us-east-1a --source-snapshot-ids snap-0abc123
3

Database waste: idle replicas, Multi-AZ on dev, old generations

RDS·Savings: 15-100%

RDS waste comes in layers. The most common: Multi-AZ enabled on development and staging databases. Multi-AZ doubles your RDS cost for high availability. A db.m5.large MySQL instance in Multi-AZ costs $0.342/hr instead of $0.171/hr. That is $125/month extra for a database that nobody uses on weekends. CostPatrol detects this by checking each instance's environment tag or name against its Multi-AZ setting.

Idle databases and read replicas

CostPatrol flags a database as idle when it has zero connections for 14 days, or when max connections stay at 15 or fewer with average CPU under 15% and combined IOPS under 50. A single idle db.r5.large costs $175/month. Idle read replicas are the same: a replica of the same class costs as much as the primary, but nobody is reading from it. Three unused replicas of that size cost about $525/month for capacity nobody touches.

Previous-generation and Graviton migration

Previous-generation instance types (db.t2, db.m4, db.r3, db.r4) cost about the same per hour as the current Intel classes, and 5-15% more than Graviton. Most of the gain from upgrading is performance per dollar. The upgrade path (MySQL, per hour):

  • db.m4 to db.m6i: about 2% cheaper, newer hardware
  • db.m6i to db.m6g (Graviton): about 11% cheaper
  • db.r4 to db.r6g: about 10% cheaper

Graviton RDS instances support MySQL, PostgreSQL, and MariaDB. The migration requires a brief failover (seconds with Multi-AZ, minutes without). Plan it for a maintenance window.

Aurora Serverless v2 for variable workloads

If your Aurora cluster has highly variable load (busy during business hours, nearly idle at night), Aurora Serverless v2 auto-scales capacity in increments of 0.5 ACUs. A cluster that needs db.r5.large during peak but barely any capacity at night can cost less than a fixed provisioned instance running 24/7.

Backup retention and snapshots

RDS backup storage is free up to the total provisioned storage of your databases in a region. Past that, it costs $0.095/GB/month. Long retention on a busy database and manual snapshots older than 90 days can push you past the free amount. CostPatrol flags retention above 7 days and manual snapshots older than 90 days. Extended support charges are another hidden cost: databases running past end of standard support (MySQL 5.7, PostgreSQL 11) pay a per-vCPU charge that can more than double the effective cost of the instance.

Burstable instance credit exhaustion

Burstable RDS instances (db.t3, db.t4g) earn CPU credits when idle and spend them when busy. RDS runs them in Unlimited mode, so if your workload keeps CPU above the baseline, you pay for surplus credits ($0.075 per vCPU-hour on MySQL). CostPatrol detects this pattern. The fix is either moving to a standard instance (db.m6g) or right-sizing the burstable tier upward. A db.t3.small at full CPU around the clock costs about $0.154/hr with surplus credits, slightly more than a db.m6g.large ($0.152/hr) with four times the memory.

Fix it yourself

# Check for idle databases (zero connections over 14 days)
# GNU date (Linux, CloudShell). On macOS use: date -u -v-14d +%Y-%m-%dT%H:%M:%S
aws cloudwatch get-metric-statistics \
  --namespace AWS/RDS \
  --metric-name DatabaseConnections \
  --dimensions Name=DBInstanceIdentifier,Value=my-db \
  --start-time $(date -u -d '-14 days' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 3600 --statistics Maximum

# List all Multi-AZ instances (check if any are non-prod)
aws rds describe-db-instances \
  --query 'DBInstances[?MultiAZ==`true`].{ID:DBInstanceIdentifier,Class:DBInstanceClass,Engine:Engine}' \
  --output table

# Disable Multi-AZ on non-prod
aws rds modify-db-instance \
  --db-instance-identifier my-staging-db \
  --no-multi-az --apply-immediately

# Reduce backup retention to 7 days
aws rds modify-db-instance \
  --db-instance-identifier my-db \
  --backup-retention-period 7

# Find read replicas and check their connections
aws rds describe-db-instances \
  --query 'DBInstances[?ReadReplicaSourceDBInstanceIdentifier!=null].{Replica:DBInstanceIdentifier,Source:ReadReplicaSourceDBInstanceIdentifier,Class:DBInstanceClass}' \
  --output table
4

NAT Gateway data processing charges

NAT Gateway·Savings: $50-500/month

NAT Gateways charge $0.045/hour ($32.85/month) plus $0.045 per GB of data processed. The hourly cost is unavoidable if you need outbound internet from private subnets. But the data processing charge is where the real money goes, and most of it is unnecessary.

If your Lambda functions or ECS tasks in private subnets talk to S3 or DynamoDB, every byte routes through the NAT Gateway and incurs the $0.045/GB processing fee. S3 Gateway Endpoints and DynamoDB Gateway Endpoints are free and carry same-region traffic straight to the service. No NAT needed, no data processing charge, lower latency.

A Lambda function hitting S3 1,000 times per day at 1MB per request = 30GB/month through NAT = $1.35/month in data processing alone. Scale that to 100 functions or larger payloads and you are looking at $50-500/month that vanishes the moment you add a VPC endpoint. In one published CostPatrol scan, a single NAT Gateway processed 4,965 GB of S3 and DynamoDB traffic in a month. At $0.045/GB, that is $223/month in processing charges alone.

Fix it yourself

# Create S3 Gateway Endpoint (free, immediate effect)
aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc123 \
  --service-name com.amazonaws.us-east-1.s3 \
  --route-table-ids rtb-0abc123

# Create DynamoDB Gateway Endpoint (also free)
aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc123 \
  --service-name com.amazonaws.us-east-1.dynamodb \
  --route-table-ids rtb-0abc123
5

Over-allocated Lambda memory

Lambda·Savings: 20-50%

Lambda pricing is per GB-second. A function allocated 1,024MB that only uses 400MB is paying 2.5x more than necessary. CostPatrol reads 30 days of REPORT lines in CloudWatch Logs and flags functions whose peak memory stays under half the allocation. It recommends p95 usage plus 30%, or the peak plus a safety margin if that is higher, rounded up to the next MB.

Example: 1,024MB allocated, 400MB used at p95 and at peak. Recommended: 400 x 1.3 = 520MB. At 1 million invocations/month with 100ms average duration, the duration cost drops from $1.67/month to $0.85/month. One function, about half the cost. Multiply across dozens of functions and it adds up.

ARM64 (Graviton) migration: 20% cheaper

ARM64 Lambda duration costs $0.0000133334/GB-second vs $0.0000166667/GB-second for x86 (first pricing tier). That is exactly 20% less. Requests cost the same on both. For compute-bound functions (image processing, data transformation, PDF generation), Graviton often runs faster too. If your function does not depend on x86-specific binaries or native extensions, switching the architecture is a one-line config change. Python, Node.js, and Java functions almost always work on ARM64 without modification.

Unused functions and provisioned concurrency waste

CostPatrol flags functions with zero invocations over 30 days. The cost of an unused function is minimal (just storage), but provisioned concurrency on unused functions is not. Provisioned concurrency on a 1GB function costs $0.015/hour per unit, about $10.95/month. If you provisioned 10 units for a function that now handles 2 concurrent executions, you are wasting $10.95 x 8 = $87.60/month. Long timeouts are another smell: a function with 15-minute timeout that averages 3 seconds of execution is not costing you directly, but it can cause cascading failures that increase retry costs.

Fix it yourself

# Check actual memory usage (from CloudWatch Logs)
# GNU date (Linux, CloudShell). On macOS use: date -u -v-30d +%s
aws logs filter-log-events \
  --log-group-name /aws/lambda/my-function \
  --filter-pattern "REPORT" \
  --start-time $(date -u -d '-30 days' +%s)000 \
  --query 'events[*].message' | grep "Max Memory Used"

# Update memory (takes effect on next cold start)
aws lambda update-function-configuration \
  --function-name my-function \
  --memory-size 512

# Switch to ARM64 (20% cheaper)
aws lambda update-function-configuration \
  --function-name my-function \
  --architectures arm64
6

Over-provisioned tables and unused GSIs

DynamoDB·Savings: 30-70%

DynamoDB tables in provisioned mode often have capacity set far above actual usage. CostPatrol compares peak consumed capacity with provisioned capacity over 30 days. If the peak stays under 20% of what you provisioned, you are paying for throughput you do not use. The fix is setting provisioned capacity to your actual peak plus 50% headroom.

Example: a table provisioned at 1,000 RCU and 1,000 WCU but peaking at 150 RCU and 150 WCU. Peak plus 50% is 225 RCU and 225 WCU. That single table change saves about $440/month.

On-demand vs provisioned: when to switch

The inverse applies too. On-demand tables with steady, predictable traffic usually cost more than provisioned. CostPatrol measures the coefficient of variation of your traffic over 30 days. If it is below 0.3 (meaning traffic is steady, not spiky), provisioned mode is usually cheaper. On-demand pricing, halved on 1 November 2024: $0.625 per million write request units, $0.125 per million read request units. Provisioned: $0.00065/hr per WCU, $0.00013/hr per RCU. A table doing a steady 50 writes per second (131.4 million a month) costs about $82/month on on-demand. Provisioned at 50 WCU (50 x $0.00065 x 730 hours), it costs about $24/month, or about $36 with 50% headroom. That gap adds up across 20 tables.

Unused GSIs, PITR on non-prod, and missing TTL

Unused Global Secondary Indexes are a quiet cost. A GSI with 100 provisioned RCU that nobody queries wastes ~$9/month. Multiply by 10 tables with 3 GSIs each. Point-in-Time Recovery (PITR) on development tables costs $0.20/GB/month on top of $0.25/GB storage, for a backup feature nobody will use on throwaway data. Tables storing time-series data without TTL keep growing forever. Adding TTL to expire records older than 90 days stops that growth. Standard-IA table class cuts storage costs by 60% ($0.10/GB vs $0.25/GB) for tables with mostly cold data.

Fix it yourself

# Check consumed vs provisioned capacity (30 days, hourly)
# Divide each hourly Sum by 3600 to get RCU per second
# GNU date (Linux, CloudShell). On macOS use: date -u -v-30d +%Y-%m-%dT%H:%M:%S
aws cloudwatch get-metric-statistics \
  --namespace AWS/DynamoDB \
  --metric-name ConsumedReadCapacityUnits \
  --dimensions Name=TableName,Value=my-table \
  --start-time $(date -u -d '-30 days' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 3600 --statistics Sum

# Right-size provisioned capacity (peak plus 50%)
aws dynamodb update-table \
  --table-name my-table \
  --provisioned-throughput \
    ReadCapacityUnits=225,WriteCapacityUnits=225
7

Buckets without lifecycle rules

S3·Savings: 40-70%

S3 Standard costs $0.023/GB/month. Infrequent Access costs $0.0125/GB. Glacier Instant Retrieval costs $0.004/GB. Glacier Deep Archive costs $0.00099/GB. If your bucket has data older than 30 days that is rarely accessed, you are paying 2-23x more than necessary.

CostPatrol flags any bucket over 100GB without lifecycle rules. A 1TB bucket on Standard costs $23/month. Add a lifecycle rule to transition to Intelligent-Tiering at 30 days and Glacier at 90 days, and that same bucket costs $6-9/month depending on access patterns.

Incomplete multipart uploads are invisible waste. When a large upload fails partway through, the parts remain in the bucket consuming storage but invisible in the console. An abort rule for incomplete uploads older than 7 days fixes this permanently.

Fix it yourself

# Add lifecycle rule (transition + cleanup)
aws s3api put-bucket-lifecycle-configuration \
  --bucket my-bucket \
  --lifecycle-configuration '{
    "Rules": [
      {
        "ID": "TransitionToIA",
        "Status": "Enabled",
        "Filter": {},
        "Transitions": [
          {"Days": 30, "StorageClass": "INTELLIGENT_TIERING"},
          {"Days": 90, "StorageClass": "GLACIER"}
        ]
      },
      {
        "ID": "AbortIncompleteUploads",
        "Status": "Enabled",
        "Filter": {},
        "AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}
      }
    ]
  }'
8

Log retention, Metric Streams, and dual-shipping

CloudWatch·Savings: $50-7,000/month

CloudWatch Logs storage costs $0.03/GB/month. Most log groups default to "never expire," meaning every log line your application has ever written is sitting in CloudWatch costing money. A 1TB log group with infinite retention costs $30/month for storage alone, on top of the $0.50/GB ingestion charge. Setting retention to 30 days means you stop paying to store anything older.

Metric Streams are the big one. An unfiltered Metric Stream ships every CloudWatch metric to a destination (Datadog, New Relic, Splunk) at $0.003 per 1,000 metric updates. Across multiple accounts and regions, this quietly reaches thousands per month. In my own production work, unfiltered Metric Streams pushing every metric to an APM tool across 4 regions cost $6,900/month, and nobody had touched the config since setup. The fix is namespace filters, so you only ship the metrics you actually dashboard.

Lambda dual-write logging is another pattern: CloudWatch Logs collects Lambda output by default, and then your APM tool also collects it. You are paying for ingestion twice. Either disable CloudWatch log collection for those functions or stop collecting from the APM side.

Custom metrics audit

Custom CloudWatch metrics cost $0.30/metric/month for the first 10,000. A namespace with 500 custom metrics costs $150/month. CostPatrol flags namespaces with 100 or more custom metrics for review. Often teams publish metrics during development that are never used in production dashboards or alarms. EC2 Detailed Monitoring at 1-minute intervals adds $2.10/instance/month. Unless you are running auto-scaling that needs 1-minute data, the default 5-minute interval is enough.

Orphaned alarms and duplicate trails

CloudWatch alarms stuck in INSUFFICIENT_DATA usually watch a resource that no longer exists, and each still costs $0.10/month. Not huge individually, but accounts with hundreds of auto-created alarms from old Auto Scaling groups or CodeDeploy deployments accumulate $20-50/month in alarm costs. CloudTrail trails are another one: the first copy of management events in each region is free, and every extra trail logging the same events pays $2.00 per 100,000 events plus its own S3 storage. Consolidate to a single organization-level trail.

Fix it yourself

# Set log retention to 30 days
aws logs put-retention-policy \
  --log-group-name /aws/lambda/my-function \
  --retention-in-days 30

# List all log groups with no expiry (infinite retention)
aws logs describe-log-groups \
  --query 'logGroups[?!retentionInDays].{Name:logGroupName,StoredBytes:storedBytes}' \
  --output table

# Find orphaned alarms (INSUFFICIENT_DATA = likely orphaned)
aws cloudwatch describe-alarms \
  --state-value INSUFFICIENT_DATA \
  --query 'MetricAlarms[*].{Name:AlarmName,Metric:MetricName,Namespace:Namespace}' \
  --output table

# List CloudTrail trails (check for duplicates)
aws cloudtrail describe-trails \
  --query 'trailList[*].{Name:Name,S3Bucket:S3BucketName,IsOrg:IsOrganizationTrail}' \
  --output table
9

Container overprovisioning and EKS extended support

ECS / EKS·Savings: 20-80%

Fargate tasks are billed per vCPU-hour ($0.04048) and per GB-hour ($0.004445). If your task definition allocates 2 vCPU and 4GB but the container uses 0.5 vCPU and 1GB, you are paying 4x more than necessary. CostPatrol checks ECS service CPU and memory utilization over 7 days and flags services where average CPU and memory both stay below 30%.

Fargate ARM64 migration

ARM64 (Graviton) Fargate tasks are 20% cheaper than x86 at the same specs. A fleet of 100 tasks running 2 vCPU + 4GB around the clock (730 hours) costs about $7,208/month on x86. On ARM64 ($0.03238 per vCPU-hour, $0.00356 per GB-hour), that same fleet costs about $5,767/month. Savings: $1,441/month. The migration is a one-line change in your task definition (runtimePlatform cpuArchitecture to ARM64), provided your container images support ARM. Multi-arch builds with Docker Buildx handle this automatically.

Fargate ephemeral storage

Fargate gives you 20GB of ephemeral storage for free. Anything above that costs $0.000111/GB-hour ($0.081/GB-month). If you allocated 100GB of ephemeral storage but only use 30GB, right-sizing to 50GB still leaves 20GB of headroom and cuts 50GB at $4.05/month per task. Across 50 tasks, that is $202.50/month.

EKS extended support penalty

This one is expensive and easy to miss. EKS clusters running Kubernetes versions that have entered extended support pay 6x the control plane cost. Standard: $0.10/hour = $73/month. Extended support: $0.60/hour = $438/month. That is $365/month extra per cluster just for not upgrading Kubernetes. If you have 3 clusters on old versions, that is $1,095/month in unnecessary fees. CostPatrol flags any cluster running a version in extended support.

Idle EKS clusters

An EKS cluster with zero nodegroups or all nodegroups scaled to 0 desired capacity still costs $73/month for the control plane. Dev and test clusters that nobody has used in weeks should be deleted. If you need to recreate them, infrastructure as code (CDK, Terraform) makes that a 10-minute operation.

Fix it yourself

# Check ECS service CPU/memory utilization (7 days)
# GNU date (Linux, CloudShell). On macOS use: date -u -v-7d +%Y-%m-%dT%H:%M:%S
aws cloudwatch get-metric-statistics \
  --namespace AWS/ECS \
  --metric-name CPUUtilization \
  --dimensions Name=ClusterName,Value=my-cluster Name=ServiceName,Value=my-service \
  --start-time $(date -u -d '-7 days' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 3600 --statistics Average Maximum

# List EKS clusters and their versions
aws eks list-clusters --query 'clusters' --output text | \
  xargs -I{} aws eks describe-cluster --name {} \
  --query 'cluster.{Name:name,Version:version,Status:status}' --output table

# Check for idle nodegroups (0 desired)
aws eks list-nodegroups --cluster-name my-cluster --output text | \
  xargs -I{} aws eks describe-nodegroup --cluster-name my-cluster \
  --nodegroup-name {} --query 'nodegroup.{Name:nodegroupName,Desired:scalingConfig.desiredSize}'
10

Idle caches and missed Graviton migrations

ElastiCache / MemoryDB·Savings: 5-100%

ElastiCache clusters that nobody connects to are surprisingly common. A team provisions Redis for a feature, the feature gets descoped, but the cluster stays. CostPatrol flags clusters with zero connections over 14 days. A single cache.r5.large node costs $158/month. An idle 3-node cluster with replicas wastes $473/month.

Graviton migration

ElastiCache Graviton nodes (m6g, r6g) cost about 5% less per hour than their Intel equivalents, with equal or better performance. cache.m5.large at $0.156/hr becomes cache.m6g.large at $0.149/hr. Over a 3-node cluster, that saves $15/month. The newer r7g and m7g nodes cost slightly more per hour than r5 and m5, so check the rate first. The migration requires a brief failover but can be done during a maintenance window.

Valkey migration (20% cheaper than Redis)

ElastiCache now supports Valkey, an open-source Redis-compatible engine that is 20% cheaper than Redis on the same instance types. If your application uses standard Redis commands without Redis-specific modules, the migration is usually simple. CostPatrol flags Redis clusters that are eligible for Valkey migration.

Kinesis shard overprovisioning

Kinesis Data Streams charge $10.95/shard/month in provisioned mode. If you provisioned 10 shards to handle a launch spike but your stream now averages 3 shards of throughput, you are wasting $76.65/month. Use Kinesis on-demand mode for variable workloads, or right-size your provisioned shards to match actual peak throughput plus a buffer.

Fix it yourself

# Find ElastiCache clusters with zero connections
# GNU date (Linux, CloudShell). On macOS use: date -u -v-14d +%Y-%m-%dT%H:%M:%S
aws cloudwatch get-metric-statistics \
  --namespace AWS/ElastiCache \
  --metric-name CurrConnections \
  --dimensions Name=CacheClusterId,Value=my-cluster-001 \
  --start-time $(date -u -d '-14 days' +%Y-%m-%dT%H:%M:%S) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
  --period 86400 --statistics Maximum

# List all ElastiCache clusters with instance types
aws elasticache describe-cache-clusters \
  --query 'CacheClusters[*].{ID:CacheClusterId,Type:CacheNodeType,Engine:Engine,Nodes:NumCacheNodes}' \
  --output table
11

Public IPv4 charges, orphaned EIPs, idle VPNs

Networking·Savings: $5-500/month

As of February 2024, AWS charges $0.005/hour ($3.65/month) for every public IPv4 address, even those assigned to running EC2 instances. This was previously free. An account with 50 public IPs now pays $182.50/month just for IP addresses. CostPatrol audits three patterns: instances in private subnets that have unnecessary public IPs, instances with both an Elastic IP AND an auto-assigned public IP (double-paying), and Elastic IPs on stopped instances.

Orphaned Elastic IPs

Elastic IPs not associated with a running instance cost $3.65/month each. After you terminate an instance, the EIP survives and keeps billing. Five to 20 forgotten EIPs cost $18-73/month in pure waste.

Idle Site-to-Site VPNs and Transit Gateways

A Site-to-Site VPN connection costs $0.05/hour ($36.50/month) even if zero bytes flow through it. VPNs set up for a temporary migration or a partner integration that ended months ago keep billing. Transit Gateways cost $0.05/attachment/hour ($36.50/month per attachment). A TGW with 0 or 1 attachments is effectively unused and should be removed.

AWS Config in unused regions

AWS Config charges $0.003 per configuration item recorded. If Config is enabled in every region but you only deploy to 3, you are paying for configuration recording in regions you do not use. The cost is small per region but adds up across accounts in an AWS Organization. Disable Config in regions where you have no resources.

Fix it yourself

# Find orphaned Elastic IPs (not associated with anything)
aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==null].{IP:PublicIp,AllocationId:AllocationId}' \
  --output table

# Release orphaned EIP
aws ec2 release-address --allocation-id eipalloc-0abc123

# Find instances with unnecessary public IPs
aws ec2 describe-instances \
  --query 'Reservations[].Instances[?PublicIpAddress!=null].{ID:InstanceId,PublicIP:PublicIpAddress,SubnetId:SubnetId,State:State.Name}' \
  --output table

# Check for idle VPN connections
aws ec2 describe-vpn-connections \
  --query 'VpnConnections[*].{ID:VpnConnectionId,State:State,TransitGW:TransitGatewayId}' \
  --output table
12

Reserved Instances and Savings Plans: the 20-72% discount layer

Purchasing·Savings: 20-72%

Everything above is about eliminating waste. This section is about paying less for what you actually use. AWS offers two commitment-based discount mechanisms: Reserved Instances (RIs) and Savings Plans. Both trade a usage commitment for a lower rate. The discount runs from about 20-30% for a 1-year no-upfront Compute Savings Plan on most instance types to up to 72% for 3-year all-upfront.

Savings Plans vs Reserved Instances

Compute Savings Plans are the most flexible. You commit to a dollar amount per hour of compute usage (e.g., $10/hour). That commitment applies to any EC2 instance type, any region, any operating system, Lambda, and Fargate. If you restructure your infrastructure, your Savings Plan still applies. Start here.

EC2 Instance Savings Plans give a bigger discount (up to 72%) but lock you to a specific instance family in a specific region (e.g., m5 in us-east-1). You can still change the size (m5.large to m5.xlarge) and the OS. Use these for workloads you know will not move.

Reserved Instances are the oldest mechanism. Standard RIs lock you to a specific instance type, region, and tenancy. Convertible RIs let you exchange but at a lower discount. In most cases, Savings Plans are better because they offer similar discounts with more flexibility. Databases are the exception. Database Savings Plans, launched in December 2025, cover RDS, Aurora, DynamoDB, ElastiCache and more, but only generation 7 and newer instances, Valkey on ElastiCache, and a 1-year term. RDS classes before generation 7 (like db.r6g) and Redis OSS still need Reserved Instances or reserved nodes for a discount.

How to buy without risk

Start with the lowest-risk commitment: 1-year no-upfront Compute Savings Plan. You pay nothing upfront and can let it expire after 12 months. Cover only your stable baseline, the minimum compute you know you will use every month. AWS Cost Explorer has a recommendations page that analyzes your usage and suggests the optimal commitment level.

Once you are comfortable, layer in EC2 Instance Savings Plans or RDS Reserved Instances for specific known workloads. Never commit 3-year all-upfront until you have at least 6 months of stable usage data. The higher discount is not worth it if your architecture changes.

Waste in existing commitments

CostPatrol also checks for underutilized commitments: Reserved Instances and Savings Plans below 80% utilization over 30 days. Below that line, you are paying for discounts you do not use. The fix is either exchanging Convertible RIs to match current usage or selling unused Standard RIs on the AWS Reserved Instance Marketplace. Savings Plans cannot be resold, so right-sizing the commitment upfront is critical.

Fix it yourself

# Get Savings Plans recommendations from Cost Explorer
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_DAYS

# Check current RI utilization
# GNU date (Linux, CloudShell). On macOS use: date -u -v-30d +%Y-%m-%d
aws ce get-reservation-utilization \
  --time-period Start=$(date -u -d '-30 days' +%Y-%m-%d),End=$(date -u +%Y-%m-%d) \
  --query 'UtilizationsByTime[0].Total'

# Check current Savings Plans utilization
aws ce get-savings-plans-utilization \
  --time-period Start=$(date -u -d '-30 days' +%Y-%m-%d),End=$(date -u +%Y-%m-%d)

DIY GUIDE

How to audit your AWS bill in an afternoon

Five steps. No tools required beyond the AWS CLI and Cost Explorer.

1

Enable Cost Explorer and tag everything

Turn on Cost Explorer daily views. Enforce cost allocation tags: service, env, owner, team. Without tags, you cannot attribute spend to specific teams or workloads. This takes 30 minutes and is the foundation of everything else.

2

Set up AWS Budgets with alerts

Create a budget for your total account spend with alerts at 80% and 100%. Then create per-service budgets for your top 3-5 cost drivers. This catches runaway spend within a day instead of at month-end.

3

Work through the 12 sections above

Start with EBS (easiest, zero-downtime GP2 to GP3 migration). Then EC2 idle/oversized. Then NAT Gateway endpoints. Then RDS Multi-AZ on dev. Then CloudWatch log retention. Work from low-risk to high-risk.

4

Check purchasing commitments

Open Cost Explorer > Recommendations. AWS will show Reserved Instance and Savings Plans recommendations based on your usage. If you have stable baseline compute, a 1-year no-upfront Compute Savings Plan saves about 20-30%, with nothing paid upfront and a 1-year commitment.

5

Schedule a monthly review

AWS spend drifts. A developer spins up a test cluster and forgets about it. A new service launches with default configs. Schedule 1 hour/month to review Cost Explorer for anomalies and re-run the checks above. Or automate it.

OR AUTOMATE ALL OF IT

CostPatrol can run these 128 rules on your account every day

Scans EC2, RDS, Lambda, DynamoDB, S3, EBS, NAT Gateway, CloudWatch and more, across 38+ AWS services
Posts specific fix commands with dollar amounts to Slack every morning
Flags cost anomalies against a same-weekday baseline built from your own history
2-minute setup via CloudFormation: one read-only IAM role
Five published scans on costpatrol.io, every finding itemized

AWS Cost Optimization FAQ

Common questions about reducing your AWS bill.

MSCLOUDTECH can reduce your AWS bill by running CostPatrol, the scanning engine I built, against your own account. It carries 128 rules across 38+ AWS services, and every finding arrives with the threshold that triggered it, the dollar amount, and the CLI command that fixes it. The zero-downtime items go first: EBS GP2 to GP3, S3 lifecycle rules, and CloudWatch log retention. Findings from real accounts are published on costpatrol.io, so you can read the work before you talk to anyone.

Still have questions? Book a call

Want me to run the full 128-rule scan on your account?

Free 48-hour AWS diagnostic. I send a prioritized report of what I find, with exact dollar amounts and fix commands.

Zero commitment·48-hour delivery·NDA protected

Let's talk about what you're building.

Start with a free 48-hour AWS diagnostic. I review your setup, find the bottlenecks, and walk you through a prioritized 30-day plan.

Free 48-Hour AWS Diagnostic

Complete infrastructure analysis

Prioritized Plan

30-day implementation plan

Expert Insights

Senior engineer recommendations

I reply within one business day. No spam. You talk to the engineer who writes the code.

No Obligation
48-Hour Delivery
Expert Analysis
1,441 commits across 50 services in one client's production codebase. Self-measured 2026-08-21.