Cloud Storage Security and Static Sites

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Commands and behaviour below are current for the gcloud CLI 586.x line (gcloud storage command group), the Cloud Storage documentation and release notes published through 2026-09-30, the Google Cloud Client Libraries for Java (libraries-bom) and Terraform hashicorp/google v8 (checked 2026-10-01). Cloud Storage has changed its defaults repeatedly since 2023 (soft delete on by default, uniform bucket-level access and domain-restricted sharing enforced in new organizations, gcloud storage replacing gsutil), so older guides that make a bucket public with object ACLs or sign URLs with a downloaded key file no longer work as written. Verify flags with gcloud storage --help and the linked reference pages.

This page is the security half of Cloud Storage. The basics — buckets, objects, storage classes, locations, versioning, soft delete, lifecycle rules, transfers and Cloud Storage FUSE — are in Cloud Storage, and the caching layer in front of the static site is in Cloud CDN and Media CDN. Two Bookshelf buckets are used throughout: bookshelf-web-$PROJECT_ID (the single page application, served to the internet through a global external Application Load Balancer) and bookshelf-uploads-$PROJECT_ID (user uploads written by catalog-api, private, read back through signed URLs). The AWS and Azure counterparts are S3 Security and Static Hosting and Blob Storage; the Spring Boot side of file storage is in File Storage & Object Stores; the React build that produces the static site is in Build and Deployment.

The security model in one page

Four independent layers decide whether a request succeeds, and knowing which one produced a 403 is most of troubleshooting: organization policy (constraints such as storage.uniformBucketLevelAccess, storage.publicAccessPrevention and iam.allowedPolicyMemberDomains, several enforced in organizations created from 2024-05-03); public access prevention (a switch that rejects allUsers and allAuthenticatedUsers); IAM, with optional conditions, as the only mechanism once ACLs are off; and encryption plus VPC Service Controls (the caller must be allowed to use the key and be inside the perimeter).

The remaining sections follow that order. Everything that calls the API uses Application Default Credentials: the service account attached to Cloud Run, the Workload Identity Federation principal of a pipeline, or --impersonate-service-account on a developer machine (see Identity and Access). No page example downloads a service-account key.

Baseline: private buckets with uniform access

A bucket created through the console today already follows most of the guidance below, and in a secure-by-default organization the rest is enforced for you. The settings to keep are:

Control Recommended value Notes

Uniform bucket-level access (UBLA)

On

Disables ACLs for the bucket and its objects, so IAM is the single access system. Enforced by the storage.uniformBucketLevelAccess constraint in organizations created from 2024-05-03. You can turn it off again for 90 days after enabling; after that it is locked on.

Public access prevention

enforced for every bucket that does not serve the public web

Set on the bucket or through the storage.publicAccessPrevention constraint. Buckets default to inherited, which defers to the organization policy.

Default encryption

Google-managed key, or CMEK where policy requires it

All data is encrypted at rest; the choice is who controls the key (below).

Older guides (and gsutil acl ch -u AllUsers:R) make objects public with ACLs. That requires UBLA to be off and PAP to be off, and both are now blocked in new organizations. The current approach is IAM bindings on the bucket, and for anything public, a deliberate exception described in Hosting a static website.

In the Google Cloud console

  1. Pick the project in the project picker and open Cloud Storage → Buckets (open Cloud Shell from the toolbar if you prefer to run the CLI below). If the API is disabled the console offers to enable it.

  2. Choose Create, enter the globally unique name bookshelf-uploads-<project-id>, and select a Region (us-central1) as the location type.

  3. For Choose a storage class, keep Standard (see Cloud Storage for the others).

  4. For Choose how to control access to objects, keep Enforce public access prevention on this bucket checked and Uniform selected.

  5. For Choose how to protect object data, leave Soft delete policy at the default, tick Object versioning if needed, and choose the encryption key (Google-managed or Cloud KMS key), then choose Create.

With the gcloud CLI

gcloud services enable storage.googleapis.com iam.googleapis.com iamcredentials.googleapis.com \
  cloudkms.googleapis.com compute.googleapis.com certificatemanager.googleapis.com

export PROJECT_ID=$(gcloud config get-value project)   # placeholder: my-project-id
export REGION=us-central1
export WEB_BUCKET=bookshelf-web-$PROJECT_ID
export UPLOADS_BUCKET=bookshelf-uploads-$PROJECT_ID

# Private bucket for uploads: UBLA on, public access blocked
gcloud storage buckets create gs://$UPLOADS_BUCKET \
  --location=$REGION \
  --default-storage-class=STANDARD \
  --uniform-bucket-level-access \
  --public-access-prevention

