FinOps / Guide 6

Stop the bleeding: storage and orphaned resources

Orphaned disks, snapshots and IPs, wrong storage tiers and redundancy, missing lifecycle rules, and logs kept forever.

Some cloud costs don't come from anything running. They come from things left behind or set up with the wrong defaults: a disk nobody attached again, a storage account replicating test data to another region, backups sitting in the most expensive tier, logs kept forever. Each one is small, but together they leak money every month until someone looks. This guide is the checklist.

Orphaned resources

LeakAzureAWSGoogle Cloud
Disks not attached to anythingManaged disks with state UnattachedEBS volumes with state availablePersistent disks with no users
Old snapshotsSnapshots with no retention ruleEBS snapshots; move long-term ones to the EBS Snapshots Archive tier (up to 75% cheaper to store)Snapshots outside a snapshot schedule
Unused public IP addressesStandard public IPs bill whether attached or not (Basic SKU IPs were retired on 30 September 2025)Every public IPv4 address bills US$0.005 an hour, attached or notStatic external IPs that are reserved but not used
Other leftoversDetached NICs, empty resource groups, unused App Service plansIdle load balancers, unused NAT gatewaysIdle load balancers, unused Cloud NAT gateways
Script on this siteFind-AzOrphanedResources.ps1Find-AwsOrphanedResources.ps1Find-GcpOrphanedResources.ps1
# Quick checks from PowerShell
Get-AzDisk | Where-Object DiskState -eq 'Unattached' | Select-Object Name, ResourceGroupName, DiskSizeGB, @{n='Sku';e={$_.Sku.Name}}
Get-EC2Volume -Filter @{ Name = 'status'; Values = 'available' } | Select-Object VolumeId, Size, VolumeType, CreateTime
Get-EC2Address | Where-Object { -not $_.AssociationId } | Select-Object PublicIp, AllocationId
gcloud compute disks list --filter='-users:*' --format='table(name,zone.basename(),sizeGb,type.basename())'
gcloud compute addresses list --filter='status=RESERVED' --format='table(name,region.basename(),address)'

Before deleting a disk, check with the owner and take a final snapshot if there's any doubt. A snapshot costs a fraction of the disk, and it can be deleted later under a retention rule.

Wrong disk type

CheckAzureAWSGoogle Cloud
Premium disks on non-productionPremium SSD on dev and test; use Standard SSDio1/io2 where gp3 is enoughpd-ssd or extreme where pd-balanced is enough
Old disk type with a cheaper successorConsider Premium SSD v2 for production disks that need performancegp2 to gp3: about 20% cheaper per GB, changed online with no downtimepd-standard for boot disks is slow; pd-balanced is the usual default
# AWS: change a gp2 volume to gp3 in place (no downtime)
Edit-EC2Volume -VolumeId 'vol-0123456789abcdef0' -VolumeType gp3

Storage redundancy and tier

Object storage has two settings that quietly multiply the bill: how many copies are kept, and which tier the data sits in.

