Cloud sprawl audit - how to identify and reduce uncontrolled cloud infrastructure growth
Identify and reduce cloud sprawl: audit orphaned resources, unused accounts, untagged infrastructure, and duplicate services. CLI commands for AWS, GCP, and Azure with cost recovery estimates.
Cloud sprawl is uncontrolled growth of cloud resources, accounts, and services without governance. Unlike deliberate multi-cloud architecture, sprawl happens organically: a developer spins up an account “for testing”, a team adopts a new tool without decommissioning the old one, or an acquisition adds infrastructure nobody inventoried.
For guidance on when multiple clouds are a deliberate architectural choice vs. sprawl, see our multi-cloud strategy guide.
Symptoms of cloud sprawl
You likely have cloud sprawl if three or more of these are true:
- Monthly cloud bill growing 10%+ without corresponding business growth
- Resources exist with no owner (nobody knows who created them or why)
- Multiple accounts/subscriptions with no naming convention or tagging
- Duplicate services (e.g. two logging stacks, three monitoring tools)
- “Shadow IT” cloud accounts outside the organisation’s billing
- Abandoned PoC environments still incurring costs
- Cost allocation: >30% of spend is untagged
Phase 1: Inventory - what do you actually have?
AWS: list all accounts in the organisation
# List all AWS accounts in the organisation
aws organizations list-accounts \
--query 'Accounts[].{Id:Id, Name:Name, Email:Email, Status:Status}' \
--output table
# Find accounts with no recent activity (no CloudTrail events in 90 days)
for account_id in $(aws organizations list-accounts --query 'Accounts[].Id' --output text); do
event_count=$(aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=AccountId,AttributeValue=$account_id \
--start-time $(date -d '90 days ago' +%Y-%m-%dT%H:%M:%S) \
--max-results 1 --query 'length(Events)' --output text 2>/dev/null)
if [ "$event_count" = "0" ]; then
echo "INACTIVE: $account_id"
fi
done
AWS: find orphaned resources
# Unattached EBS volumes (paying for storage, used by nothing)
aws ec2 describe-volumes \
--filters Name=status,Values=available \
--query 'Volumes[].{VolumeId:VolumeId, Size:Size, CreateTime:CreateTime}' \
--output table
# Elastic IPs not attached to anything ($3.60/mo each since Feb 2024)
aws ec2 describe-addresses \
--query 'Addresses[?AssociationId==null].{PublicIp:PublicIp, AllocationId:AllocationId}' \
--output table
# Stopped EC2 instances (still paying for EBS)
aws ec2 describe-instances \
--filters Name=instance-state-name,Values=stopped \
--query 'Reservations[].Instances[].{Id:InstanceId, Name:Tags[?Key==`Name`]|[0].Value, StoppedSince:StateTransitionReason}' \
--output table
# Load Balancers with no targets
aws elbv2 describe-target-groups \
--query 'TargetGroups[?length(LoadBalancerArns)==`0`].{Arn:TargetGroupArn, Name:TargetGroupName}' \
--output table
# RDS snapshots older than 90 days (manual, forgotten)
aws rds describe-db-snapshots \
--snapshot-type manual \
--query "DBSnapshots[?SnapshotCreateTime<='$(date -d '90 days ago' +%Y-%m-%d)'].{Id:DBSnapshotIdentifier, Size:AllocatedStorage, Created:SnapshotCreateTime}" \
--output table
GCP: find orphaned resources
# List all projects (look for ones nobody recognises)
gcloud projects list --format="table(projectId, name, lifecycleState, createTime)"
# Unattached persistent disks
gcloud compute disks list --filter="NOT users:*" \
--format="table(name, zone, sizeGb, status, lastAttachTimestamp)"
# Static IPs not in use
gcloud compute addresses list --filter="status=RESERVED" \
--format="table(name, address, region, status)"
# GKE clusters with zero workloads
for cluster in $(gcloud container clusters list --format="value(name,zone)"); do
name=$(echo $cluster | cut -f1)
zone=$(echo $cluster | cut -f2)
pods=$(gcloud container clusters get-credentials $name --zone $zone 2>/dev/null && \
kubectl get pods --all-namespaces --no-headers 2>/dev/null | grep -v kube-system | wc -l)
echo "$name ($zone): $pods non-system pods"
done
Azure: find orphaned resources
# List all subscriptions
az account list --query "[].{Name:name, Id:id, State:state}" --output table
# Unattached managed disks
az disk list --query "[?managedBy==null].{Name:name, Size:diskSizeGb, ResourceGroup:resourceGroup}" \
--output table
# Public IPs not associated
az network public-ip list \
--query "[?ipConfiguration==null].{Name:name, IP:ipAddress, ResourceGroup:resourceGroup}" \
--output table
# App Service Plans with no apps
az appservice plan list \
--query "[?numberOfSites==\`0\`].{Name:name, Sku:sku.name, ResourceGroup:resourceGroup}" \
--output table
Phase 2: Cost analysis - where is money leaking?
Find untagged spend
# AWS: percentage of untagged cost (last 30 days)
aws ce get-cost-and-usage \
--time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
--granularity MONTHLY \
--metrics BlendedCost \
--group-by Type=TAG,Key=team \
--query 'ResultsByTime[0].Groups[?Keys[0]==``].Metrics.BlendedCost.Amount' \
--output text
# If this number is > 30% of total spend, you have a tagging problem
Cost per account/project
# AWS: cost breakdown by linked account
aws ce get-cost-and-usage \
--time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
--granularity MONTHLY \
--metrics BlendedCost \
--group-by Type=DIMENSION,Key=LINKED_ACCOUNT \
--query 'ResultsByTime[0].Groups[].{Account:Keys[0], Cost:Metrics.BlendedCost.Amount}' \
--output table
# GCP: cost by project (last 30 days via billing export in BigQuery)
bq query --use_legacy_sql=false '
SELECT project.name, SUM(cost) as total_cost
FROM `billing_dataset.gcp_billing_export`
WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
GROUP BY project.name
ORDER BY total_cost DESC
'
Typical findings and savings
| Issue | Prevalence | Typical monthly waste |
|---|---|---|
| Orphaned EBS volumes | 60% of orgs | $50-500/mo |
| Stopped instances with attached EBS | 40% of orgs | $100-1,000/mo |
| Forgotten RDS snapshots | 50% of orgs | $50-300/mo |
| Unused Elastic IPs | 30% of orgs | $10-50/mo |
| Abandoned test accounts/projects | 25% of orgs | $200-2,000/mo |
| Duplicate monitoring tools | 40% of orgs | $300-1,500/mo |
Phase 3: Governance - prevent sprawl from returning
Tagging policy (minimum viable)
Every resource must have at minimum:
# Minimum required tags - enforce via AWS Organizations SCP or Azure Policy
tags:
team: "platform" # Who owns this?
environment: "production" # prod / staging / dev / sandbox
service: "api-gateway" # What business service does this support?
cost-centre: "eng-012" # Who pays for this?
Enforcement:
# AWS: SCP to deny resource creation without required tags
# Apply to all accounts except the management account
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyUntaggedEC2",
"Effect": "Deny",
"Action": ["ec2:RunInstances"],
"Resource": ["arn:aws:ec2:*:*:instance/*"],
"Condition": {
"Null": {
"aws:RequestTag/team": "true",
"aws:RequestTag/environment": "true"
}
}
}
]
}
Account/project lifecycle policy
- Creation - requires approval + documented purpose + owner + expiry date
- Review - quarterly check: is this account still needed? Does it have active workloads?
- Sunset - if no activity for 90 days: notify owner → 30-day grace period → decommission
- Sandbox accounts - auto-nuke after 7 days (use tools like
aws-nukeorcloud-nuke)
# Install cloud-nuke for automated sandbox cleanup
# https://github.com/gruntwork-io/cloud-nuke
cloud-nuke aws --older-than 168h --config .cloud-nuke.yml --dry-run
Monitoring sprawl indicators
Set up alerts for early sprawl detection:
- New AWS account created without required tags
- Monthly cost increase > 15% without matching deployment activity
- Resources older than 90 days with no CloudTrail activity
- New subscriptions/projects created outside approved process
Phase 4: Reduction - what to delete and what to consolidate
Decision matrix
| Resource status | Action | Risk |
|---|---|---|
| No owner + no traffic for 90 days | Delete (after snapshot) | Low |
| Has owner + no traffic for 90 days | Notify owner, 30-day deadline | Low |
| Duplicate tool (2 monitoring stacks) | Consolidate to one | Medium |
| Abandoned test account | Decommission entirely | Low |
| Account with active prod workload but no governance | Add tags, add to organisation, assign owner | None |
Safe deletion process
# Before deleting anything: create a final snapshot/backup
# EBS volume
aws ec2 create-snapshot --volume-id vol-xxx --description "pre-deletion backup"
aws ec2 delete-volume --volume-id vol-xxx
# RDS snapshot cleanup (keep only the latest, delete older)
aws rds delete-db-snapshot --db-snapshot-identifier old-snapshot-name
# Elastic IP release
aws ec2 release-address --allocation-id eipalloc-xxx
Consolidation candidates
If you find duplicate services, consolidate to one:
| Duplicate | Consolidate to | Reason |
|---|---|---|
| CloudWatch + Datadog + Grafana | Pick one (Datadog OR Grafana Cloud) | Three tools = triple cost, nobody looks at all three |
| AWS accounts with identical workloads | Merge into shared account with namespacing | Reduces account management overhead |
| Multiple CI/CD tools (Jenkins + GitHub Actions + CodePipeline) | GitHub Actions (or your primary) | One pipeline standard, one set of credentials |
Validation
After cleanup, verify:
# Check: no more unattached EBS volumes
aws ec2 describe-volumes --filters Name=status,Values=available --query 'length(Volumes)'
# Expected: 0
# Check: no unattached Elastic IPs
aws ec2 describe-addresses --query 'length(Addresses[?AssociationId==null])'
# Expected: 0
# Check: tagging compliance (should be > 90%)
aws ce get-cost-and-usage \
--time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
--granularity MONTHLY \
--metrics BlendedCost \
--group-by Type=TAG,Key=team \
--query 'ResultsByTime[0].Groups[?Keys[0]!=``].Metrics.BlendedCost.Amount' --output text
# Calculate: tagged_cost / total_cost * 100 > 90%
Schedule this audit quarterly. First run typically saves 15-30% of monthly cloud spend through orphaned resource cleanup alone.
Next steps
- Set up automated alerts for sprawl indicators (AWS Budgets, GCP Budget alerts)
- Implement tag-based cost allocation dashboards (see our showback model guide)
- Consider AWS Organizations with SCPs for governance (or GCP Organisation Policies)
- Review multi-cloud architecture: is it deliberate or sprawl? (see our multi-cloud strategy guide)
If your cloud infrastructure has grown beyond what your team can govern effectively, we can run this audit with you and implement the governance framework. Book a free consultation to discuss your situation.