# Verify
gcloud storage buckets describe gs://$UPLOADS_BUCKET \
  --format="yaml(uniform_bucket_level_access,public_access_prevention,soft_delete_policy)"

The same baseline in Terraform, which is what the Bookshelf baseline module should contain:

resource "google_storage_bucket" "uploads" {
  name                        = "bookshelf-uploads-${var.project_id}"
  location                    = "US-CENTRAL1"
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning {
    enabled = true
  }

  soft_delete_policy {
    retention_duration_seconds = 604800 # 7 days
  }
}

IAM roles for Cloud Storage

Grant roles to the narrowest principal at the narrowest scope: a bucket (or a managed folder) rather than the project. The predefined roles you will use are:

Role Grants

roles/storage.objectViewer

List objects and read object data and metadata. This is the role for readers and, bound to allUsers, the role that makes a static site public.

roles/storage.objectCreator

Create objects only (no read, overwrite or delete). Useful for write-only ingest.

roles/storage.objectUser

Read, write, list and delete objects, but not change IAM policies or bucket settings. The usual role for an application such as catalog-api on its uploads bucket.

roles/storage.objectAdmin

Everything on objects including setting object ACLs (ignored under UBLA) and object holds.

roles/storage.admin

Full control of buckets and objects, including IAM policies. Keep it for the pipeline that provisions buckets, not for applications.

roles/storage.folderAdmin

Manage managed folders and the objects under them (see below).

Basic roles (Owner, Editor, Viewer) also contain Cloud Storage permissions at project level, and the legacy bucket roles (roles/storage.legacyBucketReader and friends) are added automatically to old buckets; neither belongs in a new design. Prefer the roles above. The full role list is in the Cloud Storage IAM roles reference.

In the Google Cloud console

  1. Open Cloud Storage → Buckets → select bookshelf-uploads-<project-id> → Permissions.

  2. Choose Grant access, enter the service account catalog-api-sa@<project-id>.iam.gserviceaccount.com as the new principal and select the role Storage Object User.

  3. To restrict the grant to a prefix, choose Add IAM condition (Condition → Edit), give it a title and use the condition editor with the expression shown in the CLI section, then choose Save.

With the gcloud CLI

export SA=catalog-api-sa@$PROJECT_ID.iam.gserviceaccount.com

# Read and write the whole bucket
gcloud storage buckets add-iam-policy-binding gs://$UPLOADS_BUCKET \
  --member="serviceAccount:$SA" \
  --role=roles/storage.objectUser

# Read only objects under uploads/public-covers/ (IAM Condition on the resource name)
gcloud storage buckets add-iam-policy-binding gs://$UPLOADS_BUCKET \
  --member="serviceAccount:notifier-sa@$PROJECT_ID.iam.gserviceaccount.com" \
  --role=roles/storage.objectViewer \
  --condition='title=covers-only,description=Read cover images only,expression=resource.name.startsWith("projects/_/buckets/'"$UPLOADS_BUCKET"'/objects/uploads/public-covers/")'

# Inspect the policy
gcloud storage buckets get-iam-policy gs://$UPLOADS_BUCKET --format=yaml

Resource names in conditions have the form projects//buckets/BUCKET/objects/OBJECT; the is literal. Conditional bindings only work on buckets with uniform bucket-level access, and a policy that contains conditions must be read and written with version 3 (the output shows version: 3).

Managed folders

Managed folders are resources inside a bucket with their own IAM policy, so one prefix can be shared without conditions or a new bucket. They require UBLA and apply to every object below the prefix.

gcloud storage managed-folders create gs://$UPLOADS_BUCKET/exports/
gcloud storage managed-folders add-iam-policy-binding gs://$UPLOADS_BUCKET/exports/ \
  --member="group:report-readers@example.com" --role=roles/storage.objectViewer

Derived from gcloud storage managed-folders create and the Managed folders page. In the console, open the bucket → Create folder → managed folder, then Permissions on it.

Public access prevention and domain-restricted sharing

Public access prevention stops accidental exposure. When it is enforced on a bucket, a request to add allUsers or allAuthenticatedUsers fails and existing public bindings stop working; when it is inherited, the organization policy constraints/storage.publicAccessPrevention decides. Treat a public bucket as the exception, reviewed and documented, and keep every other bucket at enforced.

A second control is easy to miss. Organizations created from 2024-05-03 enforce constraints/iam.allowedPolicyMemberDomains (domain-restricted sharing), which rejects any IAM binding whose principal is outside your organization — including allUsers. Even with PAP off, granting objectViewer to allUsers then fails with a policy error. Options are to exempt a specific project or folder by tag or by a narrower policy set by an organization administrator, or not to make the bucket public at all and serve the site with signed URLs or cookies through Cloud CDN (see Cloud CDN and Media CDN). See Resource Hierarchy and Projects for how policies inherit.

In the Google Cloud console

Open the bucket → Permissions → Public access, then choose Prevent public access, or Remove public access prevention on the web bucket only. Administrators set the organization-wide switch under IAM & Admin → Organization Policies → storage.publicAccessPrevention.

With the gcloud CLI

# Enforce on an existing bucket
gcloud storage buckets update gs://$UPLOADS_BUCKET --public-access-prevention

# Allow public IAM on the web bucket only (lifts PAP back to inherited)
gcloud storage buckets update gs://$WEB_BUCKET --no-public-access-prevention

# Organization-wide (needs roles/orgpolicy.policyAdmin on the organization)
gcloud resource-manager org-policies enable-enforce storage.publicAccessPrevention \
  --organization=<organization-id>

Derived from gcloud storage buckets update and the Public access prevention page. The org-policies command group is the long-standing way to set a boolean constraint; for new automation Google recommends gcloud org-policies set-policy with a policy file.

Signed URLs (V4) without keys

A signed URL gives anyone holding it temporary access to one object, with the permissions of the account that signed it, without a Google identity. Bookshelf uses them so the browser downloads a user’s upload (or uploads a cover) directly from Cloud Storage instead of streaming it through catalog-api.

Version 4 signing (HMAC is for interoperability, below) has these rules:

  • The URL expires after the requested duration, at most 7 days (604800 seconds) when signed with a service account through the API. With the gcloud CLI the 7-day maximum applies to service-account signing; a command signed with user credentials is limited to a shorter validity.

  • The signer must itself have the permission the URL grants: a URL for GET signed by an account that cannot read the object fails with 403 when used.

  • Older guides download a service-account JSON key and pass --private-key-file. That works but is blocked where iam.disableServiceAccountKeyCreation is enforced (the default in new organizations). The current approach is to let the IAM Credentials API sign on behalf of the service account: the caller needs roles/iam.serviceAccountTokenCreator on the signing service account (a service account may be granted it on itself), and no key ever exists.

sequenceDiagram participant B as Browser participant A as catalog-api on Cloud Run participant I as IAM Credentials API participant S as Cloud Storage B->>A: Request download link for object A->>A: Build V4 canonical request A->>I: signBlob for catalog-api-sa I-->>A: Signature, no key leaves Google A-->>B: Signed URL valid 15 minutes B->>S: GET signed URL S->>S: Verify signature and expiry S-->>B: Object bytes

With the gcloud CLI

# One-time: let the developer or pipeline identity sign as the service account
gcloud iam service-accounts add-iam-policy-binding $SA \
  --member="user:developer@example.com" \
  --role=roles/iam.serviceAccountTokenCreator

# Download link valid for 15 minutes
gcloud storage sign-url gs://$UPLOADS_BUCKET/uploads/book-42/cover.png \
  --duration=15m \
  --impersonate-service-account=$SA

# Upload link: the client must send the same Content-Type header
gcloud storage sign-url gs://$UPLOADS_BUCKET/uploads/book-42/new-cover.png \
  --http-verb=PUT \
  --headers=content-type=image/png \
  --duration=10m \
  --impersonate-service-account=$SA

Derived from gcloud storage sign-url and the Signed URLs page. In the console there is no "create signed URL" action for objects; the console’s Share publicly action is something different and requires public access.

In Java

On Cloud Run the library obtains Compute Engine credentials from the metadata server, and those credentials can sign through the IAM Credentials API, so no key file is needed. Locally, use impersonated credentials (set up with gcloud auth application-default login --impersonate-service-account=$SA).

package com.example.bookshelf.upload;

import com.google.cloud.storage.BlobId;
import com.google.cloud.storage.BlobInfo;
import com.google.cloud.storage.HttpMethod;
import com.google.cloud.storage.Storage;
import com.google.cloud.storage.Storage.SignUrlOption;
import com.google.cloud.storage.StorageOptions;
import java.net.URL;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class UploadLinks {

    private final Storage storage = StorageOptions.getDefaultInstance().getService();
    private final String bucket;

    public UploadLinks(String bucket) {
        this.bucket = bucket;
    }

    /** Link the browser can PUT the file to; it must send the same Content-Type. */
    public URL uploadUrl(String objectName, String contentType) {
        BlobInfo blob = BlobInfo.newBuilder(BlobId.of(bucket, objectName))
                .setContentType(contentType)
                .build();
        return storage.signUrl(blob, 10, TimeUnit.MINUTES,
                SignUrlOption.httpMethod(HttpMethod.PUT),
                SignUrlOption.withExtHeaders(Map.of("Content-Type", contentType)),
                SignUrlOption.withV4Signature());
    }

    /** Link the browser can GET to download the file. */
    public URL downloadUrl(String objectName) {
        BlobInfo blob = BlobInfo.newBuilder(BlobId.of(bucket, objectName)).build();
        return storage.signUrl(blob, 15, TimeUnit.MINUTES, SignUrlOption.withV4Signature());
    }
}

Derived from the V4 signed URL code sample, the signed POST policy sample and the Cloud Storage client libraries page. The service account needs roles/iam.serviceAccountTokenCreator on itself so that signBlob is allowed. Add the Cloud Storage client to the build through the com.google.cloud:libraries-bom and the google-cloud-storage artifact (or the Spring Cloud GCP Storage starter, see File Storage & Object Stores).

A signed POST policy (storage.generateSignedPostPolicyV4) is the form-based variant: the server signs a policy listing the object name and constraints, and the browser submits a multipart form directly to Cloud Storage. Use it for plain HTML forms and a signed PUT URL for programmatic clients. Because the URL is a bearer credential, keep lifetimes short, never log it, and scope it to one object.

HMAC keys for S3-interoperable clients

Tools that only speak the Amazon S3 API can call the XML API at storage.googleapis.com with an HMAC key tied to a service account: gcloud storage hmac create $SA prints the access ID and secret once (store the secret in Secret Manager and Cloud KMS), and hmac update --deactivate and hmac delete retire it. An HMAC key is a long-lived secret like a service-account key, so use it only when a client cannot use OAuth. See gcloud storage hmac create and the HMAC keys page.

Encryption: Google-managed keys, CMEK and CSEK

Every object is encrypted at rest with no action on your part. The three ways to control the key are:

Option What it means

Google-managed encryption key (default)

Google creates, rotates and stores the keys. No setup, no extra cost.

Customer-managed encryption key (CMEK)

You create a key in Cloud KMS (optionally HSM or external) and set it as the bucket default or per object. You control rotation, can disable the key to make the data unreadable, and get audit logs of each use. The bucket and the key must be in a compatible location.

Customer-supplied encryption key (CSEK)

You send your own AES-256 key with every request and Google never stores it. Operationally heavy and not supported by features such as some transfer tools; use CMEK unless a rule requires CSEK.

For CMEK the bucket’s service agent needs roles/cloudkms.cryptoKeyEncrypterDecrypter on the key; the CLI creates the agent and grants the role in one command.

In the Google Cloud console

  1. Open Security → Key management and create a key ring (bookshelf, location us-central1) and a key (uploads, purpose Symmetric encrypt/decrypt, rotation as required).

  2. Open Cloud Storage → Buckets → the bucket → Configuration → Encryption type → Edit and choose Cloud KMS key, select the key, and accept the prompt to grant the service agent access.

With the gcloud CLI

gcloud kms keyrings create bookshelf --location=$REGION
gcloud kms keys create uploads --keyring=bookshelf --location=$REGION --purpose=encryption \
  --default-algorithm=google-symmetric-encryption

export KEY=projects/$PROJECT_ID/locations/$REGION/keyRings/bookshelf/cryptoKeys/uploads

# Create the Cloud Storage service agent if needed and let it use the key
gcloud storage service-agent --project=$PROJECT_ID --authorize-cmek=$KEY

# Use it for new objects in the bucket (existing objects keep their key until rewritten)
gcloud storage buckets update gs://$UPLOADS_BUCKET --default-encryption-key=$KEY

# Re-encrypt an existing object with the new key
gcloud storage objects update gs://$UPLOADS_BUCKET/uploads/book-42/cover.png --encryption-key=$KEY

Derived from gcloud kms keys create, gcloud storage service-agent and the Customer-managed encryption keys page. Key creation, rotation and destruction are covered in Secret Manager and Cloud KMS. If the key is disabled or destroyed, reads return an error and the data is unrecoverable once the key version is destroyed, which is why key-version destruction is scheduled rather than immediate. The organization policy constraints/gcp.restrictNonCmekServices can require CMEK for Cloud Storage.

Retention policies, holds and object retention

When data must not change or disappear for a period, use retention rather than permissions alone.

Feature Behaviour

Bucket retention policy

Every object in the bucket cannot be deleted or replaced until it is at least the retention period old. Locking the policy (bucket lock) is irreversible: the period can only be extended and the bucket cannot be deleted until its objects age out.

Object holds

A temporary hold or an event-based hold on a single object blocks deletion until released. A default event-based hold makes new objects start held; releasing the hold starts the retention clock.

Object Retention Lock

A per-object retain-until date and mode (Unlocked or Locked), on buckets created with the feature enabled. Locked mode cannot be shortened or removed.

Soft delete and versioning

Recovery features, not protection: soft delete is configurable per bucket and can be shortened, versioning keeps noncurrent versions until a lifecycle rule removes them.

In the Google Cloud console

Open the bucket → Protection, choose Set retention policy, enter a duration and optionally Lock it; for one object, select it → Edit protection and set a hold or retention.

With the gcloud CLI

# Retention: nothing in the bucket can be deleted for 30 days
gcloud storage buckets update gs://$UPLOADS_BUCKET --retention-period=30d

# Hold one object
gcloud storage objects update gs://$UPLOADS_BUCKET/uploads/book-42/cover.png --temporary-hold
gcloud storage objects update gs://$UPLOADS_BUCKET/uploads/book-42/cover.png --no-temporary-hold

# Per-object retention needs the feature on the bucket at creation time
gcloud storage buckets create gs://bookshelf-archive-$PROJECT_ID --location=$REGION \
  --uniform-bucket-level-access --public-access-prevention --enable-per-object-retention
gcloud storage objects update gs://bookshelf-archive-$PROJECT_ID/invoice-2026.pdf \
  --retain-until=2027-12-31T00:00:00Z --retention-mode=Unlocked

Derived from gcloud storage buckets update, gcloud storage objects update and the Retention policies and bucket lock and Object Retention Lock pages. Locking a retention policy (gcloud storage buckets update --lock-retention-period) is permanent, so do it only on a real data bucket after review and never on the lab buckets of this page.

CORS

Browsers block cross-origin reads unless the server answers with CORS headers. When the Bookshelf front end at https://www.example.com loads fonts or JSON directly from a bucket, or uploads to a signed URL with fetch, the bucket needs a CORS configuration that names the origin, the methods and the response headers the page may read.

[
  {
    "origin": ["https://www.example.com"],
    "method": ["GET", "PUT", "HEAD"],
    "responseHeader": ["Content-Type", "x-goog-resumable"],
    "maxAgeSeconds": 3600
  }
]
gcloud storage buckets update gs://$UPLOADS_BUCKET --cors-file=cors.json
gcloud storage buckets describe gs://$UPLOADS_BUCKET --format="yaml(cors_config)"

# Remove it
gcloud storage buckets update gs://$UPLOADS_BUCKET --clear-cors

Derived from gcloud storage buckets update and the Configure cross-origin resource sharing (CORS) page. In the console the bucket’s CORS cannot be edited; use the CLI, Terraform (cors block) or the API. CORS applies to the XML API and to the JSON API calls the browser makes; it is not applied to requests served through the load balancer for the static site, where headers are set on the backend bucket (custom response headers) instead, see Cloud CDN and Media CDN. Keep origins explicit; "*" is rarely justified.

Requester pays

