Cost optimisation starts with visibility. Cutting without data means guessing, and guesses tend to hit the wrong things: the small, visible costs instead of the large, quiet ones. This post sets up the measurement. Part 2 covers what was found and changed, and how the bill came down by one-third.
The loop
flowchart LR
I[Inform<br/>tags, reports, owners] --> O[Optimise<br/>remove, resize, commit]
O --> P[Operate<br/>budgets, reviews, guardrails]
P --> I
This is the FinOps loop: inform (who spends what), optimise (change things), operate (keep it from creeping back). One pass gives a saving; the loop keeps it.
1. Tag everything and enforce it
Without tags, a bill is a list of services. With tags, it is a list of teams, products and environments that someone owns.
| Tag key | Example values | Answers |
|---|---|---|
Project |
shop, hcm, reporting | Which product is this for? |
Env |
prod, stage, dev, test | Can it be turned off at night? |
Owner |
team or person | Who do I ask before deleting it? |
CostCenter |
finance code | Who pays? |
Enforce the keys and allowed values with an AWS Organizations tag policy:
{
"tags": {
"Env": {
"tag_key": { "@@assign": "Env" },
"tag_value": { "@@assign": ["prod", "stage", "dev", "test"] },
"enforced_for": { "@@assign": ["ec2:instance", "ec2:volume", "rds:db", "s3:bucket"] }
}
}
}
Then activate the keys as cost allocation tags in the Billing console. Only activated tags appear in Cost Explorer and exports, and only from the day they are activated.
Find untagged resources with the Resource Groups Tagging API:
aws resourcegroupstaggingapi get-resources --region ap-south-1 \
--query 'ResourceTagMappingList[?!not_null(Tags[?Key==`Owner`])].ResourceARN' --output text
2. Read the bill top-down in Cost Explorer
Work from the biggest lines down. Three views explain most bills:
- Group by Service, last 3 months, monthly. Which services dominate, and which are growing?
- Filter to the top service, group by Usage type. For EC2, this separates instance hours from EBS, data transfer, NAT gateway processing and public IPv4 addresses.
- Group by tag (
Env, thenProject). How much is non-production? Non-production is often a quarter or more of a bill and is the easiest to reduce.
Typical places money hides:
| Usage type pattern | What it is |
|---|---|
BoxUsage |
EC2 instance hours |
EBS:VolumeUsage.gp2, gp3 |
Volume storage, including unattached volumes |
EBS:SnapshotUsage |
Snapshots, often years of them |
NatGateway-Bytes |
Data processed by NAT gateways |
DataTransfer-Regional-Bytes |
Cross-AZ traffic |
PublicIPv4:InUseAddress, IdleAddress |
Public IPv4 charges, since February 2024 |
TimedStorage-ByteHrs |
S3 storage |
Requests-Tier1 |
S3 PUT and LIST requests |
DataTransfer-Out-Bytes (CloudFront) |
CDN egress |
3. Line-level detail with Data Exports and Athena
Cost Explorer is good for trends. For questions such as “which exact resources cost the most”, create a Data Export (the CUR 2.0 format) to S3 and query it with Athena.
-- top 20 resources last month
SELECT line_item_product_code AS service,
line_item_resource_id AS resource,
round(sum(line_item_unblended_cost), 2) AS cost
FROM cur.data_export
WHERE billing_period = '2026-08'
GROUP BY 1, 2
ORDER BY cost DESC
LIMIT 20;
-- cost by environment tag
SELECT resource_tags['user_env'] AS env,
round(sum(line_item_unblended_cost), 2) AS cost
FROM cur.data_export
WHERE billing_period = '2026-08'
GROUP BY 1
ORDER BY cost DESC;
Column and tag names depend on your export settings, so check them with SHOW COLUMNS first.
4. Let AWS point at the obvious
| Tool | Finds |
|---|---|
| Cost Optimization Hub | One list across accounts: idle resources, rightsizing, Graviton moves, Savings Plans, with estimated savings |
| Compute Optimizer | Over-provisioned EC2, EBS, Lambda, ECS on Fargate and RDS, based on CloudWatch utilisation |
| Trusted Advisor (cost checks) | Idle load balancers, underused EBS volumes, unassociated Elastic IPs |
| S3 Storage Lens | Buckets with no lifecycle rules, incomplete multipart uploads, noncurrent versions |
Enable Compute Optimizer early: it needs about two weeks of metrics before recommendations are useful.
5. Find idle and orphaned resources yourself
A few CLI queries catch most of the leftovers. Run them per region.
Unattached EBS volumes:
aws ec2 describe-volumes --filters Name=status,Values=available \
--query 'Volumes[].{ID:VolumeId,GB:Size,Type:VolumeType,Created:CreateTime}' --output table
Elastic IPs not attached to anything:
aws ec2 describe-addresses \
--query 'Addresses[?AssociationId==null].{IP:PublicIp,Alloc:AllocationId,Tags:Tags}' --output table
Old snapshots:
aws ec2 describe-snapshots --owner-ids self \
--query "Snapshots[?StartTime<'2025-09-01'].{ID:SnapshotId,GB:VolumeSize,Date:StartTime,Desc:Description}" \
--output table
Instances with very low CPU over 14 days:
for id in $(aws ec2 describe-instances --filters Name=instance-state-name,Values=running \
--query 'Reservations[].Instances[].InstanceId' --output text); do
avg=$(aws cloudwatch get-metric-statistics --namespace AWS/EC2 --metric-name CPUUtilization \
--dimensions Name=InstanceId,Value=$id --statistics Average --period 1209600 \
--start-time $(date -u -d '14 days ago' +%FT%TZ) --end-time $(date -u +%FT%TZ) \
--query 'Datapoints[0].Average' --output text)
echo "$id $avg"
done | awk '$2 != "None" && $2 < 5' | sort -k2 -n
Load balancers with no healthy targets:
for tg in $(aws elbv2 describe-target-groups --query 'TargetGroups[].TargetGroupArn' --output text); do
n=$(aws elbv2 describe-target-health --target-group-arn $tg --query 'length(TargetHealthDescriptions)')
[ "$n" = "0" ] && echo "empty target group: $tg"
done
CloudWatch log groups that never expire:
aws logs describe-log-groups \
--query 'logGroups[?retentionInDays==null].{Name:logGroupName,MB:storedBytes}' --output table
Stopped instances still pay for their volumes and any Elastic IPs:
aws ec2 describe-instances --filters Name=instance-state-name,Values=stopped \
--query 'Reservations[].Instances[].{ID:InstanceId,Name:Tags[?Key==`Name`]|[0].Value,Since:StateTransitionReason}' \
--output table
Put the results in a shared sheet with an owner and a decision (keep, resize, delete) per row. Nothing is deleted without an owner’s yes, or a tag-and-wait period for orphans.
6. Alerts so nothing surprises you again
Budgets per account or per Project tag, with alerts on actual and forecast spend:
{
"BudgetName": "shop-monthly",
"BudgetLimit": { "Amount": "1000", "Unit": "USD" },
"BudgetType": "COST",
"TimeUnit": "MONTHLY",
"CostFilters": { "TagKeyValue": ["user:Project$shop"] }
}
Set the amount to your own baseline. Add notifications at 80 percent of actual spend and 100 percent of forecast.
Cost Anomaly Detection with a monitor per linked account or cost category, and a daily email or Slack alert for anomalies above a small threshold. It catches a forgotten load test or a runaway log group within a day, not at the end of the month.
7. Write down the baseline
Before changing anything, record:
- The last three months’ total and the breakdown by service and environment.
- Unit costs that matter to the business, such as cost per active customer or per thousand requests.
Every saving is measured against this baseline. Without it, “we saved a lot” is an opinion.
Summary
| Step | Outcome |
|---|---|
| Tags enforced and activated | Every cost has an owner |
| Cost Explorer top-down | The big lines are known |
| Data Exports and Athena | Exact resources are known |
| Hub, Compute Optimizer, Storage Lens | AWS’s own recommendations collected |
| Idle-resource queries | A list of candidates with owners |
| Budgets and anomaly alerts | Surprises caught within a day |
| Baseline recorded | Savings can be proven |
Part 2 is what came out of this: the changes made and how they added up to a one-third reduction.