Nov 11, 20266 min read

Google Cloud Storage with Terraform: Buckets, Lifecycle Rules and Access

Terraform for a private Cloud Storage bucket with versioning, soft delete, lifecycle rules and CORS, IAM that lets a service create but not delete objects, and the permission signed URLs need on Cloud Run, plus how Encore provisions buckets from code.

Illustration for Google Cloud Storage with Terraform: Buckets, Lifecycle Rules and Access

A Cloud Storage bucket in Terraform needs a name and a location, and the defaults behind those two lines are where trouble starts. Access control depends on whether uniform bucket-level access is on, deleted objects are recoverable or not depending on soft delete and versioning, old versions pile up without a lifecycle rule, and browser uploads fail without CORS. Signed URLs add an IAM permission that only matters once the code runs on Cloud Run.

This guide covers Terraform for each of those, using the Google provider 7.x, and then how Encore provisions buckets from the code that reads and writes them.

What a GCS Terraform setup needs

PieceTerraform settingPurpose
Bucketgoogle_storage_bucketGlobally unique name, location, storage class
Access modeluniform_bucket_level_access, public_access_preventionIAM only, never public
Recoveryversioning, soft_delete_policyRestore overwritten or deleted objects
Cleanuplifecycle_ruleDelete old versions, abort stale uploads
Browser uploadscorsAllow PUT from your web app's origin
Accessgoogle_storage_bucket_iam_memberPer service account roles
Signinggoogle_service_account_iam_memberSign URLs on Cloud Run without a key

The bucket

variable "project" { type = string } variable "app_sa" { type = string } # service account of the service that uses the bucket provider "google" { project = var.project } resource "random_id" "suffix" { byte_length = 4 } resource "google_storage_bucket" "contracts" { name = "contracts-${random_id.suffix.hex}" # names are global across all of GCS location = "EU" uniform_bucket_level_access = true public_access_prevention = "enforced" versioning { enabled = true } soft_delete_policy { retention_duration_seconds = 604800 # 7 days, the default } lifecycle_rule { condition { days_since_noncurrent_time = 30 } action { type = "Delete" # drop old versions after 30 days } } lifecycle_rule { condition { age = 7 } action { type = "AbortIncompleteMultipartUpload" } } cors { origin = ["https://app.example.com"] method = ["PUT", "GET"] response_header = ["Content-Type"] max_age_seconds = 3600 } }

Bucket names are global across all of Cloud Storage, so a fixed name like contracts is almost certainly taken, and a random suffix avoids collisions between environments. With uniform bucket-level access, object ACLs are disabled and IAM is the only access path, and public_access_prevention = "enforced" blocks any grant to allUsers even if someone adds one later.

Versioning and soft delete overlap: soft delete keeps deleted objects for 7 days by default, even without versioning, according to the soft delete docs, and you pay storage for that window. Versioning also keeps replaced versions, which is why the lifecycle rule removes noncurrent versions after 30 days. The second rule cleans up multipart uploads that were started and never finished, as listed in the lifecycle docs.

IAM and signed URLs

# Create and read objects, but not delete or overwrite them. resource "google_storage_bucket_iam_member" "app_create" { bucket = google_storage_bucket.contracts.name role = "roles/storage.objectCreator" member = "serviceAccount:${var.app_sa}" } resource "google_storage_bucket_iam_member" "app_read" { bucket = google_storage_bucket.contracts.name role = "roles/storage.objectViewer" member = "serviceAccount:${var.app_sa}" } # Lets the service sign URLs with its own identity on Cloud Run, without a key file. resource "google_service_account_iam_member" "app_sign" { service_account_id = "projects/${var.project}/serviceAccounts/${var.app_sa}" role = "roles/iam.serviceAccountTokenCreator" member = "serviceAccount:${var.app_sa}" }

roles/storage.objectCreator can create objects but cannot delete or overwrite them, since overwriting needs delete permission. That suits a service that stores signed contracts and should never replace one.

Signed URLs depend on the last binding: on Cloud Run there is no key file, so the client library signs through the IAM signBlob API with the service's own identity, and the signed URL docs describe the permission it needs. Without it, the first call fails with a permission error in production while it works on a laptop with user credentials. Both files pass terraform validate with the Google provider 7.x.

