Your Cloud Storage Bucket Has 2TB of Data Nobody Touched in 6 Months (You’re Paying 16x Too Much) 💾

Standard storage costs $0.020/GB/month. Archive storage costs $0.0012/GB/month. That’s a 94% difference. If you have 2TB of data sitting untouched for 6 months in a Standard bucket, you’re paying $40/month for something that should cost $2.40. Lifecycle policies fix this automatically, and Terraform deploys them in 5 minutes.

Here’s the dirty secret of Cloud Storage: every object starts in Standard class and stays there forever unless you do something about it. GCP won’t move your data to a cheaper tier for you. That 18-month-old log archive? Still in Standard. Those backup snapshots from last year? Standard. That ML training dataset you ran once? Standard.

Lifecycle policies automatically transition objects to cheaper storage classes as they age. No manual work, no scripts, no cron jobs. Just Terraform rules that save money while you sleep.

📊 The Storage Class Cheat Sheet

Class Cost/GB/month Min Duration Best For Retrieval Fee
Standard $0.020 None Hot data, frequent access Free
Nearline $0.010 30 days Accessed < 1x/month $0.01/GB
Coldline $0.004 90 days Accessed < 1x/quarter $0.02/GB
Archive $0.0012 365 days Accessed < 1x/year $0.05/GB

The savings are massive:

Data Size Standard/year With Lifecycle/year Savings
1 TB $240 ~$65 $175 (73%)
5 TB $1,200 ~$325 $875 (73%)
10 TB $2,400 ~$650 $1,750 (73%)
50 TB $12,000 ~$3,250 $8,750 (73%)

⚠️ All storage classes have the same 11 nines of durability and millisecond access latency. Archive storage is NOT like tape. Your data is instantly accessible. You just pay more to read it back.

🔧 Step 1: The Standard Lifecycle Pipeline

This is the pattern that works for 90% of buckets: Standard -> Nearline -> Coldline -> Archive -> Delete.

resource "google_storage_bucket" "data" {
  name     = "${var.project_id}-app-data"
  location = "US"

  # Start in Standard
  storage_class = "STANDARD"

  # 30 days -> Nearline
  lifecycle_rule {
    action {
      type          = "SetStorageClass"
      storage_class = "NEARLINE"
    }
    condition {
      age                   = 30
      matches_storage_class = ["STANDARD"]
    }
  }

  # 90 days -> Coldline
  lifecycle_rule {
    action {
      type          = "SetStorageClass"
      storage_class = "COLDLINE"
    }
    condition {
      age                   = 90
      matches_storage_class = ["NEARLINE"]
    }
  }

  # 365 days -> Archive
  lifecycle_rule {
    action {
      type          = "SetStorageClass"
      storage_class = "ARCHIVE"
    }
    condition {
      age                   = 365
      matches_storage_class = ["COLDLINE"]
    }
  }

  # 730 days (2 years) -> Delete
  lifecycle_rule {
    action {
      type = "Delete"
    }
    condition {
      age = 730
    }
  }

  labels = local.common_labels
}

⚠️ Gotcha: Lifecycle transitions done by lifecycle rules do NOT incur early deletion fees when moving down the chain (Standard -> Nearline -> Coldline -> Archive). But if you manually rewrite an object to a different class, you pay both retrieval and early deletion fees. Always let lifecycle rules handle the transitions.

📋 Step 2: Different Rules for Different Data

Not all data ages the same. Logs can be archived aggressively. User uploads need to stay hot longer. Here’s a bucket with prefix-based rules:

