The GCS Backend & Native Locking
The gcs backend stores Terraform state in a Cloud Storage bucket, and the single most important thing to know is that it locks state natively, using GCS object-generation preconditions. There is no DynamoDB-style lock table to provision, as there is on AWS. You declare a backend "gcs" block with a bucket and a prefix, point it at a versioned, CMEK-encrypted bucket, and you have remote state, locking, and history from one resource instead of two.
This is the moment the Hatch pipeline's state leaves the laptop. The hatch-pipeline-dev config has been running on a local terraform.tfstate file; now it moves into the shared hatch-tfstate bucket where a team and CI can run against the same record. The migration is a few lines of configuration, and the GCS backend's native locking is why those few lines are all it takes.
The Backend Block
The backend is declared inside the terraform block. It names the bucket that holds state and a prefix — note prefix, not S3's key — which is a folder-like path under which Terraform writes default.tfstate. The prefix is what lets one bucket hold many configs side by side: each gets its own path and never collides with another.
terraform { backend "gcs" { bucket = "hatch-tfstate" # dedicated, versioned state bucket prefix = "pipeline-dev" # folder-like path, not S3's "key" } }
After adding this block you run terraform init, and Terraform offers to migrate the existing local state into the bucket. From then on, state lives in gs://hatch-tfstate/pipeline-dev/default.tfstate, and every plan and apply reads and writes there.
Native Object-Based Locking
Before any write, the backend creates a <prefix>/default.tflock object using an object-generation precondition — a conditional create that succeeds only if the object does not already exist. If the lock object is already there, the lock is held, and the run blocks until it clears. The lock is just an object in the same state bucket, so there is nothing extra to run, pay for, or keep in sync.
This is the whole GCP story on locking, and it is genuinely simpler than the AWS one. On AWS, S3 stores state but cannot lock itself, so the classic setup adds a separate DynamoDB table for the lock. On GCP that second resource does not exist: the bucket that stores state also locks it. One resource, locking included.
Object Versioning for History and Recovery
Enable object versioning on the state bucket and every apply leaves the prior state as a non-current version of the object. Recovering from a bad apply is then a matter of restoring the previous generation of the state object — not hunting for a separate backup you hopefully remembered to take.
This is not on by default, and the time you discover it was off is exactly the time you needed it. A corrupt or truncated state write with versioning enabled is a one-command rollback; the same write without versioning can be unrecoverable. Turn it on when you create the bucket, before the first apply ever runs against it.
CMEK Encryption of the Bucket
State is encrypted at rest by default with Google-managed keys, but the state bucket is a high-value target that warrants more. Set a customer-managed encryption key (CMEK) on the bucket so that reading the state bytes also requires access to the key — and key access is itself an IAM-governed, auditable control with its own logs. The crown jewels deserve a second lock whose every use is recorded.
resource "google_storage_bucket" "tfstate" { name = "hatch-tfstate" location = "US" versioning { enabled = true } # every apply leaves a recoverable prior version uniform_bucket_level_access = true # IAM only, no per-object ACLs encryption { default_kms_key_name = google_kms_crypto_key.tfstate.id # CMEK } }
This is the bucket the Hatch pipeline points its backend at: versioned for recovery, CMEK-encrypted so key access is logged, and locked to uniform IAM. It is created in a separate bootstrap step, never by the config it backs.
Uniform Bucket-Level Access and the prefix Layout
Turn on uniform bucket-level access so the bucket is governed purely by IAM, with no per-object ACLs that can be mis-set and leak a single state object while the bucket as a whole looks locked down. With uniform access, there is exactly one place access is decided — the bucket's IAM policy — and exactly one place to audit it.
The prefix is then how multiple states share that one hardened bucket. Give each environment and layer its own prefix — pipeline-dev, pipeline-staging, org — and they coexist in hatch-tfstate without ever touching each other's state. Reusing one prefix for two different configs is the one thing to avoid: they would share default.tfstate and overwrite each other.
GCS — locks with an object-generation precondition on a .tflock object inside the same state bucket. One resource, locking included, nothing extra to provision. The shorter setup is not a convenience layer — it is the native behavior of the backend.
AWS S3 + DynamoDB — S3 stores state but cannot lock itself, so the classic setup adds a separate DynamoDB table for the lock, and you pay for and maintain two resources. On GCP that second resource simply does not exist, which is the whole reason the GCP backend setup is shorter.
- Going looking for the GCP equivalent of the DynamoDB lock table and wiring up something unnecessary — GCS locks natively; the lock is an object in the same bucket, and adding anything else is wasted complexity.
- Creating the state bucket with the same Terraform config it backs, producing a chicken-and-egg bootstrap — create
hatch-tfstatein a separate bootstrap step (or with local state, then migrate) before pointing configs at it. - Leaving object versioning off the state bucket, so a bad apply that corrupts state has no prior generation to restore — you discover this exactly when you need the history you didn't enable.
- Reusing one
prefixfor two different configs, so they sharedefault.tfstateand overwrite each other's resources on the next apply. - Force-unlocking with
terraform force-unlockbecause a run "seems stuck," while another apply is genuinely mid-flight — you remove the lock object and let two applies write concurrently, corrupting state. - Leaving per-object ACLs on by skipping uniform bucket-level access — one mis-set ACL exposes a state object full of plaintext secrets while the bucket IAM looks locked down.
- Store state in a dedicated, versioned GCS bucket (
hatch-tfstate) in its own admin project, and rely on the backend's native locking rather than provisioning any separate lock mechanism. - Enable object versioning on the state bucket so every apply is recoverable by restoring the prior object generation.
- Set CMEK on the state bucket and turn on uniform bucket-level access, so encryption and access are both IAM-governed and auditable.
- Give every environment and layer its own
prefixunder the one bucket (pipeline-dev,org) instead of one state for everything. - Reach for
terraform force-unlockonly after confirming no apply is actually running, since unlocking a live run lets two writers corrupt state.
Knowledge Check
Why does the GCS backend need no separate lock table, while the AWS S3 backend classically does?
- GCS locks natively with an object-generation precondition on a
.tflockobject in the same bucket; S3 cannot lock itself, so it adds DynamoDB - GCS does not support state locking at all, so there is simply nothing extra to provision in either backend
- The
googleprovider runs a hidden, project-scoped lock server on your behalf to serialize every apply - GCS state objects are sealed fully immutable the instant they are first written, so a second concurrent apply can never clobber them and locking would be wholly redundant
What does the prefix setting do in a gcs backend block?
- It names a folder-like path under which Terraform writes
default.tfstate, letting one bucket hold many configs side by side - It supplies the Cloud KMS encryption-key prefix that scopes which CMEK key wraps the state object
- It sets the bucket's storage region, choosing where Terraform replicates the state across zones
- It is a direct alias for S3's
keysetting, naming the full object path with byte-for-byte identical behavior and no differences from the AWS backend whatsoever
A bad apply corrupts the state object. What makes this recoverable on a properly configured state bucket?
- Object versioning, which keeps the prior state as a non-current version you can restore
- The DynamoDB lock table keeps a full backup copy of every prior state it can restore
- CMEK automatically re-creates the lost state file by decrypting it back from the KMS key
- The
prefixsetting transparently stores a second, redundant copy of the state object
When is running terraform force-unlock dangerous?
- When another apply is genuinely mid-flight — removing the lock lets two applies write concurrently and corrupt state
- Whenever object versioning is enabled on the bucket, because force-unlock then cascades into permanently deleting every prior non-current state version it finds
- It is never genuinely dangerous, since the GCS lock is purely advisory and writes are serialized anyway
- Only when the bucket uses a CMEK key instead of the default Google-managed encryption keys
You got correct