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
- You are provisioning more than one or two buckets and want consistent security posture (UBLA, public-access prevention, CMEK) without auditing each one by hand.
- You need lifecycle governance — automatically transitioning objects from
STANDARDtoNEARLINE/COLDLINE/ARCHIVEby age, or deleting old object versions to control cost. - You want data residency and recoverability guarantees: a pinned location, object versioning, a soft-delete retention window, and optionally a locked retention policy for compliance (WORM).
- You are wiring buckets into event-driven pipelines and need a Pub/Sub notification on object finalize/delete, or need to grant specific service accounts
objectViewer/objectAdmindeclaratively. - Skip it for throwaway scratch buckets in a sandbox where the hardened defaults (no public access, UBLA) get in your way — though even then, the defaults are usually what you want.
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 config — live/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 config — live/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
- Keep UBLA and public access prevention on.
uniform_bucket_level_access = truepluspublic_access_prevention = "enforced"is the single most important guard against accidental data exposure; only relax it with an explicit, reviewed reason. - Use CMEK for regulated or sensitive data. Supply
kms_key_nameso you control key rotation and can revoke access by disabling the key; pair it with key-ring IAM rather than bucket IAM alone. - Tier aggressively with lifecycle rules. Most object data goes cold fast — moving to
NEARLINEat 30 days andCOLDLINE/ARCHIVElater, plus deletingnum_newer_versionsof archived objects, often cuts storage spend by half or more. - Protect against deletion, but plan recovery. Enable
versioning_enabledand asoft_delete_retention_secondswindow, and leaveforce_destroy = falsein production so Terraform can never wipe a populated bucket in one apply. - Name and label for governance. Bucket names are global, so prefix with org/env (e.g.
kv-prod-...); applylabelsforenv,team, and cost center so billing export and policy tooling can attribute storage cost. - Treat locked retention as one-way. Only set
retention_policy_locked = trueonce you are certain — locking is irreversible and prevents shortening or removing the policy and deleting the bucket until objects age out.