resource "google_storage_bucket" "multi_tier" {
  name     = "${var.project_id}-multi-tier-data"
  location = "US"

  # Logs: aggressive lifecycle (cheap and disposable)
  lifecycle_rule {
    action {
      type          = "SetStorageClass"
      storage_class = "NEARLINE"
    }
    condition {
      age              = 7
      matches_prefix   = ["logs/"]
      matches_storage_class = ["STANDARD"]
    }
  }

  lifecycle_rule {
    action {
      type          = "SetStorageClass"
      storage_class = "COLDLINE"
    }
    condition {
      age              = 30
      matches_prefix   = ["logs/"]
      matches_storage_class = ["NEARLINE"]
    }
  }

  lifecycle_rule {
    action {
      type = "Delete"
    }
    condition {
      age            = 90
      matches_prefix = ["logs/"]
    }
  }

  # Backups: keep longer, archive aggressively
  lifecycle_rule {
    action {
      type          = "SetStorageClass"
      storage_class = "COLDLINE"
    }
    condition {
      age              = 30
      matches_prefix   = ["backups/"]
      matches_storage_class = ["STANDARD"]
    }
  }

  lifecycle_rule {
    action {
      type          = "SetStorageClass"
      storage_class = "ARCHIVE"
    }
    condition {
      age              = 90
      matches_prefix   = ["backups/"]
      matches_storage_class = ["COLDLINE"]
    }
  }

  lifecycle_rule {
    action {
      type = "Delete"
    }
    condition {
      age            = 730  # Keep backups for 2 years
      matches_prefix = ["backups/"]
    }
  }

  # User uploads: stay in Standard longer
  lifecycle_rule {
    action {
      type          = "SetStorageClass"
      storage_class = "NEARLINE"
    }
    condition {
      age              = 90
      matches_prefix   = ["uploads/"]
      matches_storage_class = ["STANDARD"]
    }
  }

  labels = local.common_labels
}

This single bucket now handles three different data patterns automatically. Logs get deleted after 90 days, backups live for 2 years in Archive, and user uploads stay hot for 3 months.

🧹 Step 3: Clean Up Versioned Objects and Incomplete Uploads

Versioning is great for data protection, but it silently doubles or triples your storage costs if you don’t manage old versions:

resource "google_storage_bucket" "versioned" {
  name     = "${var.project_id}-versioned-data"
  location = "US"

  versioning {
    enabled = true
  }

  # Keep only the 3 most recent versions
  lifecycle_rule {
    action {
      type = "Delete"
    }
    condition {
      num_newer_versions = 3
      with_state         = "ARCHIVED"
    }
  }

  # Delete noncurrent versions older than 90 days
  lifecycle_rule {
    action {
      type = "Delete"
    }
    condition {
      age        = 90
      with_state = "ARCHIVED"
    }
  }

  # Clean up incomplete multipart uploads after 7 days
  lifecycle_rule {
    action {
      type = "AbortIncompleteMultipartUpload"
    }
    condition {
      age = 7
    }
  }

  labels = local.common_labels
}

⚠️ Hidden cost killer: Incomplete multipart uploads are invisible in the console but still count against your storage. If your app does large file uploads and sometimes fails midway, these fragments pile up silently. The AbortIncompleteMultipartUpload rule is free insurance.

🤖 Step 4: Autoclass (When You Don’t Know Your Access Pattern)

If you genuinely don’t know how often data gets accessed, GCP’s Autoclass feature automatically moves objects between storage classes based on actual access patterns:

resource "google_storage_bucket" "auto_tiered" {
  name     = "${var.project_id}-auto-tiered"
  location = "US"

  autoclass {
    enabled                = true
    terminal_storage_class = "ARCHIVE"  # Lowest tier it can reach
  }

  labels = local.common_labels
}

Autoclass moves frequently accessed data back UP to Standard (lifecycle rules can’t do this) and moves untouched data down through the tiers automatically. There’s a small management fee, but for unpredictable access patterns it saves more than it costs.

When to use Autoclass vs manual lifecycle rules:

Scenario Use
Known access pattern (logs, backups) Manual lifecycle rules
Unknown/mixed access patterns Autoclass
Data that might get re-accessed after months Autoclass
Compliance requirements (must be in specific class) Manual lifecycle rules
Maximum cost control Manual lifecycle rules