SettingAzureAWSGoogle Cloud
RedundancyLRS, ZRS, GRS, RA-GRS, GZRS. Geo-redundant options roughly double storage cost and add replication charges. Use LRS or ZRS for data you can re-create (test data, caches, build output).S3 keeps data across availability zones by default; Cross-Region Replication adds a second full copy. One Zone-IA is cheaper for re-creatable data.Region, dual-region or multi-region buckets. Use a single region for data that doesn't need geo-redundancy.
Tiers and minimum storage timeHot (none), Cool (30 days), Cold (90 days), Archive (180 days, offline, rehydration up to 15 hours)Standard (none), Standard-IA and One Zone-IA (30 days), Glacier Instant Retrieval and Flexible Retrieval (90 days), Glacier Deep Archive (180 days)Standard (none), Nearline (30 days), Coldline (90 days), Archive (365 days)
Automatic tieringSmart tier (moves blobs between hot, cool and cold by usage)S3 Intelligent-Tiering (small monthly monitoring fee; objects under 128 KB aren't tiered)Autoclass
Account or bucket defaultGeneral-purpose v2 accounts default to HotObjects are written to Standard unless told otherwiseBucket default class is Standard unless set

Two traps: deleting or moving data before its minimum storage time is charged as if it had stayed, and the infrequent-access tiers charge for each read. Cooler tiers suit data that is written once and rarely read, such as backups, archives and old logs, not data that's read every day.

# Azure: redundancy and default tier for every storage account
Get-AzStorageAccount | Select-Object StorageAccountName, ResourceGroupName, @{n='Redundancy';e={$_.Sku.Name}}, AccessTier

Lifecycle management

Lifecycle rules move data to cheaper tiers as it ages and delete it when it's no longer needed. Without them, everything stays in the tier it was written to, forever. Check every bucket or storage account holding backups, logs, exports or uploads.

AzureAWSGoogle Cloud
FeatureBlob lifecycle management policy (per storage account)S3 Lifecycle configuration (per bucket)Object Lifecycle Management (per bucket)
Typical ruleCool after 30 days, Cold after 90, Archive after 180, delete after the retention periodStandard-IA after 30 days, Glacier after 90, Deep Archive after 180, expire after the retention periodNearline after 30 days, Coldline after 90, Archive after 365, delete after the retention period
Also clean upOld blob versions and snapshotsIncomplete multipart uploads and noncurrent versions on versioned bucketsNoncurrent versions on versioned buckets; review soft delete retention
# Find storage without lifecycle rules
Get-AzStorageAccount | ForEach-Object {
    $p = Get-AzStorageAccountManagementPolicy -ResourceGroupName $_.ResourceGroupName -StorageAccountName $_.StorageAccountName -ErrorAction SilentlyContinue
    [pscustomobject]@{ Account = $_.StorageAccountName; HasLifecyclePolicy = [bool]$p }
}
Get-S3Bucket | ForEach-Object {
    $rules = try { Get-S3LifecycleConfiguration -BucketName $_.BucketName -ErrorAction Stop } catch { $null }
    [pscustomobject]@{ Bucket = $_.BucketName; LifecycleRules = @($rules.Rules).Count }
}
gcloud storage buckets list --format='value(name)' | ForEach-Object {
    $lc = gcloud storage buckets describe "gs://$_" --format='value(lifecycle_config)'
    [pscustomobject]@{ Bucket = $_; HasLifecycleRules = [bool]$lc }
}

Versioning and backups that never expire

  • Versioned buckets keep every overwritten and deleted object. Without a rule for noncurrent versions, a bucket that is overwritten nightly grows every night.
  • Backup retention: check backup policies keep what the business needs, not "forever by default". Daily backups kept for years add up fast. The backup coverage reports help here: Get-AzBackupCoverage.ps1, Get-AwsBackupCoverage.ps1 and Get-GcpBackupCoverage.ps1.
  • Snapshots made by hand before a change and never removed. Name them with a date and owner, and delete them after the change is signed off.

Logs kept forever

AzureAWSGoogle Cloud
Where logs pile upLog Analytics workspaces; diagnostic settings sending everythingCloudWatch Logs log groups (default retention: never expire)Cloud Logging buckets; logs routed to storage or BigQuery
FixSet table retention; send verbose tables to the Basic or Auxiliary plan; trim diagnostic settingsSet a retention period on every log groupSet bucket retention; exclude noisy logs with exclusion filters
# AWS: log groups with no retention, then set 90 days on one
Get-CWLLogGroup | Where-Object { -not $_.RetentionInDays } | Select-Object LogGroupName, StoredBytes
Write-CWLRetentionPolicy -LogGroupName '/aws/lambda/notes-test' -RetentionInDays 90

Data transfer

Traffic leaving the cloud to the internet, and traffic between regions, is charged per GB. Traffic between availability zones is charged on AWS and Google Cloud too. Watch for chatty services split across regions, backups copied to another region every night, and NAT gateways processing traffic that could use private endpoints or gateway endpoints instead. The networking comparison covers private access to cloud services in each provider.