What still takes work

  • Local development has no Cloud Storage, so teams point laptops at a shared dev bucket or run an emulator, and code paths for signed URLs differ between them.
  • Public assets need a separate bucket, an allUsers binding with prevention turned off, and Cloud CDN with a backend bucket and load balancer in front.
  • Each new bucket repeats the bucket, IAM and lifecycle resources, and the service account it belongs to lives in another module.
  • Renaming a bucket is not possible, so moving to a new name means copying every object.

How does Encore provision Cloud Storage from code?

Encore

Encore is infrastructure from code for Go and TypeScript backends: a bucket is a variable declared in the service that uses it, and the Encore platform creates it in your own GCP or AWS account when you deploy. On GCP, the GCP infrastructure docs state that buckets get unique names, each service gets permissions for only the buckets and operations it uses, and public buckets are served through Cloud CDN.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create shop --example=hello-world$ cd shop && mkdir contracts

A private, versioned bucket for contracts and a public one for product images, both declared in the contracts service:

// contracts/contracts.go package contracts import ( "context" "errors" "time" "encore.dev/beta/errs" "encore.dev/storage/objects" ) // Private and versioned: an overwritten contract keeps its previous versions. var Contracts = objects.NewBucket("contracts", objects.BucketConfig{ Versioned: true, }) // Public through a CDN: product images that anyone may load. var ProductImages = objects.NewBucket("product-images", objects.BucketConfig{ Public: true, }) type DownloadResponse struct { URL string } //encore:api auth method=GET path=/contracts/:id/download func Download(ctx context.Context, id string) (*DownloadResponse, error) { key := "signed/" + id + ".pdf" // ... check that the caller may read this contract ... if _, err := Contracts.Attrs(ctx, key); errors.Is(err, objects.ErrObjectNotFound) { return nil, &errs.Error{Code: errs.NotFound, Message: "contract not found"} } else if err != nil { return nil, err } url, err := Contracts.SignedDownloadURL(ctx, key, objects.WithTTL(5*time.Minute)) if err != nil { return nil, err } return &DownloadResponse{URL: url.URL}, nil } type ImageResponse struct { URL string } //encore:api public method=GET path=/products/:sku/image func Image(ctx context.Context, sku string) (*ImageResponse, error) { return &ImageResponse{URL: ProductImages.PublicURL("products/" + sku + ".webp").String()}, nil }

The auth endpoint requires an auth handler in the app. Versioned: true replaces the versioning block, Public: true replaces the separate public bucket and CDN setup, and SignedDownloadURL returns a link that expires after the TTL you pass, as covered in the object storage docs. encore run stores objects on local disk, so both endpoints can be exercised on a laptop, and the local development dashboard traces each call.

When a coding agent adds a bucket for invoices, it is one more NewBucket declaration in the pull request, and the bucket and the service's permissions on it are created on the next deploy. Existing buckets can be imported instead.

Best fit: Go and TypeScript teams on GCP who want private and public buckets with permissions per service, declared in the code that uses them.

Good to know

  • Moving a bucket to a different location means creating a new bucket and copying objects, since location cannot be changed.
  • Uniform bucket-level access can be turned off again only within 90 days of enabling it.
  • Object holds and retention policies with retention_policy { is_locked = true } cannot be undone, so test them on a scratch bucket first.

Frequently asked questions

How do I create a private GCS bucket in Terraform?

Use google_storage_bucket with uniform_bucket_level_access = true and public_access_prevention = "enforced", then grant access with google_storage_bucket_iam_member bindings for the service accounts that need it. Uniform access turns off object ACLs, so IAM is the only way in.

How do I delete old object versions in a GCS bucket with Terraform?

Enable versioning and add a lifecycle_rule with condition { days_since_noncurrent_time = 30 } and action { type = "Delete" }. That removes versions 30 days after they were replaced, while the live version stays.

Why does generating a signed URL fail on Cloud Run?

Cloud Run has no service account key file to sign with, so the client library signs through the IAM signBlob API using the service's own identity. The service account needs roles/iam.serviceAccountTokenCreator on itself for that call to succeed.

How do I get Cloud Storage buckets without Terraform?

With Encore, declare a bucket in Go with objects.NewBucket("name", objects.BucketConfig{Versioned: true}) or in TypeScript with new Bucket("name"). Deploying to your GCP project creates a Cloud Storage bucket with a unique name and grants the services that use it the permissions they need.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders