IaC GCP

Terraform Module: GCP Cloud Storage — Secure, Versioned Buckets with Lifecycle Governance

Quick take — A production-ready Terraform module for google_storage_bucket on hashicorp/google ~> 5.0: uniform bucket-level access, CMEK, versioning, lifecycle rules, retention and IAM — driven entirely by variables. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "cloud_storage" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-storage?ref=v1.0.0"

  name       = "..."  # Globally unique bucket name (3-63 chars, lowercase).
  project_id = "..."  # GCP project ID that owns the bucket.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Google Cloud Storage is GCP’s object store: globally addressable buckets that hold blobs (“objects”) across storage classes from STANDARD to ARCHIVE, with server-side encryption, object versioning, retention locks and IAM-based access control. A raw google_storage_bucket resource looks deceptively simple, but a safe bucket is not — it needs uniform_bucket_level_access turned on so ACLs can’t quietly re-open it, public_access_prevention = "enforced" so nobody fat-fingers allUsers, a customer-managed encryption key (CMEK) for regulated data, lifecycle rules to tier cold data down to cheaper classes, and versioning plus a soft-delete window so a bad gsutil rm is recoverable.

This module wraps google_storage_bucket (plus its IAM and notification companions) so every bucket in your estate is created the same secure way. Instead of each team copy-pasting 60 lines of HCL and forgetting public_access_prevention, they call the module with a name and a class, and inherit hardened defaults: UBLA on, public access blocked, versioning configurable, lifecycle tiering wired up, and least-privilege IAM bindings managed authoritatively per role.

When to use it

Module structure

terraform-module-gcp-cloud-storage/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # google_storage_bucket + IAM + notification
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # bucket name, url, self_link, ids

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Build the optional retention policy block only when a duration is supplied.
  retention_policy = var.retention_period_seconds == null ? [] : [
    {
      retention_period = var.retention_period_seconds
      is_locked        = var.retention_policy_locked
    }
  ]
}

resource "google_storage_bucket" "this" {
  name                        = var.name
  project                     = var.project_id
  location                    = var.location
  storage_class               = var.storage_class
  force_destroy               = var.force_destroy
  uniform_bucket_level_access = var.uniform_bucket_level_access
  public_access_prevention    = var.public_access_prevention
  labels                      = var.labels

  # Object versioning — keep noncurrent versions so deletes/overwrites are recoverable.
  versioning {
    enabled = var.versioning_enabled
  }

  # Soft-delete window: retain deleted objects this long before permanent removal.
  soft_delete_policy {
    retention_duration_seconds = var.soft_delete_retention_seconds
  }

  # Customer-managed encryption key (CMEK). Omitted -> Google-managed key.
  dynamic "encryption" {
    for_each = var.kms_key_name == null ? [] : [var.kms_key_name]
    content {
      default_kms_key_name = encryption.value
    }
  }

  # Lifecycle tiering / expiry rules, fully data-driven from var.lifecycle_rules.
  dynamic "lifecycle_rule" {
    for_each = var.lifecycle_rules
    content {
      action {
        type          = lifecycle_rule.value.action_type
        storage_class = lookup(lifecycle_rule.value, "action_storage_class", null)
      }
      condition {
        age                        = lookup(lifecycle_rule.value, "age", null)
        created_before             = lookup(lifecycle_rule.value, "created_before", null)
        with_state                 = lookup(lifecycle_rule.value, "with_state", null)
        num_newer_versions         = lookup(lifecycle_rule.value, "num_newer_versions", null)
        days_since_noncurrent_time = lookup(lifecycle_rule.value, "days_since_noncurrent_time", null)
        matches_storage_class      = lookup(lifecycle_rule.value, "matches_storage_class", null)
        matches_prefix             = lookup(lifecycle_rule.value, "matches_prefix", null)
        matches_suffix             = lookup(lifecycle_rule.value, "matches_suffix", null)
      }
    }
  }

  # WORM-style retention policy (optional, can be locked = irreversible).
  dynamic "retention_policy" {
    for_each = local.retention_policy
    content {
      retention_period = retention_policy.value.retention_period
      is_locked        = retention_policy.value.is_locked
    }
  }

  # Static website config (optional) — only emitted when index page is set.
  dynamic "website" {
    for_each = var.website_main_page_suffix == null ? [] : [1]
    content {
      main_page_suffix = var.website_main_page_suffix
      not_found_page   = var.website_not_found_page
    }
  }
}

# Authoritative, per-role IAM bindings on the bucket.
resource "google_storage_bucket_iam_binding" "bindings" {
  for_each = var.iam_bindings

  bucket  = google_storage_bucket.this.name
  role    = each.key
  members = each.value
}

# Optional Pub/Sub notification on object events (e.g. finalize/delete).
resource "google_storage_notification" "this" {
  count = var.notification_topic == null ? 0 : 1

  bucket         = google_storage_bucket.this.name
  topic          = var.notification_topic
  payload_format = var.notification_payload_format
  event_types    = var.notification_event_types
}

variables.tf

variable "name" {
  description = "Globally unique bucket name (lowercase, 3-63 chars, no underscores when used as a hostname)."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9-_.]{1,61}[a-z0-9]$", var.name))
    error_message = "Bucket name must be 3-63 chars, lowercase, and may contain digits, hyphens, underscores and dots only."
  }
}

variable "project_id" {
  description = "GCP project ID that will own the bucket."
  type        = string
}

variable "location" {
  description = "Bucket location: a region (us-central1), dual-region (NAM4), or multi-region (US, EU, ASIA)."
  type        = string
  default     = "US"
}

variable "storage_class" {
  description = "Default storage class for new objects."
  type        = string
  default     = "STANDARD"

  validation {
    condition     = contains(["STANDARD", "NEARLINE", "COLDLINE", "ARCHIVE"], var.storage_class)
    error_message = "storage_class must be one of STANDARD, NEARLINE, COLDLINE, ARCHIVE."
  }
}

variable "labels" {
  description = "Key/value labels applied to the bucket."
  type        = map(string)
  default     = {}
}

variable "uniform_bucket_level_access" {
  description = "Enforce IAM-only access (disables object ACLs). Strongly recommended to keep true."
  type        = bool
  default     = true
}

variable "public_access_prevention" {
  description = "Public access prevention: 'enforced' blocks allUsers/allAuthenticatedUsers; 'inherited' follows org policy."
  type        = string
  default     = "enforced"

  validation {
    condition     = contains(["enforced", "inherited"], var.public_access_prevention)
    error_message = "public_access_prevention must be 'enforced' or 'inherited'."
  }
}

variable "versioning_enabled" {
  description = "Enable object versioning so overwrites/deletes keep recoverable noncurrent versions."
  type        = bool
  default     = true
}

variable "soft_delete_retention_seconds" {
  description = "Soft-delete retention in seconds (0 disables; otherwise 7-90 days). Default 7 days."
  type        = number
  default     = 604800

  validation {
    condition     = var.soft_delete_retention_seconds == 0 || (var.soft_delete_retention_seconds >= 604800 && var.soft_delete_retention_seconds <= 7776000)
    error_message = "soft_delete_retention_seconds must be 0, or between 604800 (7d) and 7776000 (90d)."
  }
}

variable "kms_key_name" {
  description = "Full resource ID of a Cloud KMS key for CMEK encryption; null uses a Google-managed key."
  type        = string
  default     = null
}

variable "force_destroy" {
  description = "Allow Terraform to delete the bucket even when it still contains objects. Keep false in prod."
  type        = bool
  default     = false
}

variable "retention_period_seconds" {
  description = "Optional WORM retention period in seconds. null disables the retention policy."
  type        = number
  default     = null
}

variable "retention_policy_locked" {
  description = "Lock the retention policy. WARNING: locking is irreversible and prevents shortening/removal."
  type        = bool
  default     = false
}

variable "lifecycle_rules" {
  description = <<-EOT
    List of lifecycle rules. Each object supports:
      action_type (required): "SetStorageClass" or "Delete" or "AbortIncompleteMultipartUpload"
      action_storage_class:   target class for SetStorageClass
      and any condition key: age, created_before, with_state (LIVE|ARCHIVED|ANY),
      num_newer_versions, days_since_noncurrent_time, matches_storage_class,
      matches_prefix, matches_suffix.
  EOT
  type        = list(any)
  default     = []
}

variable "iam_bindings" {
  description = "Map of role => list(members) applied authoritatively to the bucket (e.g. {\"roles/storage.objectViewer\" = [\"serviceAccount:app@proj.iam.gserviceaccount.com\"]})."
  type        = map(list(string))
  default     = {}
}

variable "notification_topic" {
  description = "Full Pub/Sub topic ID to receive object notifications; null disables notifications."
  type        = string
  default     = null
}

variable "notification_event_types" {
  description = "Object event types to notify on."
  type        = list(string)
  default     = ["OBJECT_FINALIZE", "OBJECT_DELETE"]
}

variable "notification_payload_format" {
  description = "Notification payload format."
  type        = string
  default     = "JSON_API_V1"
}

variable "website_main_page_suffix" {
  description = "Index page suffix for static website hosting (e.g. index.html); null disables website config."
  type        = string
  default     = null
}

variable "website_not_found_page" {
  description = "404 page for static website hosting (e.g. 404.html)."
  type        = string
  default     = null
}

outputs.tf

output "name" {
  description = "The name of the bucket."
  value       = google_storage_bucket.this.name
}

output "id" {
  description = "The Terraform resource ID of the bucket (the bucket name)."
  value       = google_storage_bucket.this.id
}

output "url" {
  description = "The gs:// base URL of the bucket."
  value       = google_storage_bucket.this.url
}

