Skip to content
Blocks

10 Most Common AWS Savings Opportunities

What we found analyzing thousands of cost findings across more than 100 startups and scaleups, and how to check for the same things in yours.

Every finding in this paper comes out of our cloud cost optimization scan, the engine behind Blocks. It reads an AWS account, identifies waste, and validates each opportunity before flagging it. The result is a set of real savings, not theoretical ones, and the kind that gets fixed in a few minutes without re-architecting anything.

We then applied one filter. Anything worth less than $200 a year was thrown out as you want to concentrate on savings that move the needle. The most common finding in the whole dataset was unattached EBS volumes at a median of twenty cents a month. Clear out that noise and a short list of real problems is left, and the same ones show up account after account.

This is that list, ranked by how many accounts had the problem rather than by how much each one saves. The goal is to tell you what to check first, and that is the thing you are most likely to have. It reflects a typical AWS startup setup, so expect differences: if an item names a service you do not run, skip it.

The ten most common opportunities, ranked by how many accounts had them:

  1. Rightsize EC2 instances

    Same memory, fewer vCPUs.

    46% · $673/yr median
  2. Consolidate redundant NAT gateways

    One per subnet is enough.

    46% · $499/yr median
  3. Delete idle NAT gateways

    ~$32 a month for nothing.

    31% · $464/yr median
  4. Delete idle VPC endpoints

    Here silence is the finding.

    29% · $263/yr median
  5. Delete old RDS snapshots

    Manual snapshots never expire.

    28% · $433/yr median
  6. Delete unused EKS clusters

    Control planes running nothing.

    17% · $876/yr median
  7. Rightsize RDS instances

    The biggest single saving.

    14% · $3,504/yr median
  8. Delete idle VPN connections

    Tunnels down, still billing.

    14% · $432/yr median
  9. Delete unused load balancers

    Nothing behind them.

    14% · $222/yr median
  10. Rightsize ElastiCache clusters

    Memory the cache never fills.

    13% · $254/yr median

Before you start: set up your shell once

Every Cost Explorer command below uses the same billing month, so declare it once per shell session and reuse it. macOS ships BSD date, which has no -d flag, so the fallback below covers both platforms (installing GNU coreutils and using gdate works too, but this needs nothing installed):

Shell · billing monthSTART=$(date -u -d "$(date -u +%Y-%m-01) -1 month" +%Y-%m-01 2>/dev/null \
  || date -u -v1d -v-1m +%Y-%m-01)
END=$(date -u +%Y-%m-01)

Both branches subtract the month from the 1st rather than from today, and that detail matters. GNU date -d '1 month ago' run on 31 May asks for 31 April, normalizes it to 1 May, and hands you the current month: START and END come out identical and every Cost Explorer call below fails with “Start date must be before end date”. It fails on the last days of March, May, July, October and December only, so it is the kind of thing that works all month and breaks on the day you demo it.

The CloudWatch commands avoid date -d entirely by doing the arithmetic in epoch seconds, which every Linux and macOS shell accepts:

Shell · CloudWatch windows# 30 days back
--start-time $(( $(date -u +%s) - 2592000 )) --end-time $(date -u +%s)
# 90 days back
--start-time $(( $(date -u +%s) - 7776000 )) --end-time $(date -u +%s)

Resource IDs are set as shell variables at the top of each check, for example INSTANCE_ID=i-0123456789abcdef0. Replace the placeholder value with your own and the rest of the block runs as written.

Two more things decide whether any of this returns the truth rather than a comfortable blank:

Region. Every command here except the Cost Explorer ones is region-scoped. A NAT gateway in a region you never queried reads exactly like no NAT gateway at all, and an empty table is the most common false negative in this whole paper. Set the region explicitly and repeat each check for every region you actually use:

Shell · regionexport AWS_REGION=eu-west-1
aws ec2 describe-regions --query 'Regions[].RegionName' --output text

Cost Explorer is a global endpoint and ignores whatever you set here, so the triage queries work from anywhere.

Account. The Cost Explorer triage runs in the management account, but every describe-* and CloudWatch command after it runs in the account that owns the resource. So each time a section says “in the flagged accounts”, you need credentials for that account first: a named profile per account and --profile, or aws sts assume-role into your organization’s cross-account role and export the resulting AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN.

Every CloudWatch query returns a Days count alongside the numbers. Read it first: if you asked for 30 days and got 9 datapoints back, you do not have enough history to judge the resource, either because the metric is sparse or because the resource was created last week.

1. Rightsize EC2 instances

46%
Of accounts had it
$673/yr
Median saving

The most common finding we see, and the biggest by median value on this list. Over-provisioned EC2s is the default failure mode: someone picks a size at launch with headroom to be safe, and no one checks or cares to adjust later. It’s a pain to check as you only get CPU and not memory usage out of the box, and a slap on the wrist if downsizing leads to a downtime due to the now underprovisioned instance.

Our trick sidesteps the memory problem entirely. Instead of trying to measure memory usage, we downsize to an instance that keeps the same memory and only sheds vCPUs, crossing instance families if we have to (this assumes you have not chosen the instance family for its specific properties). If memory never drops, there is no memory metric to chase and no way for the downsize to starve the workload. You are only removing CPU, and CPU is the one thing you can see. An idle m5.2xlarge (8 vCPU, 32 GB) moves to an r5.xlarge (4 vCPU, 32 GB): identical memory, half the vCPUs, and about 30% cheaper.

How to check

Use AWS Compute Optimizer as a cost radar (if not enabled, turn it on and wait for a day). It watches CloudWatch over its own free two-week window and ranks instances by how over-provisioned they are and how much they cost. Pull the over-provisioned instances ranked by saving, across all accounts if you enrolled at the org level:

CLI · Compute Optimizeraws compute-optimizer get-ec2-instance-recommendations \
  --filters name=Finding,values=Overprovisioned \
  --query 'instanceRecommendations[].{Instance:instanceArn,
    Current:currentInstanceType,
    Recommended:recommendationOptions[0].instanceType,
    Account:accountId}' \
  --output table

That gives you the shortlist, each instance’s current type, and what Compute Optimizer would move it to. Now confirm the CPU really is idle. Pull 30 days of CloudWatch yourself to ensure that there are no monthly spikes from jobs running:

CLI · CloudWatch CPUINSTANCE_ID=i-0123456789abcdef0
aws cloudwatch get-metric-statistics \
  --namespace AWS/EC2 --metric-name CPUUtilization \
  --dimensions Name=InstanceId,Value=$INSTANCE_ID \
  --start-time $(( $(date -u +%s) - 2592000 )) \
  --end-time $(date -u +%s) \
  --period 86400 --statistics Average Maximum \
  --query '{PeakMax:max(Datapoints[].Maximum),
    MeanAvg:avg(Datapoints[].Average),
    Days:length(Datapoints)}'

Three numbers instead of a wall of datapoints: the worst single day, the average across the window, and how many days of data actually exist. Check Days first. Anything short of 30 means you cannot rule out a monthly job yet, either because the instance is younger than the window or because the metric has gaps, so wait rather than resize.

As an easy rule of thumb, a PeakMax that never crosses roughly 40% over 30 days means there are vCPUs to give back, but scale that threshold to the size of the cut you are making. CPU percentage is relative to the vCPUs the instance has, so removing half of them roughly doubles it: a 40% peak on 8 vCPUs lands around 80% on 4. The test worth applying is PeakMax × current_vCPU / target_vCPU staying under about 60%, which for a half-size move means the current peak has to be under 30%.

Next, find every current-generation type with the same memory as the instance you are looking at. This reads the instance’s type, looks up its memory, and lists the same-memory alternatives sorted by vCPU count in one go:

CLI · same-memory alternativesMEM=$(aws ec2 describe-instance-types --output text \
  --query 'InstanceTypes[0].MemoryInfo.SizeInMiB' \
  --instance-types "$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
    --query 'Reservations[0].Instances[0].InstanceType' --output text)")
aws ec2 describe-instance-types \
  --filters Name=memory-info.size-in-mib,Values="$MEM" \
    Name=current-generation,Values=true \
  --query 'InstanceTypes[].{Type:InstanceType,vCPU:VCpuInfo.DefaultVCpus}
    | sort_by(@,&vCPU)' \
  --output table

Pick the lowest-vCPU option that still covers your measured CPU load. Stay on the same processor architecture as your current AMI unless you are ready to rebuild for Graviton, and you have a same-memory, lower-cost target with zero memory risk.

When to skip

Watch your commitments before switching family: a plain Compute Savings Plan follows you across families, but an EC2 Instance Savings Plan or a Reserved Instance is locked to one family and as a result you will be billed both for the new family and the locked family instance. If the instance sits behind an Auto Scaling group, change the launch template, not the running instance, or the group replaces it with the old size.

2. Consolidate redundant NAT gateways

46%
Of accounts had it
$499/yr
Median saving

A NAT gateway lets resources in a private subnet reach the internet. The standard high-availability pattern puts one in each Availability Zone, so a VPC spread across three AZs runs three NAT gateways. That is correct for production traffic that genuinely needs to survive an AZ outage. The waste shows up in two ways. The first is environments that never needed per-AZ redundancy at all: dev, staging, internal tooling etc. The second is hitting production as well: multiple NAT gateways sitting in the same Availability Zone. That buys no additional resilience as both will fail when the AZ goes down.

Every NAT gateway bills ~32 dollars a month just to exist, before a single byte moves through it. The only offsetting cost for a single AZ setup is a small amount of cross-AZ data transfer when resources route out through a gateway in another zone, which for low-traffic environments is a fraction of what the extra gateways cost.

How to check

Start on the management account, not inside individual accounts. Cost Explorer already knows what every linked account spends on NAT gateways, so one query triages the whole organization before you touch a single account directly.

The trick is to look at the running-hours charge on its own. A NAT gateway bills two ways: a fixed hourly charge for existing, and a per-gigabyte charge for data it processes. Only the fixed charge tells you how many gateways an account runs, so filter to the running-hours usage type and every dollar is fixed gateway cost:

CLI · Cost Explorer triageaws ce get-cost-and-usage \
  --time-period Start=$START,End=$END \
  --granularity MONTHLY \
  --metrics UnblendedCost \
  --filter '{"Dimensions":{"Key":"USAGE_TYPE_GROUP",
    "Values":["EC2: NAT Gateway - Running Hours"]}}' \
  --group-by Type=DIMENSION,Key=LINKED_ACCOUNT \
  --query 'ResultsByTime[0].Groups[].{Account:Keys[0],
    Cost:Metrics.UnblendedCost.Amount}' \
  --output table

Read the output as a gateway count. One gateway is about 32 dollars a month, so an account showing roughly 64 is running two, and so on. Decide which accounts require more than one NAT Gateway for resilience and consolidate the NAT Gateways to one on the accounts that do not.

For the remaining accounts with multiple NAT Gateways, list the gateways with their subnets:

CLI · gateways by subnetaws ec2 describe-nat-gateways \
  --filter "Name=state,Values=available" \
  --query 'NatGateways[].{ID:NatGatewayId,VPC:VpcId,Subnet:SubnetId}' \
  --output table

A subnet lives in exactly one Availability Zone, so the subnet column is your AZ column. Two gateways sharing a subnet are in the same AZ by definition and add nothing over each other, so keep one per subnet and delete the rest.

3. Delete idle NAT gateways

31%
Of accounts had it
$464/yr
Median saving

Finding 2 was about having too many NAT gateways. This one is about having a NAT gateway that does nothing at all. Same ~$32 a month fixed charge, except here you are not even splitting traffic across it.

How to check

You already listed every account’s gateway count in Finding 2. The difference here is traffic: an idle gateway is one where nothing flows through it. Total the outbound bytes per gateway over 30 days:

CLI · CloudWatch NAT trafficNAT_ID=nat-0123456789abcdef0
aws cloudwatch get-metric-statistics \
  --namespace AWS/NATGateway \
  --metric-name BytesOutToDestination \
  --dimensions Name=NatGatewayId,Value=$NAT_ID \
  --start-time $(( $(date -u +%s) - 2592000 )) \
  --end-time $(date -u +%s) \
  --period 86400 --statistics Sum \
  --query '{TotalBytes:sum(Datapoints[].Sum),Days:length(Datapoints)}'

Two numbers: total bytes out over the month, and how many days reported. TotalBytes at zero or a handful of kilobytes across a full 30 Days is a gateway routing nothing. Read Days before the total, because an empty result sums to zero too: three days of data and a zero total tells you nothing yet. Is it a standby, or does something occasional like a monthly batch job depend on it? If not, delete it, and release its Elastic IP while you are there to save an extra $44/year. Unassociated Elastic IPs are one of the most common things we find, sitting in nearly half the accounts we scan.

CLI · unassociated Elastic IPsaws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==null].{IP:PublicIp,AllocationId:AllocationId}' \
  --output table

4. Delete idle VPC endpoints

29%
Of accounts had it
$263/yr
Median saving

There are two kinds of VPC endpoint. Gateway endpoints for S3 and DynamoDB are free. Interface endpoints are the other kind: they reach other AWS services privately and bill about $7.30 a month per Availability Zone just to exist. Those are the ones worth checking, because they get left behind when the workload that needed them goes away.

How to check

Same management-account triage as before. Group VPC spend by usage type and account, then keep only the endpoint-hours rows:

CLI · Cost Explorer triageaws ce get-cost-and-usage \
  --time-period Start=$START,End=$END \
  --granularity MONTHLY --metrics UnblendedCost \
  --filter '{"Dimensions":{"Key":"SERVICE",
    "Values":["Amazon Virtual Private Cloud"]}}' \
  --group-by Type=DIMENSION,Key=USAGE_TYPE Type=DIMENSION,Key=LINKED_ACCOUNT \
  --query "ResultsByTime[0].Groups[?contains(Keys[0],'VpcEndpoint-Hours')].
    {Usage:Keys[0],Account:Keys[1],Cost:Metrics.UnblendedCost.Amount}" \
  --output table

