Team Backends on GCS
A solo backend is one bucket and one engineer running apply. A team backend is the same hatch-tfstate bucket carved into many prefix-separated stacks, with native GCS locking so two pipelines never write the same state at once, and bucket IAM tight enough that only CI and admins can touch any of it. The bucket from Chapter 2 already has object versioning and a customer-managed encryption key; this topic is about making it safe for many hands.
Nothing here is a new bucket. The whole move from solo to team is policy on the one bucket you already have: how stacks share it, how locking prevents corruption, who has a write path to state, and how you recover when an apply goes wrong. Get those four right and a dozen engineers can run Terraform against the same backend without ever stepping on each other.
One Bucket, Many Prefixes
Every stack in the org uses the same hatch-tfstate bucket and is separated by a distinct prefix — prod/network, apps/prod, data/prod. The prefix is the only thing that changes between one stack's backend block and another's, so the configuration is nearly identical everywhere and the bucket's IAM, versioning, and CMEK policy apply uniformly to all of it. One bucket to secure, one policy to reason about, many stacks behind it.
The backend block below is what every stack carries, varying by a single line. That single-line difference is deliberate: it means a new stack inherits the bucket's entire security posture for free, rather than recreating it.
terraform { backend "gcs" { bucket = "hatch-tfstate" # the one shared bucket prefix = "apps/prod" # the only line that differs per stack } }
Point the network stack at prod/network and the data stack at data/prod and they share storage but never share a state object. The bucket is the unit of security; the prefix is the unit of isolation.
Native GCS Locking
The GCS backend acquires a lock object alongside the state object before it writes. A second apply against the same prefix sees that lock and waits or fails fast, rather than racing the first one and corrupting the state. This is built into the backend — there is no separate lock table to provision. If you came from AWS, this is the sharp contrast: the S3 backend needs a DynamoDB table standing beside it to hold locks, and GCS folds that responsibility into the bucket itself.
The practical payoff is one less thing to bootstrap and one less thing to keep in sync. You do not create, secure, or pay for a lock table; the moment the bucket exists, locking works for every prefix in it.
Bucket IAM, Not Object ACLs
Turn on uniform bucket-level access and govern the whole bucket with one IAM policy: roles/storage.objectAdmin granted only to the CI service account and the org-admin group, and to no one else. Developers get no write path to state at all. The only way prod state changes is through the pipeline, which is exactly the property you want — a developer cannot run a local apply against prod even by accident, because they cannot write the resulting state.
Uniform bucket-level access matters because the alternative, legacy per-object ACLs, drifts. Every state object would carry its own access list, those lists diverge over time, and you lose the single policy that should govern all state. One bucket, one policy, no per-object exceptions.
Versioning as the Recovery Mechanism
Object versioning keeps every prior state object, which makes it the team's undo button. A bad apply that mangles state, or an accidental terraform state rm that drops a resource, is recoverable by promoting the previous version of the state object back into place. With versioning off, a botched state rm is permanent: the previous state is gone and you rebuild it by hand against live resources, mapping each real object back into a fresh state file.
Keep versioning on permanently, and pair it with a lifecycle rule that retains noncurrent versions for a bounded window — 90 days is a reasonable recovery horizon — so the bucket does not grow without limit while still giving you a real window to recover. Versioning is why the state bucket is never lifecycle-deleted aggressively.
Locking Down the Bucket Itself
The bucket holds every team's state, so destroying it is catastrophic. Set force_destroy = false so a terraform destroy cannot wipe a bucket full of state objects, adopt a deletion-protection posture, and let the noncurrent-version lifecycle rule keep recovery possible without unbounded growth. These settings make the bucket boring and durable, which is the goal.
There is a bootstrap wrinkle: the hatch-tfstate bucket cannot store the state that creates it. So it lives in its own hatch-admin project and is managed by a small bootstrap config that uses local state — a chicken-and-egg you resolve by letting one tiny config keep its state on disk while every other stack stores state in the bucket that bootstrap config creates.
GCS backend — locking is built in. The backend writes a lock object beside the state object in the same bucket, so a concurrent apply on the same prefix waits or fails. Nothing extra to provision, secure, or pay for.
S3 + DynamoDB backend — the AWS structural analog. State lives in S3 but the lock lives in a separate DynamoDB table you must create and grant access to. Same outcome, two resources to keep in sync instead of one. On GCP the lock table simply does not exist.
- Giving every stack its own bucket instead of one bucket with prefixes — you multiply the IAM surface, the versioning config, and the CMEK wiring, and every new stack is a new bucket to secure correctly.
- Granting
gcp-developers@hatch.iowrite access tohatch-tfstateso they can runapplylocally — now any developer can corrupt prod state outside the pipeline, which defeats the point of CI-owned state. - Leaving object versioning off and discovering after a botched
state rmthat the previous state is gone — there is no recovery, and you rebuild state by hand against the live resources. - Setting
force_destroy = trueon the state bucket — aterraform destroyof the admin stack can wipe every team's state in a single command. - Using legacy object ACLs instead of uniform bucket-level access — per-object ACLs drift over time and you lose the single IAM policy that should govern all state.
- Use one versioned, CMEK-encrypted
hatch-tfstatebucket with a per-stackprefix, and enable uniform bucket-level access so a single IAM policy governs all state. - Grant
roles/storage.objectAdminon the bucket only to the CI service account andgcp-org-admins@hatch.io; never to developers. - Keep object versioning on permanently and set a lifecycle rule that retains noncurrent versions for at least 90 days as the recovery window.
- Manage the state bucket from a separate bootstrap config in the
hatch-adminproject withforce_destroy = false, so it cannot be destroyed by a stack'sapply. - Keep the backend block identical across stacks except for the
prefix, so a new stack inherits the bucket's full security posture without reconfiguration.
Knowledge Check
Why does the org use one hatch-tfstate bucket with per-stack prefixes instead of one bucket per stack?
- A single bucket means one IAM policy, one versioning config, and one CMEK setup govern all state, instead of re-securing a new bucket per stack
- A single GCS bucket can only ever hold one state object at a time, so per-stack prefixes are strictly required to store more than a single stack in it
- Per-stack prefixes make every Terraform plan and apply run measurably faster than separate buckets would
- GCS charges a flat monthly fee per bucket, so one shared bucket is cheaper regardless of the security model
How does GCS native locking differ from the AWS S3 backend's locking?
- GCS writes a lock object in the same bucket, so there is no separate lock table to provision; S3 needs a DynamoDB table beside it
- GCS does not lock the state at all and instead relies entirely on developers coordinating their applies by hand
- GCS requires you to separately provision and maintain a Cloud Spanner lock table beside the bucket, the way the S3 backend requires a DynamoDB one
- GCS locks the entire bucket at once, so only one stack can apply at a time across every prefix in it
An engineer runs an accidental terraform state rm and drops a resource from state. What recovers it?
- Object versioning — promoting the previous version of the state object back into place
- Nothing at all; a state removal is always permanent, so you must rebuild the whole stack from scratch
- Simply re-running terraform apply, which automatically re-imports and restores any removed state entries
- The lock object beside the state, which keeps a backup copy of the last known-good state
Why should developers have no write access to the hatch-tfstate bucket?
- Without a write path to state, the only way to change prod is through the reviewed pipeline, so no one can corrupt prod state with a local apply
- Developers each writing their own state into the shared bucket would quickly exceed its fixed storage quota and break the whole shared CI pipeline for everyone
- Write access to the bucket would let any developer read the CMEK encryption key material in plaintext
- It is strictly required by GCS itself, since a bucket rejects more than two concurrent writers regardless of policy
You got correct