output "self_link" {
  description = "The URI of the created bucket."
  value       = google_storage_bucket.this.self_link
}

output "location" {
  description = "The resolved location of the bucket."
  value       = google_storage_bucket.this.location
}

output "storage_class" {
  description = "The default storage class of the bucket."
  value       = google_storage_bucket.this.storage_class
}

output "notification_id" {
  description = "The ID of the Pub/Sub notification, if one was created."
  value       = try(google_storage_notification.this[0].id, null)
}

How to use it

module "cloud_storage" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-storage?ref=v1.0.0"

  name       = "kv-prod-ingest-eu"
  project_id = "kv-data-prod"
  location   = "EU"

  storage_class      = "STANDARD"
  versioning_enabled = true
  kms_key_name       = "projects/kv-data-prod/locations/europe-west1/keyRings/storage/cryptoKeys/gcs"

  # Tier cold data down, then expire old versions to control cost.
  lifecycle_rules = [
    {
      action_type          = "SetStorageClass"
      action_storage_class = "NEARLINE"
      age                  = 30
    },
    {
      action_type          = "SetStorageClass"
      action_storage_class = "COLDLINE"
      age                  = 90
    },
    {
      action_type        = "Delete"
      num_newer_versions = 3
      with_state         = "ARCHIVED"
    }
  ]

  # Least-privilege access for the pipeline service account.
  iam_bindings = {
    "roles/storage.objectAdmin" = [
      "serviceAccount:ingest-pipeline@kv-data-prod.iam.gserviceaccount.com"
    ]
  }

  # Fire a Pub/Sub event when objects land, to trigger downstream processing.
  notification_topic = "projects/kv-data-prod/topics/gcs-ingest-events"

  labels = {
    env  = "prod"
    team = "data-platform"
  }
}

# Downstream: mount the bucket name into a Cloud Run job's env so it knows where to read.
resource "google_cloud_run_v2_job" "processor" {
  name     = "ingest-processor"
  location = "europe-west1"
  project  = "kv-data-prod"

  template {
    template {
      containers {
        image = "europe-docker.pkg.dev/kv-data-prod/jobs/processor:1.4.0"
        env {
          name  = "SOURCE_BUCKET"
          value = module.cloud_storage.name
        }
      }
    }
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/cloud_storage/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-storage?ref=v1.0.0"
}

inputs = {
  name = "..."
  project_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/cloud_storage && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string yes Globally unique bucket name (3-63 chars, lowercase).
project_id string yes GCP project ID that owns the bucket.
location string "US" no Region, dual-region, or multi-region location.
storage_class string "STANDARD" no Default class: STANDARD, NEARLINE, COLDLINE, ARCHIVE.
labels map(string) {} no Labels applied to the bucket.
uniform_bucket_level_access bool true no Enforce IAM-only access (disables object ACLs).
public_access_prevention string "enforced" no enforced blocks public access; inherited follows org policy.
versioning_enabled bool true no Keep recoverable noncurrent object versions.
soft_delete_retention_seconds number 604800 no Soft-delete window in seconds (0, or 7-90 days).
kms_key_name string null no Cloud KMS key for CMEK; null = Google-managed key.
force_destroy bool false no Allow deleting a non-empty bucket. Keep false in prod.
retention_period_seconds number null no Optional WORM retention period; null disables it.
retention_policy_locked bool false no Lock the retention policy (irreversible).
lifecycle_rules list(any) [] no Lifecycle tiering/expiry rules (see variable doc).
iam_bindings map(list(string)) {} no Authoritative role => members bindings on the bucket.
notification_topic string null no Pub/Sub topic for object notifications; null disables.
notification_event_types list(string) ["OBJECT_FINALIZE","OBJECT_DELETE"] no Object events to notify on.
notification_payload_format string "JSON_API_V1" no Notification payload format.
website_main_page_suffix string null no Index page for static website hosting; null disables.
website_not_found_page string null no 404 page for static website hosting.

Outputs

Name Description
name The name of the bucket.
id The Terraform resource ID of the bucket (the bucket name).
url The gs:// base URL of the bucket.
self_link The URI of the created bucket.
location The resolved location of the bucket.
storage_class The default storage class of the bucket.
notification_id The ID of the Pub/Sub notification, if one was created.

Enterprise scenario

A fintech data platform ingests partner settlement files into a multi-region EU bucket that must satisfy auditors. The module provisions it with a CMEK key from Cloud KMS, public_access_prevention = "enforced", UBLA on, a 7-year locked retention_policy (retention_period_seconds set, retention_policy_locked = true) so files are immutable WORM records, and lifecycle rules that push objects to COLDLINE after 90 days. A Pub/Sub notification on OBJECT_FINALIZE triggers a Cloud Run job that validates and reconciles each new settlement file, while iam_bindings grants only the ingestion service account objectAdmin — every other identity is denied by default.

Best practices

TerraformGCPCloud StorageModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading