AWSCost · run it yourself
Find AWS waste yourself.
These are the 33 things the AWSCost scanner looks for, and the AWS CLI command that finds each one by hand. No sign-up, no role to deploy, nothing to install beyond the CLI you already have.
Every command only reads. There is not a single delete, modify or create on this page.
The catch is the region. AWS bills and reports one region at a time, so each command below answers for one only. Run them again for every region you have switched on.
Where a command reads a metric it works out its own 14-day window, so nothing here goes stale. Placeholder ids like i-0123456789abcdef0 are yours to replace.
Networking - which resources bill with no traffic through them?
Elastic IPs attached to nothing
An unattached Elastic IP bills $0.005/hour - about $3.65/month each in us-east-1.
# Elastic IPs allocated but attached to nothing
aws ec2 describe-addresses \
--query 'Addresses[?AssociationId==`null`].{IP:PublicIp,AllocId:AllocationId}' \
--output tablePublic IPv4 addresses you pay for by the hour
Every public IPv4 address bills $0.005/hour whether anything reaches it or not - about $3.65/month per address in us-east-1.
# every public IPv4 address you are billed for, in use or not
aws ec2 describe-network-interfaces \
--query 'NetworkInterfaces[?Association.PublicIp!=`null`].{Eni:NetworkInterfaceId,IP:Association.PublicIp,Type:InterfaceType,Attached:Description}' \
--output tableNAT gateways passing no traffic
A NAT gateway that moved no bytes for 14 days still bills its hourly charge - about $32.85/month in us-east-1, before per-GB processing.
# every NAT gateway in the region
aws ec2 describe-nat-gateways --filter Name=state,Values=available \
--query 'NatGateways[].{Id:NatGatewayId,Vpc:VpcId,Subnet:SubnetId,Created:CreateTime}' \
--output table
# bytes one moved over 14 days - a flat 0 is the hourly charge for nothing
aws cloudwatch get-metric-statistics \
--namespace AWS/NATGateway --metric-name BytesOutToDestination \
--dimensions Name=NatGatewayId,Value=nat-0123456789abcdef0 \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics SumInterface VPC endpoints nothing calls
An interface endpoint bills per subnet it lives in - about $7.30/month each in us-east-1 - with or without traffic.
# interface endpoints and the number of subnets each one bills for
aws ec2 describe-vpc-endpoints \
--query "VpcEndpoints[?VpcEndpointType=='Interface'].{Id:VpcEndpointId,Service:ServiceName,AZs:length(SubnetIds),State:State}" \
--output table
# bytes through one endpoint in one subnet - near zero means nothing calls it
aws cloudwatch get-metric-statistics \
--namespace AWS/PrivateLinkEndpoints --metric-name BytesProcessed \
--dimensions Name=VpcId,Value=vpc-0123456789abcdef0 \
Name=VpcEndpointId,Value=vpce-0123456789abcdef0 \
Name=EndpointType,Value=Interface \
Name=SubnetId,Value=subnet-0123456789abcdef0 \
Name=ServiceName,Value=com.amazonaws.us-east-1.ssm \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics SumClient VPN endpoints nobody connects to
A Client VPN bills $0.10/hour per associated subnet - about $73/month in us-east-1 - at zero connected clients.
# every Client VPN endpoint in the region
aws ec2 describe-client-vpn-endpoints \
--query 'ClientVpnEndpoints[?Status.Code==`available`].{Id:ClientVpnEndpointId,Vpc:VpcId,Desc:Description}' \
--output table
# the subnets that bill - only "associated" is charged
aws ec2 describe-client-vpn-target-networks \
--client-vpn-endpoint-id cvpn-endpoint-0123456789abcdef0 \
--query 'ClientVpnTargetNetworks[?Status.Code==`associated`].{Association:AssociationId,Subnet:TargetNetworkId}' \
--output table
# peak connected clients over 14 days - a flat 0 means nobody connected
aws cloudwatch get-metric-statistics \
--namespace AWS/ClientVPN --metric-name ActiveConnectionsCount \
--dimensions Name=Endpoint,Value=cvpn-endpoint-0123456789abcdef0 \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics MaximumTransit Gateway attachments carrying nothing
Each attachment bills $36.50/month in us-east-1, traffic or no traffic.
# attachments, and which account pays for each
aws ec2 describe-transit-gateway-attachments \
--query 'TransitGatewayAttachments[?State==`available`].{Id:TransitGatewayAttachmentId,Type:ResourceType,Owner:ResourceOwnerId,Resource:ResourceId}' \
--output table
# bytes through one attachment over 14 days - a flat 0 is a route to nothing
aws cloudwatch get-metric-statistics \
--namespace AWS/TransitGateway --metric-name BytesIn \
--dimensions Name=TransitGatewayAttachment,Value=tgw-attach-0123456789abcdef0 \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics SumSite-to-Site VPN connections with both tunnels down
A VPN connection bills $36.50/month in us-east-1 while it exists, whether or not either tunnel is up.
# tunnel state and bandwidth tier, straight off the describe
aws ec2 describe-vpn-connections \
--query 'VpnConnections[?State==`available`].{Id:VpnConnectionId,Bandwidth:Options.TunnelBandwidth,Tunnels:VgwTelemetry[].Status,Changed:VgwTelemetry[].LastStatusChange}' \
--output json
# only if the tunnels are up: has anything crossed them in 14 days?
aws cloudwatch get-metric-statistics \
--namespace AWS/VPN --metric-name TunnelDataIn \
--dimensions Name=VpnId,Value=vpn-0123456789abcdef0 \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics SumLoad balancers with no healthy targets
An ALB or NLB with nothing behind it still bills its hourly charge - roughly $16 to $22/month before capacity units.
# every load balancer in the region
aws elbv2 describe-load-balancers \
--query 'LoadBalancers[].{Name:LoadBalancerName,Type:Type,State:State.Code,ARN:LoadBalancerArn}' \
--output table
# requests one handled over 14 days - zero is an idle balancer
aws cloudwatch get-metric-statistics \
--namespace AWS/ApplicationELB --metric-name RequestCount \
--dimensions Name=LoadBalancer,Value=app/my-lb/0123456789abcdef \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics SumWhere your data transfer charges come from
Splits the transfer bill into NAT processing, cross-AZ, cross-region and internet egress, and names the parts worth attacking.
# this month's spend by usage type - read the DataTransfer, Bytes
# and NatGateway lines. On the 1st of the month, set Start yourself -
# AWS rejects a window with no days in it.
aws ce get-cost-and-usage \
--time-period Start=$(date -u +%Y-%m-01),End=$(date -u +%Y-%m-%d) \
--granularity MONTHLY --metrics UnblendedCost \
--group-by Type=DIMENSION,Key=USAGE_TYPECompute - what is running that nothing uses?
EC2 instances doing nothing
An instance averaging under 5% CPU for 14 days, priced at what its type costs to run - plus stopped instances still billing for disk.
# every instance - a stopped one still bills for its disks
aws ec2 describe-instances \
--query 'Reservations[].Instances[].{Id:InstanceId,Type:InstanceType,State:State.Name,Launched:LaunchTime}' \
--output table
# average CPU over 14 days - under 5% is idle
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 --metric-name CPUUtilization \
--dimensions Name=InstanceId,Value=i-0123456789abcdef0 \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics AverageEC2 instances bigger than their workload
Headroom the instance never touches, priced at the gap down to a smaller type.
# peak CPU over 14 days - a low maximum is headroom you pay for
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 --metric-name CPUUtilization \
--dimensions Name=InstanceId,Value=i-0123456789abcdef0 \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics MaximumInstances AWS sells cheaper today, same specs
Same memory, at least the vCPUs, lower price: t2.micro to t3a.micro saves $1.90/month and m4.xlarge to m6a.xlarge $24.09/month in eu-central-1.
# what is running, and on what
aws ec2 describe-instances \
--query 'Reservations[].Instances[?State.Name==`running`].{Id:InstanceId,Type:InstanceType,Az:Placement.AvailabilityZone}' \
--output table
# current-generation types with the same 16 GiB and at least 4 vCPUs
aws ec2 describe-instance-types \
--filters Name=current-generation,Values=true Name=memory-info.size-in-mib,Values=16384 \
--query 'InstanceTypes[?VCpuInfo.DefaultVCpus>=`4`].{Type:InstanceType,VCpus:VCpuInfo.DefaultVCpus,Ena:NetworkInfo.EnaSupport}' \
--output tableEKS clusters with no nodes
An abandoned control plane bills $73/month flat in every region, with nothing running on it.
# clusters in this region
aws eks list-clusters --query 'clusters' --output text
# a cluster with no node groups still bills the control plane
aws eks list-nodegroups --cluster-name my-cluster --query 'nodegroups'Fargate services nobody uses
A service whose peak CPU never passed 5% in 14 days, priced at task size times desired count - $36.04/month for one 1 vCPU / 2 GB task in us-east-1.
# services in a cluster, and how many tasks each keeps running
aws ecs list-clusters --query 'clusterArns' --output text
aws ecs list-services --cluster my-cluster --output text
aws ecs describe-services --cluster my-cluster --services my-service \
--query 'services[].{Name:serviceName,Desired:desiredCount,Running:runningCount}' \
--output table
# peak CPU over 14 days - never above 5% and the tasks are idle
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 +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics MaximumLambda memory you configured and never use
Functions holding memory far above their measured peak, where Lambda Insights is switched on to measure it.
# every function and the memory it is configured for
aws lambda list-functions \
--query 'Functions[].{Name:FunctionName,MB:MemorySize,Timeout:Timeout,Runtime:Runtime}' \
--output table
# what one actually used - the REPORT line carries max memory used
aws logs filter-log-events --log-group-name /aws/lambda/my-function \
--filter-pattern "REPORT" --limit 20 \
--query 'events[].message' --output textStorage - what is stored that nothing reads?
EBS volumes nothing is using
Volumes attached to nothing, and volumes whose only instance is stopped - both bill in full, $0.10/GiB-month for gp2 in us-east-1.
# volumes attached to nothing
aws ec2 describe-volumes --filters Name=status,Values=available \
--query 'Volumes[].{Id:VolumeId,GiB:Size,Type:VolumeType,Created:CreateTime,Az:AvailabilityZone}' \
--output table
# volumes parked on a stopped instance - same charge, different place
aws ec2 describe-instances --filters Name=instance-state-name,Values=stopped \
--query 'Reservations[].Instances[].{Id:InstanceId,Vols:BlockDeviceMappings[].Ebs.VolumeId}' \
--output jsonEBS volumes still on gp2
gp3 costs $0.08/GiB-month against gp2's $0.10 in us-east-1 - about 20% less at the same size, migrated live.
# every volume still on gp2 - these are the migration candidates
aws ec2 describe-volumes --filters Name=volume-type,Values=gp2 \
--query 'Volumes[].{Id:VolumeId,GiB:Size,Az:AvailabilityZone}' \
--output tableSnapshots of volumes that no longer exist
Orphaned snapshots, and snapshots older than 180 days, at $0.05/GiB-month in us-east-1.
# every snapshot this account owns
aws ec2 describe-snapshots --owner-ids self \
--query 'Snapshots[].{Id:SnapshotId,Vol:VolumeId,GiB:VolumeSize,Started:StartTime}' \
--output table
# volumes that still exist - any snapshot Vol missing here is orphaned
aws ec2 describe-volumes --query 'Volumes[].VolumeId' --output textAMIs nobody launches
Images nothing has launched in months, priced at the EBS snapshots behind them - $0.05/GiB-month in us-east-1.
# your AMIs and the snapshots behind each one
aws ec2 describe-images --owners self \
--query 'Images[].{Ami:ImageId,Name:Name,Created:CreationDate,Snapshots:BlockDeviceMappings[].Ebs.SnapshotId}' \
--output json
# instances running from an AMI - anything not listed here launches nothing
aws ec2 describe-instances \
--query 'Reservations[].Instances[].ImageId' --output textEFS file systems nothing reads or writes
A file system left behind after a migration bills every byte it holds - $0.30/GiB-month on Standard in us-east-1, whether or not anything mounts it.
# every file system, its size by class, and whether anything can mount it
aws efs describe-file-systems \
--query 'FileSystems[?LifeCycleState==`available`].{Id:FileSystemId,Mounts:NumberOfMountTargets,Standard:SizeInBytes.ValueInStandard,IA:SizeInBytes.ValueInIA,Archive:SizeInBytes.ValueInArchive}' \
--output table
# bytes read or written over 14 days - a flat 0 means nothing touched it
aws cloudwatch get-metric-statistics \
--namespace AWS/EFS --metric-name TotalIOBytes \
--dimensions Name=FileSystemId,Value=fs-0123456789abcdef0 \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics SumContainer images nobody pulls
Years of CI builds sit in ECR at $0.10/GiB-month. We price the layers deleting them would actually free, counting a shared layer once - not once per image.
# repositories in this region
aws ecr describe-repositories --query 'repositories[].repositoryName' --output text
# push and last-pull times per image, oldest push first
aws ecr describe-images --repository-name my-app \
--query 'sort_by(imageDetails,&imagePushedAt)[].{Digest:imageDigest,Pushed:imagePushedAt,LastPull:lastRecordedPullTime,Bytes:imageSizeInBytes}' \
--output tableS3 uploads that failed and still bill
Multipart uploads that never finished keep billing and show up nowhere in the console, and buckets with no lifecycle rule keep them forever.
# the incomplete uploads the console does not show
aws s3api list-multipart-uploads --bucket my-bucket \
--query 'Uploads[].{Key:Key,Initiated:Initiated}' --output table
# is there a rule to abort them? NoSuchLifecycleConfiguration means no
aws s3api get-bucket-lifecycle-configuration --bucket my-bucketS3 objects sitting in the wrong storage class
Cold data parked in Standard that Infrequent Access or Glacier would hold for a fraction of the price.
# every bucket, and the region each one lives in
aws s3api list-buckets --query 'Buckets[].Name' --output text
aws s3api get-bucket-location --bucket my-bucket
# bytes sitting in Standard - run this in the bucket's own region
aws cloudwatch get-metric-statistics \
--namespace AWS/S3 --metric-name BucketSizeBytes \
--dimensions Name=BucketName,Value=my-bucket Name=StorageType,Value=StandardStorage \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics AverageDatabases - what is provisioned above the load?
RDS databases nobody queries
Near-zero CPU and connections over 14 days, plus stopped databases that keep billing for storage.
# every database - a stopped one still bills for its storage
aws rds describe-db-instances \
--query 'DBInstances[].{Id:DBInstanceIdentifier,Class:DBInstanceClass,Status:DBInstanceStatus,GiB:AllocatedStorage,MultiAZ:MultiAZ}' \
--output table
# connections over 14 days - a flat 0 means nobody queries it
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS --metric-name DatabaseConnections \
--dimensions Name=DBInstanceIdentifier,Value=my-db \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics MaximumRDS instances bigger than their load
An instance class above what the measured load needs, priced at the gap down.
# peak CPU over 14 days - a low maximum is an instance class too big
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS --metric-name CPUUtilization \
--dimensions Name=DBInstanceIdentifier,Value=my-db \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics Maximum
# memory it never touches, in bytes
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS --metric-name FreeableMemory \
--dimensions Name=DBInstanceIdentifier,Value=my-db \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics MinimumManual RDS snapshots that never expire
Automated backups age out on their own; manual snapshots do not, at $0.095/GiB-month in us-east-1.
# manual snapshots only - the automated ones expire on their own
aws rds describe-db-snapshots --snapshot-type manual \
--query 'sort_by(DBSnapshots,&SnapshotCreateTime)[].{Id:DBSnapshotIdentifier,Db:DBInstanceIdentifier,GiB:AllocatedStorage,Created:SnapshotCreateTime,Engine:Engine}' \
--output tableAurora Serverless v2 minimum capacity
The cluster never scales below the floor you configured - one unused ACU is $87.60/month in us-east-1.
# clusters with a Serverless v2 floor, and what it is set to
aws rds describe-db-clusters \
--query "DBClusters[?ServerlessV2ScalingConfiguration!=null].{Id:DBClusterIdentifier,Min:ServerlessV2ScalingConfiguration.MinCapacity,Max:ServerlessV2ScalingConfiguration.MaxCapacity}" \
--output table
# lowest capacity one instance reached - equal to the floor means the floor is the limit
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS --metric-name ServerlessDatabaseCapacity \
--dimensions Name=DBInstanceIdentifier,Value=my-aurora-instance-1 \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics MinimumElastiCache nodes with no connections
A cache.t3.small is $24.82/month in us-east-1, and a cache group is usually several nodes.
# every cache node, its type, engine and the group it belongs to
aws elasticache describe-cache-clusters --show-cache-node-info \
--query 'CacheClusters[?CacheClusterStatus==`available`].{Id:CacheClusterId,Group:ReplicationGroupId,Type:CacheNodeType,Engine:Engine,Version:EngineVersion}' \
--output table
# peak connections over 14 days - 0 across the window means nothing connected
aws cloudwatch get-metric-statistics \
--namespace AWS/ElastiCache --metric-name CurrConnections \
--dimensions Name=CacheClusterId,Value=my-cache-001 \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics MaximumOpenSearch domains nobody searches
Priced across data, master and warm nodes - three r5.large.search nodes are $407.34/month in us-east-1.
# the nodes each domain bills for - data, masters and warm
aws opensearch list-domain-names --query 'DomainNames[].DomainName' --output text
aws opensearch describe-domains --domain-names my-logs-domain \
--query 'DomainStatusList[].{Name:DomainName,Data:ClusterConfig.InstanceType,Count:ClusterConfig.InstanceCount,Master:ClusterConfig.DedicatedMasterType,Masters:ClusterConfig.DedicatedMasterCount,Warm:ClusterConfig.WarmType,Warms:ClusterConfig.WarmCount}' \
--output table
# searches over 14 days - both dimensions are required or you get nothing back
aws cloudwatch get-metric-statistics \
--namespace AWS/ES --metric-name SearchRate \
--dimensions Name=ClientId,Value=123456789012 Name=DomainName,Value=my-logs-domain \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics MaximumDynamoDB capacity you provisioned and do not consume
Provisioned read and write units far above what the table actually uses.
# billing mode and provisioned capacity - an empty Mode means provisioned
aws dynamodb list-tables --query 'TableNames' --output text
aws dynamodb describe-table --table-name my-table \
--query 'Table.{Name:TableName,Mode:BillingModeSummary.BillingMode,RCU:ProvisionedThroughput.ReadCapacityUnits,WCU:ProvisionedThroughput.WriteCapacityUnits}'
# what it actually consumed over 14 days - far below the provisioned number is waste
aws cloudwatch get-metric-statistics \
--namespace AWS/DynamoDB --metric-name ConsumedReadCapacityUnits \
--dimensions Name=TableName,Value=my-table \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics SumObservability - what are the logs costing?
Log groups keeping everything forever
A log group with no retention set stores every line for good, at $0.03/GiB-month in us-east-1.
# log groups with no retention - they keep every line forever
aws logs describe-log-groups \
--query 'logGroups[?retentionInDays==`null`].{Name:logGroupName,Bytes:storedBytes}' \
--output tableLogs ingested at the expensive class
High-volume groups on Standard ingest at $0.50/GB where Infrequent Access is $0.25 in us-east-1.
# which class each group ingests at
aws logs describe-log-groups \
--query 'logGroups[].{Name:logGroupName,Class:logGroupClass,Bytes:storedBytes}' \
--output table
# bytes ingested over 14 days - high volume on STANDARD is the one to move
aws cloudwatch get-metric-statistics \
--namespace AWS/Logs --metric-name IncomingBytes \
--dimensions Name=LogGroupName,Value=/my/log/group \
--start-time $(( $(date +%s) - 1209600 )) --end-time $(date +%s) \
--period 86400 --statistics SumCommitments - what is still paying the on-demand rate?
On-demand spend with no Savings Plan behind it
Steady baseline usage paying the on-demand rate, and commitment you already bought but do not use.
# AWS's own Compute Savings Plan recommendation from recent usage
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_DAYSRunning all 33 by hand, once per region, is an afternoon. Connect read-only and the same 33 run across every region while you wait - each finding priced at that region’s live rate, with what changing it would cost you.
The role can only read - Get, Describe, List, nothing else. Read every permission it asks for before you connect, and delete the stack whenever you want. The scan adds about $0.10 to your own bill.
Frequently asked questions
Can any of these commands change my account?
+
No. Every one is a describe, a list or a get. A test fails the build if a command on this page contains a verb that writes, so a fix cannot slip in here by accident. The commands that do change things live in the articles, marked MUTATING.
Do I have to edit the dates?
+
No. The commands that read a metric work out their own 14-day window from the clock on your machine, so they stay correct however long this page has been up. The one Cost Explorer command uses the current month to date - widen it yourself if you want a full month.
Do I need anything installed?
+
AWS CLI v2, configured with credentials that can read your account. Nothing else. Every command runs from a terminal and prints to it.
Why give this away?
+
Because it is already true, and hiding it would not make it less true. What you cannot do by hand is run all 33 across every region you have switched on, price each finding at that region's live rate, and rule out the resources that only look idle. That is what the scan is for.
Do these cost anything to run?
+
Almost nothing. The describe and list calls are free. CloudWatch metric reads are $0.01 per 1,000, and the Cost Explorer call is $0.01 each. Running the whole page once in one region costs a few cents.
What each check costs when it fires · An example report · AWS cost breakdowns