🏢 Reusable Module for Consistent Lifecycle Policies

Deploy the same lifecycle rules across every team’s buckets:

variable "buckets" {
  type = map(object({
    location       = string
    lifecycle_type = string  # "logs", "backups", "general", "autoclass"
  }))
  default = {
    "team-alpha-logs" = {
      location       = "US"
      lifecycle_type = "logs"
    }
    "team-beta-backups" = {
      location       = "US"
      lifecycle_type = "backups"
    }
    "ml-training-data" = {
      location       = "US"
      lifecycle_type = "autoclass"
    }
  }
}

locals {
  lifecycle_configs = {
    logs = {
      nearline_age = 7
      coldline_age = 30
      delete_age   = 90
    }
    backups = {
      nearline_age = 30
      coldline_age = 90
      delete_age   = 730
    }
    general = {
      nearline_age = 30
      coldline_age = 90
      delete_age   = 365
    }
  }
}

resource "google_storage_bucket" "managed" {
  for_each = {
    for k, v in var.buckets : k => v
    if v.lifecycle_type != "autoclass"
  }

  name          = "${var.project_id}-${each.key}"
  location      = each.value.location
  storage_class = "STANDARD"

  lifecycle_rule {
    action {
      type          = "SetStorageClass"
      storage_class = "NEARLINE"
    }
    condition {
      age = local.lifecycle_configs[each.value.lifecycle_type].nearline_age
      matches_storage_class = ["STANDARD"]
    }
  }

  lifecycle_rule {
    action {
      type          = "SetStorageClass"
      storage_class = "COLDLINE"
    }
    condition {
      age = local.lifecycle_configs[each.value.lifecycle_type].coldline_age
      matches_storage_class = ["NEARLINE"]
    }
  }

  lifecycle_rule {
    action {
      type = "Delete"
    }
    condition {
      age = local.lifecycle_configs[each.value.lifecycle_type].delete_age
    }
  }

  lifecycle_rule {
    action {
      type = "AbortIncompleteMultipartUpload"
    }
    condition {
      age = 7
    }
  }

  labels = local.common_labels
}

New bucket needed? Add one entry to the map. terraform apply. Lifecycle rules are instantly applied. ✅

💡 Quick Reference: What to Do First

Action Effort Savings
Add lifecycle rules to existing log buckets 5 min 73-94% on log storage
Enable AbortIncompleteMultipartUpload 2 min Stops silent cost leaks
Add versioning cleanup rules 5 min 50-70% on versioned buckets
Create reusable lifecycle module 15 min Consistent rules org-wide
Enable Autoclass for unknown patterns 2 min Auto-optimized tiers

Start with your log buckets. They’re the biggest offenders – huge volumes of data that nobody reads after a week. 🎯

📊 TL;DR

Standard = $0.020/GB    (hot data, frequent access)
Nearline = $0.010/GB    (accessed < 1x/month, 30-day min)
Coldline = $0.004/GB    (accessed < 1x/quarter, 90-day min)
Archive  = $0.0012/GB   (accessed < 1x/year, 365-day min)
Lifecycle rules          = automatic transitions, no early deletion fees
Prefix matching          = different rules for logs/ vs uploads/ vs backups/
Versioning cleanup       = limit old versions or storage doubles silently
Multipart cleanup        = stop invisible upload fragments from piling up
Autoclass                = let GCP auto-tier when you don't know the pattern
All classes              = same durability, same latency, different price

Bottom line: If your buckets don’t have lifecycle rules, every object sits in Standard forever and you pay 16x more than necessary for old data. Five minutes of Terraform fixes years of accumulated waste. 🪣

Go look at your biggest bucket right now. Sort by “last accessed.” I bet half the objects haven’t been touched in 90 days. That’s 80% savings sitting there waiting for one lifecycle rule. 😀

Found this helpful? Follow for more GCP cost optimization with Terraform! 💬

Leave a Reply