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.

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.
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.
# 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.
allUsers binding with prevention turned off, and Cloud CDN with a backend bucket and load balancer in front.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.

Run in your terminal to get started locally.
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.
location cannot be changed.retention_policy { is_locked = true } cannot be undone, so test them on a scratch bucket first.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.
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.
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.
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.