How to Reduce AWS Costs: Step-by-Step Checklist With CLI (2026)
This guide is for engineers who own an AWS account, or an organisation's management account, and have been asked to cut the bill without breaking anything. By the end you will have a cost breakdown by usage type, a CUR 2.0 export you can query in Athena, recommendations from Compute Optimizer and Cost Optimization Hub, a reviewed list of idle volumes, snapshots, IPs and load balancers, free VPC endpoints for S3 and DynamoDB, lifecycle and retention rules, and alerts that fire before the invoice does. Commands target AWS CLI v2 (the current release is 2.39.3; I checked flags against the CLI's help output and the current command reference) and use us-east-1 list prices from the AWS Price List as of October 2026. You need credentials that can read billing data (Cost Explorer, Data Exports, Cost Optimization Hub, Compute Optimizer) and manage EC2, ELB, S3, Glue and CloudWatch Logs, plus jq. Why each line item grows is explained in the companion post, why is my AWS bill so high.
Step 1: Break down the bill in Cost Explorer
Start with the question "which meter moved?", not "which service is big?". Cost Explorer groups EBS, snapshots, Elastic IPs, NAT gateways and data transfer under a service called EC2-Other, so group by usage type to see the actual meters. The console is enough for a first look, but the CLI is repeatable. Each Cost Explorer API request is billed at $0.01, so these three calls cost three cents.
# Run against the management (payer) account. AWS CLI v2, jq (tested with 1.7.1).
export AWS_PAGER=""
START=2026-09-01; END=2026-10-01 # last full month; End is exclusive
# 1a. Top 10 services by unblended cost
aws ce get-cost-and-usage \
--time-period Start=$START,End=$END \
--granularity MONTHLY --metrics UnblendedCost \
--group-by Type=DIMENSION,Key=SERVICE \
--output json \
| jq -r '.ResultsByTime[0].Groups[] | [.Metrics.UnblendedCost.Amount, .Keys[0]] | @tsv' \
| sort -rn | head -10
# 1b. Open up "EC2 - Other" by usage type
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 \
--output json \
| jq -r '.ResultsByTime[0].Groups[] | [.Metrics.UnblendedCost.Amount, .Keys[0]] | @tsv' \
| sort -rn | head -15
Each output line is an amount in USD followed by a service or usage type name. In us-east-1 most usage types have no region prefix (NatGateway-Bytes, DataTransfer-Regional-Bytes, EBS:VolumeUsage.gp2, EBS:SnapshotUsage); in other regions they start with a code such as USW2-.
To see what changed between two months, use the cost comparison APIs AWS added to Cost Explorer in May 2025. The CLI needs version 2.27.23 or newer; periods must start on the first of a month and span exactly one month.
# 1c. Which dimensions drove the change from August to September?
aws ce get-cost-comparison-drivers \
--baseline-time-period Start=2026-08-01,End=2026-09-01 \
--comparison-time-period Start=2026-09-01,End=2026-10-01 \
--metric-for-comparison UnblendedCost \
--output json | jq '.CostComparisonDrivers[:5]'
Write down the top five usage types and their monthly cost. Every later step should move one of those numbers.
Step 2: Export CUR 2.0 and query it with Athena
Cost Explorer stops at totals. The Cost and Usage Report 2.0, delivered through AWS Data Exports, gives you every line item with resource IDs, in a fixed schema. The export itself has no charge; you pay standard S3 rates to store it and Athena for the data each query scans. First, a bucket with the policy Data Exports requires:
# 2a. Bucket plus the bucket policy from the AWS Data Exports user guide
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
BUCKET="cur-${ACCOUNT_ID}-exports"
aws s3api create-bucket --bucket "$BUCKET" --region us-east-1
cat > cur-bucket-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "EnableAWSDataExportsToWriteToS3",
"Effect": "Allow",
"Principal": {"Service": ["bcm-data-exports.amazonaws.com"]},
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::${BUCKET}/*",
"Condition": {
"ArnLike": {"aws:SourceArn": "arn:aws:bcm-data-exports:us-east-1:${ACCOUNT_ID}:export/*"},
"StringEquals": {"aws:SourceAccount": "${ACCOUNT_ID}"}
}
}]
}
EOF
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file://cur-bucket-policy.json
The export definition is a SQL column selection plus table configurations. INCLUDE_RESOURCES adds line_item_resource_id, which is what lets you name the NAT gateway or volume behind a cost. Column names below are from the CUR 2.0 line item and product dictionaries.
# 2b. Daily CUR 2.0 with resource IDs, Parquet, overwritten in place each refresh
cat > cur2-export.json <<EOF
{
"Name": "cur2-daily",
"DataQuery": {
"QueryStatement": "SELECT bill_billing_period_start_date, line_item_usage_account_id, line_item_line_item_type, line_item_usage_start_date, line_item_product_code, line_item_usage_type, line_item_operation, line_item_resource_id, line_item_availability_zone, line_item_usage_amount, line_item_unblended_cost, product_region_code, resource_tags FROM COST_AND_USAGE_REPORT",
"TableConfigurations": {
"COST_AND_USAGE_REPORT": {
"TIME_GRANULARITY": "DAILY",
"INCLUDE_RESOURCES": "TRUE",
"INCLUDE_SPLIT_COST_ALLOCATION_DATA": "FALSE",
"INCLUDE_MANUAL_DISCOUNT_COMPATIBILITY": "FALSE"
}
}
},
"DestinationConfigurations": {
"S3Destination": {
"S3Bucket": "${BUCKET}",
"S3Prefix": "cur2",
"S3Region": "us-east-1",
"S3OutputConfigurations": {
"OutputType": "CUSTOM",
"Format": "PARQUET",
"Compression": "PARQUET",
"Overwrite": "OVERWRITE_REPORT"
}
}
},
"RefreshCadence": {"Frequency": "SYNCHRONOUS"}
}
EOF
aws bcm-data-exports create-export --region us-east-1 --export file://cur2-export.json
# Expected: {"ExportArn": "arn:aws:bcm-data-exports:us-east-1:<account-id>:export/cur2-daily-..."}
First delivery can take up to a day. When Parquet files appear under s3://<bucket>/cur2/cur2-daily/data/BILLING_PERIOD=2026-10/, catalogue them with a Glue crawler, which is the path the Data Exports processing guide documents:
# 2c. The role needs the AWSGlueServiceRole managed policy plus read access to the bucket.
aws glue create-database --database-input Name=cur2
aws glue create-crawler --name cur2-daily \
--role AWSGlueServiceRole-cur2 \
--database-name cur2 \
--targets "{\"S3Targets\":[{\"Path\":\"s3://${BUCKET}/cur2/cur2-daily/data/\"}]}"
aws glue start-crawler --name cur2-daily # rerun, or schedule, when a new month appears
aws glue get-tables --database-name cur2 --query 'TableList[].Name' # usually ["data"]
Then, in the Athena console, two queries do most of the work. The first ranks usage types; the second buckets the usual hidden charges. LIKE patterns with a leading % match both the unprefixed us-east-1 names and prefixed names elsewhere.
-- 2d. Top usage types last month (billing_period is the partition the crawler creates)
SELECT line_item_product_code,
line_item_usage_type,
ROUND(SUM(line_item_unblended_cost), 2) AS cost_usd
FROM cur2.data
WHERE billing_period = '2026-09'
AND line_item_line_item_type = 'Usage'
GROUP BY 1, 2
ORDER BY cost_usd DESC
LIMIT 25;
-- 2e. The usual hidden charges, bucketed
SELECT CASE
WHEN line_item_usage_type LIKE '%NatGateway-Bytes' THEN 'NAT processing'
WHEN line_item_usage_type LIKE '%NatGateway-Hours' THEN 'NAT hours'
WHEN line_item_usage_type LIKE '%DataTransfer-Regional-Bytes' THEN 'Cross-AZ transfer'
WHEN line_item_usage_type LIKE '%PublicIPv4%' THEN 'Public IPv4'
WHEN line_item_usage_type LIKE '%EBS:SnapshotUsage%' THEN 'EBS snapshots'
WHEN line_item_usage_type LIKE '%EBS:VolumeUsage%' THEN 'EBS volumes'
WHEN line_item_product_code = 'AmazonCloudWatch'
AND (line_item_usage_type LIKE '%DataProcessing%'
OR line_item_usage_type LIKE '%VendedLog%') THEN 'CloudWatch Logs ingest'
WHEN lower(line_item_usage_type) LIKE '%extendedsupport%' THEN 'EKS or RDS extended support'
ELSE 'everything else'
END AS bucket,
ROUND(SUM(line_item_unblended_cost), 2) AS cost_usd
FROM cur2.data
WHERE billing_period = '2026-09'
GROUP BY 1
ORDER BY cost_usd DESC;
-- 2f. Which NAT gateways process the most data
SELECT line_item_resource_id,
ROUND(SUM(line_item_usage_amount)) AS gb,
ROUND(SUM(line_item_unblended_cost), 2) AS cost_usd
FROM cur2.data
WHERE billing_period = '2026-09'
AND line_item_usage_type LIKE '%NatGateway-Bytes'
GROUP BY 1
ORDER BY cost_usd DESC
LIMIT 10;
The extended support bucket catches both EKS (USE1-AmazonEKS-Hours:extendedSupport) and RDS (ExtendedSupport:Yr1-Yr2:MySQL8.0 and similar), which is why the match is case-insensitive.
Step 3: Turn on Compute Optimizer and Cost Optimization Hub
Compute Optimizer is free at its default 14-day lookback and produces rightsizing and idle findings for EC2, Auto Scaling groups, EBS, Lambda, ECS on Fargate, RDS, Aurora, NAT gateways and more. Cost Optimization Hub imports those findings plus Savings Plans and RI recommendations, prices them with your discounts and de-duplicates overlapping ones, so a rightsizing and a commitment on the same instance are not counted twice. It stores recommendations in us-east-1 and can take up to 24 hours to fill after you opt in.
# 3a. Opt in once. From a management account, include member accounts.
aws compute-optimizer update-enrollment-status --status Active --include-member-accounts
aws cost-optimization-hub update-enrollment-status --status Active --include-member-accounts --region us-east-1
aws cost-optimization-hub update-preferences --savings-estimation-mode AfterDiscounts --region us-east-1
# 3b. Next day: estimated monthly savings by resource type, de-duplicated total first
aws cost-optimization-hub list-recommendation-summaries --group-by ResourceType --region us-east-1 \
--query '{dedupedTotal: estimatedTotalDedupedSavings, byType: items[].[group, recommendationCount, estimatedMonthlySavings]}'
# 3c. The 20 largest individual recommendations
aws cost-optimization-hub list-recommendations --region us-east-1 --output json \
| jq -r '.items | sort_by(-.estimatedMonthlySavings) | .[:20][]
| [.estimatedMonthlySavings, .actionType, .currentResourceType, .resourceId, .implementationEffort] | @tsv'
# 3d. Idle resources Compute Optimizer found in this Region
aws compute-optimizer get-idle-recommendations --region us-east-1 --output json \
| jq -r '.idleRecommendations[]
| [.savingsOpportunity.estimatedMonthlySavings.value, .resourceType, .finding, .resourceArn] | @tsv'
The idle criteria are conservative: an EC2 instance is idle when peak CPU stays below 5 percent and network I/O below 5 MB a day over 14 days; an EBS volume is unattached after 32 days; a NAT gateway is idle when it has no route table association, no active connections and no packets. Treat the output as a review queue with an owner per line, not a delete list.
Step 4: Delete unattached EBS volumes, old snapshots and idle load balancers
EBS bills provisioned gigabytes whether or not anything is attached: $0.10 per GB-month for gp2 and $0.08 for gp3 in us-east-1, and $0.05 per GB-month for standard snapshots (EBS pricing). A volume in the available state is attached to nothing.
# 4a. Unattached volumes, largest first, with tags so you can find an owner
aws ec2 describe-volumes --region us-east-1 --filters Name=status,Values=available --output json \
| jq -r '.Volumes | sort_by(-.Size)[]
| [.VolumeId, .Size, .VolumeType, .CreateTime, ((.Tags // []) | map("\(.Key)=\(.Value)") | join(","))] | @tsv'
# After an owner confirms: keep a final snapshot, wait for it, then delete the volume
SNAP=$(aws ec2 create-snapshot --volume-id vol-0123456789abcdef0 \
--description "final copy before delete" --query SnapshotId --output text)
aws ec2 wait snapshot-completed --snapshot-ids "$SNAP"
aws ec2 delete-volume --volume-id vol-0123456789abcdef0
Snapshots referenced by an AMI cannot be deleted until the AMI is deregistered, so exclude them up front. Old snapshots you must keep for compliance can move to the archive tier at $0.0125 per GB-month, with a 90-day minimum and $0.03 per GB to restore.
# 4b. Snapshots older than one year that no AMI of yours references
CUTOFF=2025-10-31
aws ec2 describe-images --owners self --output json \
| jq -r '.Images[].BlockDeviceMappings[].Ebs.SnapshotId // empty' | sort -u > ami-snapshots.txt
aws ec2 describe-snapshots --owner-ids self --output json \
| jq -r --arg c "$CUTOFF" '.Snapshots[] | select(.StartTime < $c)
| [.SnapshotId, .VolumeSize, .StartTime, .StorageTier, (.Description // "")] | @tsv' \
| grep -v -F -f ami-snapshots.txt > old-snapshots.tsv
wc -l old-snapshots.tsv
# Then, per snapshot: archive what you must keep, delete what you do not
aws ec2 modify-snapshot-tier --snapshot-id snap-0123456789abcdef0 --storage-tier archive
aws ec2 delete-snapshot --snapshot-id snap-0fedcba9876543210
Live gp2 volumes convert to gp3 in place, without detaching. gp3 includes 3,000 IOPS and 125 MB/s; gp2 volumes above 1,000 GiB had more baseline IOPS (3 per GiB), so pass --iops to match before switching. Each modification must complete before the next, and a volume can be modified at most four times in a rolling 24-hour period (EBS docs).
# 4c. gp2 to gp3
aws ec2 describe-volumes --filters Name=volume-type,Values=gp2 --query 'Volumes[].[VolumeId,Size,Iops]' --output text
aws ec2 modify-volume --volume-id vol-0123456789abcdef0 --volume-type gp3
aws ec2 describe-volumes-modifications --volume-ids vol-0123456789abcdef0 \
--query 'VolumesModifications[0].[ModificationState,Progress]' --output text
# Expected while running: modifying 40 (later: optimizing, then completed)
An Application Load Balancer costs $0.0225 an hour before any traffic (ELB pricing), plus a public IPv4 address per AZ if it is internet-facing. This loop sums RequestCount over 14 days for every ALB; 0 means nothing called it.
# 4d. ALBs with no requests in 14 days (macOS date shown; GNU: date -u -d '14 days ago' ...)
END_T=$(date -u +%Y-%m-%dT%H:%M:%SZ)
START_T=$(date -u -v-14d +%Y-%m-%dT%H:%M:%SZ)
for arn in $(aws elbv2 describe-load-balancers \
--query 'LoadBalancers[?Type==`application`].LoadBalancerArn' --output text); do
dim=${arn#*:loadbalancer/} # app/<name>/<id>
n=$(aws cloudwatch get-metric-statistics --namespace AWS/ApplicationELB --metric-name RequestCount \
--dimensions Name=LoadBalancer,Value="$dim" \
--start-time "$START_T" --end-time "$END_T" --period 1209600 --statistics Sum \
--query 'sum(Datapoints[].Sum)' --output text)
printf '%s\t%s\n' "$n" "$dim"
done | sort -n | head
# For Network Load Balancers use --namespace AWS/NetworkELB --metric-name NewFlowCount.
Step 5: Release idle Elastic IPs and cut public IPv4 use
Every public IPv4 address costs $0.005 an hour, in use or idle, which is $3.65 a month each (Price List, AmazonVPC). Unassociated Elastic IPs are the easy wins; the rest need an architecture decision, such as moving instances behind a load balancer or onto IPv6.
# 5a. Elastic IPs associated with nothing
aws ec2 describe-addresses --query 'Addresses[?AssociationId==null].[PublicIp,AllocationId]' --output text
aws ec2 release-address --allocation-id eipalloc-0123456789abcdef0
# 5b. Every network interface holding a public IPv4, and what it belongs to
aws ec2 describe-network-interfaces --output json \
| jq -r '.NetworkInterfaces[] | select(.Association.PublicIp != null)
| [.Association.PublicIp, .InterfaceType, .Description] | @tsv'
Once released, an address can be allocated to another customer and getting it back is not guaranteed, so check DNS records and partner allow-lists first. Public IP Insights in VPC IPAM gives the same inventory across accounts at no charge.
Step 6: Move S3 and DynamoDB traffic off the NAT gateway
A NAT gateway charges $0.045 per GB processed on top of $0.045 an hour, even for traffic to S3 in the same Region (VPC pricing). Gateway endpoints for S3 and DynamoDB are free and take that traffic off the NAT path. Creating one rewrites route tables and, per the S3 gateway endpoint docs, disconnects open TCP connections to S3, so do it in a quiet window and make sure clients retry.
# 6a. Free gateway endpoints on every route table in the VPC
VPC_ID=vpc-0123456789abcdef0
RTBS=$(aws ec2 describe-route-tables --filters Name=vpc-id,Values=$VPC_ID \
--query 'RouteTables[].RouteTableId' --output text)
for svc in s3 dynamodb; do
aws ec2 create-vpc-endpoint --vpc-id "$VPC_ID" --vpc-endpoint-type Gateway \
--service-name "com.amazonaws.us-east-1.${svc}" --route-table-ids $RTBS
done
# 6b. Interface endpoint example (ECR image pulls). $0.01/hour per AZ plus $0.01/GB.
aws ec2 create-vpc-endpoint --vpc-id "$VPC_ID" --vpc-endpoint-type Interface \
--service-name com.amazonaws.us-east-1.ecr.dkr \
--subnet-ids subnet-0aaa subnet-0bbb subnet-0ccc \
--security-group-ids sg-0123456789abcdef0 --private-dns-enabled
Interface endpoints are not free, so do the arithmetic. One endpoint in three AZs costs $21.90 a month (PrivateLink pricing) and saves $0.035 on each GB that no longer crosses the NAT, which breaks even around 630 GB a month per service. ECR stores image layers in S3, so the S3 gateway endpoint from 6a matters for image pulls too. Re-run query 2f a week later; the NAT bytes for that VPC should have dropped. The regional NAT gateway (create-nat-gateway --availability-mode regional, CLI 2.32 or newer) simplifies multi-AZ setups but keeps the same per-GB price.
Step 7: Add S3 lifecycle rules and CloudWatch Logs retention
Two defaults keep data forever: buckets without lifecycle rules, and log groups, which store data "indefinitely" unless you set retention. In us-east-1, S3 Standard is $0.023 per GB-month, Standard-IA $0.0125 and Glacier Instant Retrieval $0.004 (Price List, AmazonS3). This rule set cleans up failed multipart uploads and old versions everywhere, and tiers a logs/ prefix.
{
"Rules": [
{
"ID": "cleanup-everywhere",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 },
"NoncurrentVersionExpiration": { "NoncurrentDays": 30, "NewerNoncurrentVersions": 3 }
},
{
"ID": "tier-then-expire-logs",
"Status": "Enabled",
"Filter": { "Prefix": "logs/" },
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 120, "StorageClass": "GLACIER_IR" }
],
"Expiration": { "Days": 400 }
}
]
}
# 7a. put-bucket-lifecycle-configuration REPLACES the whole configuration, so back it up first
aws s3api get-bucket-lifecycle-configuration --bucket my-app-data > lifecycle-backup.json \
|| echo "no existing lifecycle rules"
aws s3api put-bucket-lifecycle-configuration --bucket my-app-data \
--lifecycle-configuration file://lifecycle.json
Since September 2024, lifecycle transitions skip objects smaller than 128 KB by default, because per-object transition requests can cost more than the storage saved. Moving an object out of a class before its minimum storage duration (90 days for Glacier Instant Retrieval) still bills the remainder, so space transitions out rather than chaining them quickly.
CloudWatch Logs charges $0.50 per GB to ingest into the Standard class and $0.03 per GB-month to store. Retention fixes storage; ingestion needs lower log levels.
# 7b. Log groups that never expire, largest stored bytes first
aws logs describe-log-groups --output json \
| jq -r '.logGroups[] | select(.retentionInDays == null) | [.storedBytes, .logGroupName] | @tsv' \
| sort -rn | head -20
# 7c. Apply 30 days to all of them (valid values: 1, 3, 5, 7, 14, 30, 60, 90 ... 3653)
aws logs describe-log-groups --output json \
| jq -r '.logGroups[] | select(.retentionInDays == null) | .logGroupName' \
| while read -r lg; do aws logs put-retention-policy --log-group-name "$lg" --retention-in-days 30; done
Check compliance requirements before 7c; audit logs often need a year or more. For new, rarely queried log groups, aws logs create-log-group --log-group-class INFREQUENT_ACCESS halves ingestion to $0.25 per GB, but the class cannot be changed after creation.
Step 8: Set a budget and Cost Anomaly Detection
Budgets catch slow drift; anomaly detection catches spikes. Monitoring budgets are free and only action-enabled budgets beyond the first two cost $0.10 a day (AWS Budgets pricing). Budgets refresh up to three times a day, so they always lag a little.
# 8a. Monthly cost budget with an 80% actual alert and a 100% forecast alert
cat > budget.json <<'EOF'
{
"BudgetName": "monthly-total",
"BudgetType": "COST",
"TimeUnit": "MONTHLY",
"BudgetLimit": { "Amount": "5000", "Unit": "USD" }
}
EOF
cat > notifications.json <<'EOF'
[
{ "Notification": { "NotificationType": "ACTUAL", "ComparisonOperator": "GREATER_THAN",
"Threshold": 80, "ThresholdType": "PERCENTAGE" },
"Subscribers": [ { "SubscriptionType": "EMAIL", "Address": "finops@example.com" } ] },
{ "Notification": { "NotificationType": "FORECASTED", "ComparisonOperator": "GREATER_THAN",
"Threshold": 100, "ThresholdType": "PERCENTAGE" },
"Subscribers": [ { "SubscriptionType": "EMAIL", "Address": "finops@example.com" } ] }
]
EOF
aws budgets create-budget --account-id "$ACCOUNT_ID" \
--budget file://budget.json --notifications-with-subscribers file://notifications.json
# 8b. AWS-managed services monitor plus a daily email for anomalies of $100 or more
MONITOR_ARN=$(aws ce create-anomaly-monitor \
--anomaly-monitor '{"MonitorName":"aws-services","MonitorType":"DIMENSIONAL","MonitorDimension":"SERVICE"}' \
--query MonitorArn --output text)
aws ce create-anomaly-subscription --anomaly-subscription "{
\"SubscriptionName\": \"daily-100-usd\",
\"MonitorArnList\": [\"${MONITOR_ARN}\"],
\"Subscribers\": [{\"Type\": \"EMAIL\", \"Address\": \"finops@example.com\"}],
\"Frequency\": \"DAILY\",
\"ThresholdExpression\": {\"Dimensions\": {\"Key\": \"ANOMALY_TOTAL_IMPACT_ABSOLUTE\",
\"MatchOptions\": [\"GREATER_THAN_OR_EQUAL\"], \"Values\": [\"100\"]}}
}"
Per the Cost Management quotas, an account can hold one AWS-managed services monitor, detection needs at least 10 days of history, and an anomaly can take up to 24 hours to surface. Individual (immediate) alerts need an SNS topic instead of email.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
create-export fails, or the console shows "Invalid bucket" |
Bucket policy missing the bcm-data-exports.amazonaws.com principal, or aws:SourceArn has the wrong account |
Re-apply the 2a policy; the SourceArn Region is always us-east-1 |
| Athena returns zero rows | Crawler ran before the first delivery, or points above the data/ folder |
Wait for files under BILLING_PERIOD=, rerun the crawler on .../data/, check SHOW PARTITIONS cur2.data |
| NAT or snapshot query finds nothing in us-east-1 | us-east-1 usage types have no region prefix | Use LIKE '%NatGateway-Bytes' rather than 'USE1-NatGateway-Bytes' |
| Cost Optimization Hub returns no items | Not enrolled, Compute Optimizer not opted in, wrong Region, or under 24 hours since opt-in | Enroll both, call with --region us-east-1, wait a day |
create-anomaly-monitor fails for the SERVICE monitor |
The account already has its one AWS-managed services monitor | aws ce get-anomaly-monitors and reuse that ARN in 8b |
delete-snapshot returns InvalidSnapshot.InUse |
The snapshot backs a registered AMI | Confirm the AMI is unused, deregister it, then delete |
| Deleted snapshots or volumes still on the bill | A Recycle Bin retention rule holds them at normal rates | aws rbin list-rules --resource-type EBS_SNAPSHOT, then shorten or scope the rule |
modify-volume rejected for modification rate |
Previous change still running, or four modifications already in the last 24 hours | Wait for completed in describe-volumes-modifications; the error says when the next change is allowed |
| Apps log S3 timeouts right after 6a | New endpoint routes reset open TCP connections to S3 | Expected once; make sure SDK retries are on and schedule changes off-peak |
| Budget alert arrived after the money was spent | Billing data lags usage and budgets refresh up to three times a day | Keep the forecast alert and rely on anomaly detection for spikes |
FAQ
What is the fastest way to reduce my AWS bill?
Delete what is provably unused (unattached EBS volumes, unassociated Elastic IPs, load balancers with no requests), add free S3 and DynamoDB gateway endpoints, and set log retention. Those three take an afternoon and carry little risk. Rightsizing and commitments save more but need evidence and owners.
Is CUR 2.0 free?
AWS Data Exports does not charge for the export itself. You pay standard S3 rates to store the files and Athena charges for the data each query scans, which Parquet and the billing_period partition keep small.
Does Compute Optimizer cost anything?
The default 14-day analysis is free. Enhanced infrastructure metrics, which extend the lookback to about three months, cost $0.0003360215 per resource-hour, roughly $0.25 a month per resource running all month.
Should I buy Savings Plans before or after this checklist?
After. Savings Plans discount compute and Database Savings Plans discount databases, but neither touches NAT processing, transfer, IPv4, EBS, snapshots or logs. Buying first locks in today's oversized baseline for one or three years.
What I check first on any account is query 2e: it tells me in one screen whether the problem is NAT, cross-AZ traffic, storage, logs or version debt, and each has a different owner. In production I run steps 1 to 3 monthly, keep 4 and 5 as a quarterly review with tag-based owners, and treat 6 to 8 as defaults in the VPC and account baseline so new environments start clean. That last part matters more every year as agents and pipelines create resources nobody remembers. On Kubernetes, pair this with the cluster-level work in Kubernetes cost optimization, and for token spend see FinOps for AI. Official references: AWS Data Exports, Cost Optimization Hub, Compute Optimizer idle recommendations, Cost Anomaly Detection and the EKS version calendar.
Related: Kubernetes Cost Optimization in 2026: 8% CPU Use and What Fixes It, Terraform State in 2026: S3 Locking Replaced DynamoDB, What Else Changed and Why Is My AWS Bill So High? 9 Hidden Charges and 2026 Prices.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Set Up Karpenter on EKS: NodePools, Spot and Consolidation (2026)
Step-by-step Karpenter 1.15.1 setup on an existing EKS cluster: CloudFormation IAM and SQS, EKS Pod Identity, Helm install, EC2NodeClass and NodePool YAML, Spot with on-demand fallback, Balanced consolidation and disruption budgets, Cluster Autoscaler migration, and fixes for nodes not launching.
14 minOct 31, 2026Why Is My AWS Bill So High? 9 Hidden Charges and 2026 Prices
Why your AWS bill is high in 2026: NAT Gateway at $0.045 per GB, $3.65 a month per public IPv4, cross-AZ traffic billed on both sides, gp2 and old snapshots, CloudWatch Logs at $0.50 per GB, EKS and RDS extended support, and Savings Plans vs RIs, with what to fix first.
13 minOct 31, 2026Amazon S3 Explained (2026): Storage Classes, Real Prices and Egress
Amazon S3 in 2026 explained for engineers: every storage class with current us-east-1 prices, request and egress costs, Intelligent-Tiering break-even, Express One Zone, S3 Tables, Vectors and Files, security defaults, and how S3 compares with Cloudflare R2 and Google Cloud Storage.
13 min