With requester pays the caller’s project, not the owner’s, is billed for transfer and operations, and every request must name a billing project (gcloud storage buckets update gs://BUCKET --requester-pays, then gcloud storage ls gs://BUCKET --billing-project=$PROJECT_ID). It suits shared datasets, does not work with anonymous access or signed URLs, and does not combine with the public site below. See the Requester Pays page.

VPC Service Controls

IAM answers "who may call"; VPC Service Controls answers "from where". A perimeter around the project makes Cloud Storage reject calls whose source is outside the perimeter or outside an allowed access level, even with valid credentials, which limits data exfiltration with stolen tokens or misconfigured IAM. Create the perimeter in dry-run mode first and read the violations in the audit logs before enforcing.

gcloud access-context-manager perimeters dry-run create bookshelf_perimeter --policy=<access-policy-id> \
  --perimeter-title="Bookshelf" --perimeter-type=regular \
  --perimeter-resources=projects/<project-number> --perimeter-restricted-services=storage.googleapis.com

Derived from gcloud access-context-manager perimeters dry-run create and the VPC Service Controls overview. Perimeters are created at the organization level and need Access Context Manager permissions; see Governance, Security and Compliance for the governance context. A perimeter also blocks a browser downloading through a signed URL from outside it unless an ingress rule or access level allows it, which is why the Bookshelf static bucket is normally kept outside the perimeter that guards the uploads bucket.

Hosting a static website

Cloud Storage can serve a site directly from a bucket, but with two limits that decide the design:

  • The bucket-hostname form (https://storage.googleapis.com/BUCKET/index.html, or a CNAME to c.storage.googleapis.com) serves HTTP only on a custom domain; Cloud Storage does not terminate TLS for your own domain.

  • The site content must be readable by everyone, so the bucket needs allUsers with roles/storage.objectViewer, public access prevention off, and an organization that permits a public principal.

The current approach for a production site is therefore the global external Application Load Balancer with a backend bucket, a Google-managed certificate from Certificate Manager and, optionally, Cloud CDN on the backend bucket. The bucket stays the origin and the load balancer provides HTTPS, the anycast IP address, HTTP-to-HTTPS redirect and Cloud Armor if needed. Firebase Hosting is the simpler alternative for single page applications because it includes TLS, a CDN, rewrite rules and preview channels without a load balancer, at the price of a separate product and deploy tool.

flowchart LR U[Browser] -->|HTTPS www.example.com| IP[Global anycast IP] IP --> FR[Forwarding rule 443] FR --> P[Target HTTPS proxy with certificate map] P --> M[URL map] M --> BB[Backend bucket with Cloud CDN] BB --> B[(bookshelf-web bucket)] M -.->|/api| NEG[Serverless NEG to catalog-api]

The bucket website settings still matter behind the load balancer: MainPageSuffix is the file served for a directory-style request such as / and NotFoundPage is the object returned with a 404 status for a missing path. A single page application whose router uses real paths needs unknown paths to return index.html; setting NotFoundPage to index.html makes the app load but the status code stays 404, so use hash routing, a URL-map rewrite to the app shell, or Firebase Hosting rewrites when the status matters to crawlers.

In the Google Cloud console

  1. Open Cloud Storage → Buckets → Create, name it bookshelf-web-<project-id> in us-central1 (or a multi-region), and leave Uniform access. On the access page, uncheck Enforce public access prevention for this bucket only.

  2. Upload the build output of the React app (the dist folder) with Upload → Upload folder.

  3. Open the bucket → Permissions → Grant access, principal allUsers, role Storage Object Viewer, and confirm that the content is public.

  4. Open the bucket → Configuration → Edit website configuration and set Index (main) page to index.html and Error (404) page to index.html or 404.html.

  5. Open Network services → Load balancing → Create load balancer → Application Load Balancer (HTTP/S) → From Internet to my VMs or serverless services → Global external Application Load Balancer.

  6. In Backend configuration create a Backend bucket pointing to the web bucket and tick Enable Cloud CDN.

  7. In Frontend configuration choose protocol HTTPS, create a new global IPv4 address, and create a certificate with Certificate Manager for www.example.com.

  8. Review and choose Create. Create an A record for www.example.com pointing to the new address in your DNS provider and wait until the certificate status is Active.

With the gcloud CLI

export DOMAIN=www.example.com          # placeholder; use a domain you control

# 1. The bucket: public by exception (UBLA on, PAP off for this bucket only)
gcloud storage buckets create gs://$WEB_BUCKET \
  --location=$REGION --uniform-bucket-level-access --no-public-access-prevention

gcloud storage buckets update gs://$WEB_BUCKET \
  --web-main-page-suffix=index.html --web-error-page=index.html

gcloud storage cp --recursive dist/* gs://$WEB_BUCKET/
gcloud storage buckets add-iam-policy-binding gs://$WEB_BUCKET \
  --member=allUsers --role=roles/storage.objectViewer

# 2. Global address and backend bucket with Cloud CDN
gcloud compute addresses create bookshelf-web-ip --global --ip-version=IPV4
gcloud compute backend-buckets create bookshelf-web-backend \
  --gcs-bucket-name=$WEB_BUCKET --enable-cdn

# 3. URL map
gcloud compute url-maps create bookshelf-web-map --default-backend-bucket=bookshelf-web-backend

# 4. Google-managed certificate through a certificate map (DNS must point to the address first)
gcloud certificate-manager certificates create bookshelf-cert --domains=$DOMAIN
gcloud certificate-manager maps create bookshelf-map
gcloud certificate-manager maps entries create bookshelf-entry \
  --map=bookshelf-map --hostname=$DOMAIN --certificates=bookshelf-cert

# 5. HTTPS proxy and forwarding rule
gcloud compute target-https-proxies create bookshelf-https-proxy \
  --url-map=bookshelf-web-map --certificate-map=bookshelf-map
gcloud compute forwarding-rules create bookshelf-https-rule \
  --global --load-balancing-scheme=EXTERNAL_MANAGED --network-tier=PREMIUM \
  --address=bookshelf-web-ip --target-https-proxy=bookshelf-https-proxy --ports=443

# 6. Show the address for the DNS A record
gcloud compute addresses describe bookshelf-web-ip --global --format='value(address)'

Derived from gcloud compute backend-buckets create, gcloud compute url-maps create, gcloud certificate-manager certificates create, gcloud compute target-https-proxies create, gcloud compute forwarding-rules create and the Host a static website page. A Certificate Manager certificate for a domain that is not yet pointing at the load balancer stays provisioning; the managed certificate with load-balancer authorization or DNS authorization is described in the Certificate Manager overview and step-by-step in Cloud CDN and Media CDN, which also adds the HTTP-to-HTTPS redirect, cache modes and invalidation.

A bucket that stays private cannot be read by a plain backend bucket. If domain-restricted sharing or company policy forbids allUsers, keep the bucket private, enable Cloud CDN signed URLs or signed cookies on the backend bucket and issue them from catalog-api, which also lets you gate the site behind a login (see the CDN page).

Firebase Hosting alternative

firebase init hosting followed by firebase deploy --only hosting publishes the dist folder with TLS and a CDN (derived from the Firebase Hosting quickstart). It serves from its own origin, not a bucket you manage, so the bucket controls on this page do not apply to it.

Auditing and monitoring

Admin Activity audit logs (bucket and IAM changes) are always on and free. Data Access audit logs, which record who read or wrote which object, are off by default for Cloud Storage because they can be voluminous, and they are billed as log ingestion. Enable DATA_READ and DATA_WRITE for storage.googleapis.com on sensitive buckets only, and route them to a log bucket or BigQuery sink.

# Add to the project IAM policy (gcloud projects get-iam-policy, edit, set-iam-policy)
auditConfigs:
- service: storage.googleapis.com
  auditLogConfigs:
  - logType: DATA_READ
  - logType: DATA_WRITE
gcloud logging read \
  'protoPayload.serviceName="storage.googleapis.com" AND logName:"cloudaudit.googleapis.com%2Fdata_access"' \
  --limit=5 --format="table(timestamp,protoPayload.methodName,protoPayload.resourceName)"

Derived from the Cloud Storage audit logging page and gcloud logging read. In the console use IAM & Admin → Audit Logs, filter for Google Cloud Storage, and tick Data Read and Data Write. Also look at the Security Command Center findings for public buckets, covered in Governance, Security and Compliance.

Retired and legacy features to avoid

  • Object ACLs and bucket ACLs are replaced by IAM and UBLA; gsutil acl and --predefined-acl do not work on UBLA buckets.

  • gsutil is legacy and minimally maintained, lacks soft delete and managed folders, and after March 2027 stops shipping with the gcloud CLI. Use gcloud storage.

  • Signing with a downloaded key file (gsutil signurl -p) conflicts with key-creation policy; sign through IAM signBlob as above. V2 signed URLs are legacy; use V4.

  • Object change notification (watching a bucket with gsutil notification watchbucket) was deprecated on 2026-01-30; use Pub/Sub notifications or Eventarc (see Cloud Run Functions and Events).

  • Launch stages. Soft delete has been on by default (7 days) since March 2024; secure-by-default organization policies apply from 2024-05-03; Rapid Cache (formerly Anywhere Cache, renamed 2026-03-24) and Storage Intelligence (GA 2026-09-10) are newer; check the release notes before relying on a Preview feature.

  • Public buckets with HTTP-only CNAME hosting for a production site; put the bucket behind the load balancer.

Scaling and limits

A single object name accepts roughly one write per second and bucket metadata or IAM updates are rate limited, so do not use one object as a counter. V4 signed URLs live at most 7 days, and bucket IAM policies, CORS and lifecycle configurations have size limits, so prefer groups and managed folders over thousands of individual bindings (see the quotas and limits page for current numbers). Behind the load balancer, Cloud CDN absorbs repeat reads, and forwarding-rule, proxy and URL-map quotas apply per project.

Pricing model

Prices change; use the Cloud Storage pricing page and the Google Cloud Pricing Calculator and note the date you looked. The security features on this page mostly add small cost drivers:

  • Cloud Storage itself. UBLA, PAP, IAM, IAM Conditions, managed folders, signed URLs, CORS and requester-pays settings have no extra charge beyond storage, operations and network egress. Retention and holds keep bytes you would otherwise delete, and soft-deleted and noncurrent bytes are billed as storage.

  • CMEK. Cloud KMS charges per active key version and per cryptographic operation (more for HSM and external keys); object reads and writes with CMEK each call KMS.

  • Auditing. Admin Activity logs are free; Data Access logs are billed as Cloud Logging ingestion and storage.

  • Static hosting. The global external Application Load Balancer charges per forwarding rule and per GB processed; Cloud CDN charges cache egress and cache fill, and misses are billed as Cloud Storage egress to the load balancer. Firebase Hosting has its own free allowance and then storage and transfer charges.

  • Free tier. The Always Free tier includes a small amount of Standard storage and operations in selected US regions each month; it does not cover load balancing, KMS or logging.

Symptom Likely cause and fix

403 with storage.objects.get access denied on a signed URL

The signing account lacks the permission, or the URL expired. Check the signer’s role on the object and the X-Goog-Expires value; sign again.

SignatureDoesNotMatch on a signed PUT

A signed header differs from the one sent (usually Content-Type), the URL was edited, or the clock is skewed.

Permission 'iam.serviceAccounts.signBlob' denied

Grant roles/iam.serviceAccountTokenCreator on the signing service account to the caller (itself, on Cloud Run) and enable the IAM Credentials API.

Error that ACLs cannot be used with uniform bucket-level access

UBLA is on. Remove the ACL flag and grant an IAM role instead.

Cannot add allUsers: policy violation

PAP is enforced or iam.allowedPolicyMemberDomains rejects public principals. Lift PAP on that one bucket or ask for a scoped exception; otherwise use signed cookies.

Reads fail on a CMEK bucket

The key is disabled, in the wrong location, or the Cloud Storage service agent lacks roles/cloudkms.cryptoKeyEncrypterDecrypter.

Browser reports a CORS error

No matching origin or method in the CORS configuration, or the request went through the load balancer, which ignores bucket CORS; set response headers on the backend bucket.

Site shows AccessDenied XML or 403

The objects are not public (missing allUsers binding), or the backend bucket points at the wrong bucket.

Site returns 404 for /books/42

The object does not exist; set NotFoundPage to the app shell, use hash routing, or rewrite in the URL map.

HTTPS certificate stuck in provisioning

DNS does not yet resolve to the load balancer address, or the certificate map entry is missing.

Clean up

Delete in dependency order, in a throwaway project where possible. A bucket with a locked retention policy cannot be deleted until its objects age out, and soft-deleted objects remain recoverable (and billed) until their window ends.

# 1. Load balancer chain: forwarding rule, proxy, certificate map, URL map, backend bucket, address
gcloud compute forwarding-rules delete bookshelf-https-rule --global --quiet
gcloud compute target-https-proxies delete bookshelf-https-proxy --quiet
gcloud certificate-manager maps entries delete bookshelf-entry --map=bookshelf-map --quiet
gcloud certificate-manager maps delete bookshelf-map --quiet
gcloud certificate-manager certificates delete bookshelf-cert --quiet
gcloud compute url-maps delete bookshelf-web-map --quiet
gcloud compute backend-buckets delete bookshelf-web-backend --quiet
gcloud compute addresses delete bookshelf-web-ip --global --quiet

# 2. Buckets, with every object version
gcloud storage rm --recursive gs://$WEB_BUCKET gs://$UPLOADS_BUCKET gs://bookshelf-archive-$PROJECT_ID

# 3. Schedule destruction of the CMEK key version (key rings cannot be deleted)
gcloud kms keys versions destroy 1 --key=uploads --keyring=bookshelf --location=$REGION

Derived from gcloud storage rm, gcloud compute forwarding-rules delete and gcloud kms keys versions destroy. gcloud storage rm --recursive on a bucket removes the objects, every noncurrent version and the bucket itself; a bucket still under retention fails until the retention period of each object has passed. Soft-deleted objects can be listed with gcloud storage ls --soft-deleted and are purged when the soft-delete window ends. Key-version destruction is scheduled (24 hours by default) and the key ring and key resources remain, so no further charge accrues once the versions are destroyed. If the Firebase site was used, run firebase hosting:disable. Deleting the whole throwaway project removes everything at once (see Resource Hierarchy and Projects), after a 30-day recovery window.