Gateway endpoints are free and never appear here, so every dollar is an interface endpoint. Any account spending more than a few dollars a month is worth opening.

Then, in the flagged accounts, start with the inventory. This is the authoritative list of what you are paying for, and unlike anything metric-derived it does not depend on the endpoint having reported recently:

CLI · endpoint inventoryaws ec2 describe-vpc-endpoints \
  --filters "Name=vpc-endpoint-type,Values=Interface" \
  --query 'VpcEndpoints[].{ID:VpcEndpointId,Service:ServiceName,State:State}' \
  --output table

Now get the traffic. Interface endpoints publish BytesProcessed to CloudWatch under a four-dimension key, so the loop below discovers the endpoints from the metric dimensions and totals each one in the same pass:

CLI · traffic per endpointaws cloudwatch list-metrics --namespace AWS/PrivateLinkEndpoints \
  --metric-name BytesProcessed \
  --query "Metrics[?length(Dimensions)==`4`].
    [Dimensions[?Name=='VPC Endpoint Id'].Value|[0],
     Dimensions[?Name=='VPC Id'].Value|[0],
     Dimensions[?Name=='Service Name'].Value|[0]]" \
  --output text | sort -u | while read VPCE VPC SVC; do
  BYTES=$(aws cloudwatch get-metric-statistics \
    --namespace AWS/PrivateLinkEndpoints --metric-name BytesProcessed \
    --dimensions Name="VPC Endpoint Id",Value="$VPCE" Name="VPC Id",Value="$VPC" \
      Name="Endpoint Type",Value=Interface Name="Service Name",Value="$SVC" \
    --start-time $(( $(date -u +%s) - 2592000 )) --end-time $(date -u +%s) \
    --period 86400 --statistics Sum \
    --query 'sum(Datapoints[].Sum)' --output text)
  printf '%s\t%s\t%s bytes\n' "$VPCE" "${SVC##*.}" "$BYTES"
done

One line per endpoint: the endpoint ID, the service it fronts, and the total bytes it processed in 30 days. A zero is an endpoint nothing is talking to.

Then diff the two lists, because list-metrics only returns metrics that reported data in roughly the last two weeks. An endpoint that has been completely unused for longer than that never shows up in the loop at all, so the strongest signal is an endpoint that appears in describe-vpc-endpoints and is missing from the loop output entirely. Here silence is the finding, not a zero. Either way, confirm it is not a standby, then delete it.

Want to go deeper?

Many of the top 10 findings revolve around network setup. For an in-depth understanding of VPCs, NAT Gateways, Load Balancers etc., head over to our friends from AWS Fundamentals. There you’ll find clear explanations, easy to understand graphics & cheat sheets.

5. Delete old RDS snapshots

28%
Of accounts had it
$433/yr
Median saving

RDS gives you two kinds of snapshot and they behave differently. Automated backups expire on their own, aging out on whatever retention window you set. Manual snapshots do not. You take one before a risky migration or a version bump, it works, and the snapshot sits there forever because nothing ever deletes it. You cannot set expiry dates on manual snapshots, neither can you move them into a cheaper cold storage tier. Exporting a snapshot to S3 exists, but that is a separate analytics copy in Parquet, not a restorable backup.

How to check

Start on the management account. Group RDS spend by usage type and account and keep the backup rows, and one query tells you which accounts are worth opening before you list a single snapshot:

CLI · Cost Explorer triageaws ce get-cost-and-usage \
  --time-period Start=$START,End=$END \
  --granularity MONTHLY --metrics UnblendedCost \
  --filter '{"Dimensions":{"Key":"SERVICE",
    "Values":["Amazon Relational Database Service"]}}' \
  --group-by Type=DIMENSION,Key=USAGE_TYPE Type=DIMENSION,Key=LINKED_ACCOUNT \
  --query "ResultsByTime[0].Groups[?contains(Keys[0],'Backup')].
    {Usage:Keys[0],Account:Keys[1],Cost:Metrics.UnblendedCost.Amount}" \
  --output table

One thing that makes this cleaner than it looks: RDS backup storage is free up to 100% of your provisioned database storage in a region, and you only pay above that. So you might have old manual snapshots but still fall under the free backup storage tier, then there is nothing to do.

In the flagged accounts, list the manual snapshots oldest first with their size and tags, since a tag is often where a “do not delete” or compliance marker lives:

CLI · manual snapshotsaws rds describe-db-snapshots \
  --snapshot-type manual \
  --query 'sort_by(DBSnapshots,&SnapshotCreateTime)[].
    {ID:DBSnapshotIdentifier,DB:DBInstanceIdentifier,
     GB:AllocatedStorage,Created:SnapshotCreateTime,Tags:TagList}' \
  --output json

The AllocatedStorage value is your cost signal: sort by age to find the stale ones, but delete the large ones first, since storage is billed per GB. Read the tags before you touch anything, since a retention or “do not delete” tag is the cheapest possible confirmation that a snapshot is being kept on purpose. Beyond the tags, check your compliance obligations or ask the legal team, since some snapshots are held for a fixed retention period and look identical to junk from the outside. And if you need to keep snapshots for compliance, start using AWS Backup to manage them on a policy rather than by hand.

6. Delete unused EKS clusters

17%
Of accounts had it
$876/yr
Median saving

An EKS cluster bills a flat control-plane fee of about $0.10 an hour or $876 a year, before you run a single worker node. Someone spun one up to try something, or tore down the workloads but forgot to delete the cluster.

The way to check whether the cluster is active is to get the number of nodes in the cluster. Similar to memory on EC2, this metric is not available by default but requires Container Insights to be enabled (which costs money). The free hack is to ask the EKS and EC2 APIs directly, which always answer regardless of whether Container Insights is on.

How to check

List every cluster, then for each one ask whether anything is attached. No managed node groups and no Fargate profiles is the strong signal:

CLI · cluster inventoryaws eks list-clusters --query 'clusters' --output table

CLUSTER=my-cluster
aws eks list-nodegroups --cluster-name $CLUSTER \
  --query 'nodegroups' --output table
aws eks list-fargate-profiles --cluster-name $CLUSTER \
  --query 'fargateProfileNames' --output table

One gap to close by hand: self-managed nodes, meaning EC2 instances joined to the cluster directly rather than through a managed node group, will not show up in list-nodegroups. Catch those with a tag lookup, since every node in a cluster carries a kubernetes.io/cluster/<cluster-name> tag:

CLI · self-managed nodesaws ec2 describe-instances \
  --filters "Name=tag-key,Values=kubernetes.io/cluster/$CLUSTER" \
    "Name=instance-state-name,Values=running" \
  --query 'Reservations[].Instances[].InstanceId' --output table

Nothing from all three is an ACTIVE cluster with zero compute. Is it a standby, or does a team scale it to zero between batch runs? If not, delete it, and remember an empty cluster rarely stands alone: check for associated load balancers and security groups that should go with it.

That is the check, and it catches the common case well. Where it gets harder is running it across every cluster in every account rather than one at a time. The manual version answers the question for the cluster in front of you. A scanner answers it for all of them, and keeps answering as new clusters appear.

7. Rightsize RDS instances

14%
Of accounts had it
$3,504/yr
Median saving

RDS is a database AWS runs for you, billed per instance-hour on the class you pick, the same over-provisioning trap as EC2. A managed database is one of the most expensive single things you run, so an over-provisioned one wastes money quickly.

How to check

Start on the management account to see where the RDS money is, since this is a find-the-big-ones exercise, not a sweep:

CLI · Cost Explorer triageaws ce get-cost-and-usage \
  --time-period Start=$START,End=$END \
  --granularity MONTHLY \
  --metrics UnblendedCost \
  --filter '{"Dimensions":{"Key":"SERVICE",
    "Values":["Amazon Relational Database Service"]}}' \
  --group-by Type=DIMENSION,Key=LINKED_ACCOUNT \
  --query 'ResultsByTime[0].Groups[].{Account:Keys[0],
    Cost:Metrics.UnblendedCost.Amount}' \
  --output table

In the accounts with real RDS spend, list the instances with their class and whether they are Multi-AZ or read replicas, since both change what you can safely do:

CLI · instance inventoryaws rds describe-db-instances \
  --query 'DBInstances[].{ID:DBInstanceIdentifier,Class:DBInstanceClass,
    Engine:Engine,MultiAZ:MultiAZ,
    Replica:ReadReplicaSourceDBInstanceIdentifier}' \
  --output table

Then read CPU and memory to decide on a downsize. We suggest to use a 90-day window here, as a database is the resource most likely to have monthly or quarterly peaks.

CLI · CloudWatch CPU + memory (90 days)DB_ID=my-db-instance
aws cloudwatch get-metric-statistics \
  --namespace AWS/RDS --metric-name CPUUtilization \
  --dimensions Name=DBInstanceIdentifier,Value=$DB_ID \
  --start-time $(( $(date -u +%s) - 7776000 )) \
  --end-time $(date -u +%s) \
  --period 86400 --statistics Average Maximum \
  --query '{PeakMax:max(Datapoints[].Maximum),
    MeanAvg:avg(Datapoints[].Average),Days:length(Datapoints)}'

aws cloudwatch get-metric-statistics \
  --namespace AWS/RDS --metric-name FreeableMemory \
  --dimensions Name=DBInstanceIdentifier,Value=$DB_ID \
  --start-time $(( $(date -u +%s) - 7776000 )) \
  --end-time $(date -u +%s) \
  --period 86400 --statistics Average Minimum \
  --query '{MinBytes:min(Datapoints[].Minimum),
    AvgBytes:avg(Datapoints[].Average),Days:length(Datapoints)}'

FreeableMemory reports bytes, so divide by 1073741824 to get GiB. Turning that into a percentage needs the class’s total memory, and this is the one number no API here gives you: describe-db-instances reports the class, not its size. Look the class up in the DB instance class table in the AWS documentation (db.r6g.large is 16 GiB, for example) and write it down next to the instance, because every judgement below is a fraction of that figure. And check Days on both: fewer than 90 and you are judging a quarterly-peak workload on partial data.

Two conditions both have to hold before you shrink. MeanAvg CPU should sit low, in single digits, with no hard peaks: if PeakMax spikes toward maxing out even briefly, the database needs that ceiling and you leave it. The vCPU scaling from Finding 1 applies here too, so compare the peak against the vCPU count you are moving to, not the one you have. And at least half the memory should stay free across the whole window, measured on MinBytes, not the average.

When to skip

If you have RDS Reserved Instances: these are tied to a class, so moving off them results in paying both for the original reserved instance and the new on-demand instance. For replicas, keeping the same size as its writer is often a deliberate failover standby that must stay large enough to take over.

8. Delete idle VPN connections

14%
Of accounts had it
$432/yr
Median saving

A Site-to-Site VPN connection links your VPC to an on-premises network or another site over an encrypted tunnel. It bills a flat ~$0.05 an hour, or $432 a year, whether or not a single packet crosses it. Just another resource someone forgot.

How to check

Start on the management account to see which accounts even run VPN connections, since most will not:

CLI · Cost Explorer triageaws ce get-cost-and-usage \
  --time-period Start=$START,End=$END \
  --granularity MONTHLY \
  --metrics UnblendedCost \
  --filter '{"Dimensions":{"Key":"USAGE_TYPE_GROUP",
    "Values":["VPC: VPN Connection"]}}' \
  --group-by Type=DIMENSION,Key=LINKED_ACCOUNT \
  --query 'ResultsByTime[0].Groups[].{Account:Keys[0],
    Cost:Metrics.UnblendedCost.Amount}' \
  --output table

In a flagged account, get the connection IDs and what they attach to:

CLI · VPN inventoryaws ec2 describe-vpn-connections \
  --query 'VpnConnections[].{Id:VpnConnectionId,State:State,
    VGW:VpnGatewayId,TGW:TransitGatewayId}' \
  --output table

A VPN reads as idle in one of two ways, and it is worth checking both. The stronger signal is tunnels down: a live VPN keeps its tunnels up, and an abandoned one has them down because the far-end device is gone. The cleanest way to see this is the TunnelState metric, which reads 1 when both tunnels are up, 0.5 for one, and 0 when both are down. A 30-day average sitting at or near zero means the connection had no established tunnel for essentially the whole month:

CLI · tunnel stateVPN_ID=vpn-0123456789abcdef0
aws cloudwatch get-metric-statistics \
  --namespace AWS/VPN --metric-name TunnelState \
  --dimensions Name=VpnId,Value=$VPN_ID \
  --start-time $(( $(date -u +%s) - 2592000 )) \
  --end-time $(date -u +%s) \
  --period 86400 --statistics Average \
  --query '{AvgState:avg(Datapoints[].Average),Days:length(Datapoints)}'

The second case is subtler: the tunnel is up but nothing flows through it. Do not trust the byte metrics to read exactly zero, because a tunnel reports small amounts even when idle from keepalives and status checks. The test is order of magnitude: a real workload VPN moves gigabytes over 30 days, so total tunnel bytes in the low megabytes means nothing real is using it. Sum TunnelDataOut, and TunnelDataIn the same way:

CLI · tunnel trafficaws cloudwatch get-metric-statistics \
  --namespace AWS/VPN --metric-name TunnelDataOut \
  --dimensions Name=VpnId,Value=$VPN_ID \
  --start-time $(( $(date -u +%s) - 2592000 )) \
  --end-time $(date -u +%s) \
  --period 86400 --statistics Sum \
  --query '{TotalBytes:sum(Datapoints[].Sum),Days:length(Datapoints)}'

Tunnels down, or up but carrying only kilobytes, over a full 30 days is a connection nobody is using. But a down tunnel is genuinely indistinguishable from a cold-standby DR path from the AWS side, so this is the one check where you should not act on the metric alone: confirm with whoever owns the far end first. If it is truly retired, delete it, along with the customer gateway and any unused virtual private gateway that went with it.

9. Delete unused load balancers

14%
Of accounts had it
$222/yr
Median saving

A load balancer bills a flat hourly charge the moment it exists, around $16 a month for an Application or Network Load Balancer, before it serves a single request. The idle ones are the usual story: a service was retired, an environment was torn down, a blue-green cutover finished, but the load balancer in front of it stayed. The clean structural tell is that it has nothing behind it: no listeners, or listeners with no target groups. It routes nothing and bills its hourly rate anyway.

How to check

Start on the management account to see which accounts carry load balancer spend. Grouping by usage type as well as account keeps only the hourly LoadBalancerUsage rows, which is the fixed charge, and puts the biggest spenders at the top. Note the to_number: Cost Explorer returns Amount as a string, so sorting it raw is lexicographic and files $9.50 above $100.00.

CLI · Cost Explorer triageaws ce get-cost-and-usage \
  --time-period Start=$START,End=$END \
  --granularity MONTHLY --metrics UnblendedCost \
  --filter '{"Dimensions":{"Key":"SERVICE",
    "Values":["Amazon Elastic Load Balancing"]}}' \
  --group-by Type=DIMENSION,Key=USAGE_TYPE Type=DIMENSION,Key=LINKED_ACCOUNT \
  --query "reverse(sort_by(ResultsByTime[0].Groups[?
    ends_with(Keys[0],'LoadBalancerUsage')],
    &to_number(Metrics.UnblendedCost.Amount)))[].
    {Usage:Keys[0],Account:Keys[1],Cost:Metrics.UnblendedCost.Amount}" \
  --output table

Unlike most checks here, this one needs no metrics and no waiting: an unused load balancer is a structural fact you can read straight from the describe calls. List the modern load balancers (ALB, NLB, GLB), then for each one check whether it has any listeners and whether those listeners point at a target group:

CLI · listeners & target groupsaws elbv2 describe-load-balancers \
  --query 'LoadBalancers[].{Name:LoadBalancerName,
    ARN:LoadBalancerArn,Type:Type}' \
  --output table

LB_ARN=arn:aws:elasticloadbalancing:eu-west-1:111122223333:loadbalancer/app/my-lb/0123456789abcdef
aws elbv2 describe-listeners --load-balancer-arn $LB_ARN \
  --query 'Listeners[].{Port:Port,Proto:Protocol,
    Action:DefaultActions[0].Type,
    TG:DefaultActions[0].TargetGroupArn,
    Fwd:DefaultActions[0].ForwardConfig.TargetGroups[].TargetGroupArn}' \
  --output table

Read both target-group columns, not just one: a simple listener puts its target group in TG, while a weighted forward action puts them in Fwd, and a listener whose default action is fixed-response or redirect has no target group at all by design. No listeners, or listeners with no target group behind them, means the load balancer is routing nothing. That is the flag.

If you still run Classic load balancers, they have a different shape, check for no listeners or no registered instances instead:

CLI · Classic load balancersaws elb describe-load-balancers \
  --query 'LoadBalancerDescriptions[].{Name:LoadBalancerName,
    Instances:Instances,Listeners:ListenerDescriptions}' \
  --output json

Confirm the empty one is not a standby waiting for a service to come up behind it, then delete it.

10. Rightsize ElastiCache clusters

13%
Of accounts had it
$254/yr
Median saving

ElastiCache is Redis or Memcached that AWS runs for you, the same shape as EC2. Over-provisioning happens the same way: someone picks a node with headroom, the cache never fills it, and nobody shrinks it. You need to check two metrics before shrinking: memory underused and CPU not hot.

How to check

Start on the management account so you are not running metrics across every account blindly. Group ElastiCache spend by account to see where it is worth looking:

CLI · Cost Explorer triageaws ce get-cost-and-usage \
  --time-period Start=$START,End=$END \
  --granularity MONTHLY \
  --metrics UnblendedCost \
  --filter '{"Dimensions":{"Key":"SERVICE","Values":["Amazon ElastiCache"]}}' \
  --group-by Type=DIMENSION,Key=LINKED_ACCOUNT \
  --query 'ResultsByTime[0].Groups[].{Account:Keys[0],
    Cost:Metrics.UnblendedCost.Amount}' \
  --output table

Unlike the NAT and snapshot checks, this number is not the waste itself. Check the accounts with real ElastiCache bills, not just a few dollars.

Both metrics are in CloudWatch out of the box, no agent required. List the clusters in a flagged account, then pull 30 days of each.

CLI · cluster inventoryaws elasticache describe-cache-clusters \
  --query 'CacheClusters[].{ID:CacheClusterId,Node:CacheNodeType,Engine:Engine}' \
  --output table

For memory, use FreeableMemory. It works for both Redis and Memcached, unlike the Redis-only percentage metrics, but it reports free RAM in bytes, so you read it against the node’s total memory to judge how empty the node is:

CLI · memoryCACHE_ID=my-cache-cluster-001
aws cloudwatch get-metric-statistics \
  --namespace AWS/ElastiCache --metric-name FreeableMemory \
  --dimensions Name=CacheClusterId,Value=$CACHE_ID \
  --start-time $(( $(date -u +%s) - 2592000 )) \
  --end-time $(date -u +%s) \
  --period 86400 --statistics Average Minimum \
  --query '{AvgBytes:avg(Datapoints[].Average),
    MinBytes:min(Datapoints[].Minimum),Days:length(Datapoints)}'

As with RDS, the denominator is not in the API: describe-cache-clusters gives you CacheNodeType but not its size, so look the type up in the ElastiCache node type table in the AWS documentation. Use the published figure rather than the family’s nominal RAM, since it already accounts for engine overhead: a cache.r6g.large is listed at 13.07 GiB, not 16. Against that number, a node whose AvgBytes stays more than half of its total memory over 30 days is carrying more memory than its data needs, and MinBytes tells you how close the fullest moment came to the limit.

Then confirm CPU is not the constraint. For Redis, use EngineCPUUtilization, not general CPUUtilization: Redis runs commands on a single thread, so the general metric averages across all vCPUs and can look idle while the one thread that matters is pegged. On Memcached, which is multi-threaded, fall back to CPUUtilization:

CLI · engine CPUaws cloudwatch get-metric-statistics \
  --namespace AWS/ElastiCache --metric-name EngineCPUUtilization \
  --dimensions Name=CacheClusterId,Value=$CACHE_ID \
  --start-time $(( $(date -u +%s) - 2592000 )) \
  --end-time $(date -u +%s) \
  --period 86400 --statistics Maximum \
  --query '{PeakMax:max(Datapoints[].Maximum),Days:length(Datapoints)}'

The downsize is safe only when both line up: memory more than half free and peak engine CPU under roughly 40% across the whole 30 days. Then drop the node one size within the same family, which roughly halves both memory and cost.

When to skip

Check your ElastiCache reservations before shrinking, or you will book a saving that never shows up on the bill.

Beyond visibility: where the real work is

These are the most common findings across the accounts we scan, but every setup is different, and yours will have its own shape. The fastest way to see your own is to group Cost Explorer by usage type in the management account. That ranks your actual largest cost drivers first, across every linked account at once, and it will surface things this list does not: the usage type at the top of your bill is where your money is, whether or not it appears anywhere in this paper.

CLI · your top 20 cost driversaws ce get-cost-and-usage \
  --time-period Start=$START,End=$END \
  --granularity MONTHLY --metrics UnblendedCost \
  --group-by Type=DIMENSION,Key=USAGE_TYPE \
  --query "reverse(sort_by(ResultsByTime[0].Groups,
    &to_number(Metrics.UnblendedCost.Amount)))[:20].
    {Usage:Keys[0],Cost:Metrics.UnblendedCost.Amount}" \
  --output table

Start there, then work down.

A note on where these findings come from. Every one of them is something our own tool detects and validates automatically, and we wrote this paper because we know from building it how much manual work each check really is. The commands here look short, but AWS splits everything across accounts and regions, so doing this properly means running each check in every account, in every region, and repeating it as new resources pile up. That is the part that does not scale by hand.

But the harder problem is not finding the waste. We have talked to hundreds of startups and scaleups, and the thing that stops them is almost never visibility. It is making the decision to act, and then actually executing it. A list of findings sits untouched because someone has to gather the context, judge whether the change is safe, and then do it, and that work is what never gets prioritized. Our DevOps AI agent, Major Tom, closes exactly that gap: it finds each of these, gathers the metrics and context you would otherwise assemble yourself, and hands you a guided fix that usually takes a minute or less to apply, because everything you need to make the call is already in front of you.

Dr. Andreas Schroeter

Dr. Andreas Schroeter

Co-founder · Blocks

Company builder and co-founder of Blocks. Twenty years as a general manager across digital and media industries with deep expertise at the intersection of marketing, growth and product.

LinkedIn
Rijul Gogia

Rijul Gogia

Senior DevOps Engineer · Blocks

Senior DevOps building the infrastructure behind Blocks. Eight years as a DevOps and site reliability engineer, with deep expertise in cloud automation, Kubernetes, and cost.

LinkedIn

Get 20% off your AWS bill

Blocks members get 20% off their AWS bill. Guaranteed. Read-only access, no infra changes, no lock-in.