Chapter 10: Structuring Projects & Environments at Scale
Topic 64

Project and Repository Layout

LayoutState

The single most consequential decision in a growing GCP estate is not which resource you write but where the root modules live and how state is partitioned across them. Get that right and adding the fiftieth project is a copy of the forty-ninth; get it wrong and every apply grows slower and more dangerous as the estate grows. The durable shape is one root module per project-per-environment, each with its own state, composed from a shared modules/ library.

The repository question — one repo or many — is downstream of that partitioning, not the other way around. Decide your state boundaries first, then decide where the directories that own those boundaries physically live. This topic works through the layout Hatch settles on: a stack per project-environment, a thin envs/ tree over a fat modules/ tree, one state object per stack in hatch-tfstate, and a bootstrap stack to break the chicken-and-egg of creating the state bucket.

One Root Module Per Stack

A stack is a unit you plan and apply together, with its own state. The whole game of structuring at scale is choosing where the seams between stacks fall, because a seam is a blast-radius boundary: an apply can only touch what its state tracks. On GCP the natural seam is the project-environment. The Shared VPC host project, each service project, and the org-level IAM bindings are separate roots — separate directories, separate state — not one giant apply that plans the entire org every time.

So hatch-app-prod and hatch-app-staging are two stacks, the network host project is a third, and the folder-and-IAM scaffolding is a fourth. Each has a root module of its own. When you change a firewall rule in hatch-app-prod, the plan reads only that project's state and proposes only that project's diff — production network and the data platform are nowhere in the run, because they are not in that stack's state.

The modules/ + envs/ Layout

Reusable logic lives in a modules/ tree — a service-project module, an ingest-pipeline module — and envs/<project>/ directories instantiate them with per-environment values. The env directory is deliberately thin: a backend block, a provider, and a short module call. The module carries the logic; the environment carries only what makes it that environment. The third environment is exactly where copy-pasted resource blocks start to drift, and a fat module is what removes the surface they drift on.

The modules/ + envs/ layout in one repo
# hatch-io/infrastructure
modules/
  service-project/        # the reusable logic, written once
    main.tf
    variables.tf
    outputs.tf
  ingest-pipeline/
envs/
  hatch-app-prod/         # thin: a backend, a provider, a module call
    main.tf
    backend.tf
    prod.tfvars
  hatch-app-staging/
  hatch-net-host/
bootstrap/                # creates the state bucket itself (see below)

Each envs/ directory's main.tf is a handful of lines: it calls module "service_project" from ../../modules/service-project and passes the project name and environment. The same module backs every environment, so a fix to the module reaches all of them on the next apply, and there is no second copy to forget.

Fat modules/, thin envs/, one repo
modules/
shared logic, written once
envs/<project>/
thin call + backend + .tfvars
hatch-tfstate
one state prefix per stack

Where State Lives Per Stack

Every root module gets its own state object in the shared hatch-tfstate bucket, keyed by a prefix. The bucket is one; the prefixes are many. envs/hatch-app-prod writes to prefix = "apps/hatch-app-prod", the staging stack to apps/hatch-app-staging, the network host to platform/hatch-net-host. Separate state per stack is what bounds blast radius — because state defines what an apply can see and change, a per-stack state means a per-stack blast radius.

Per-stack backend: one bucket, a prefix per stack
terraform {
  backend "gcs" {
    bucket = "hatch-tfstate"
    prefix = "apps/hatch-app-prod"   # the only line that changes per stack
  }
}

The discipline here is that two stacks must never share a prefix to save a bucket object. Shared state collapses the boundary: a destroy in one stack reads the shared state and proposes deleting the other's resources, because as far as the state is concerned they are one estate.

Mono-Repo vs Multi-Repo

One hatch-io/infrastructure mono-repo keeps the module library and every environment reviewable in a single pull request and versioned atomically — a module change and the environment that consumes it move together, and you can read the whole estate in one clone. Splitting into per-team repos buys independent permissions and CI isolation, at the cost of cross-repo module versioning: now modules/ is a published, version-tagged dependency and a change is a release, not an edit.

Pick the mono-repo until team boundaries genuinely force the split. The trigger is organizational, not technical — when two teams need different people approving their applies and different CI permissions, a shared repo fights you. Until then, one repo with strong per-stack state boundaries gives you isolation where it matters (state and apply) without paying the multi-repo versioning tax everywhere else.

The Bootstrap Stack

There is one stack that cannot follow the rule. The state bucket, the hatch-admin project that holds it, and the org-level Workload Identity Federation pool cannot store their state in a bucket that does not yet exist — the classic chicken-and-egg. The answer is a separate bootstrap/ root that runs with local state first, creates the foundation, and is then migrated to remote state once the bucket it just created is available to hold it.

So the bootstrap stack is run once, by hand, with a local terraform.tfstate; it creates hatch-tfstate and hatch-admin; then you add a gcs backend block pointing at the bucket and run terraform init -migrate-state to move its own state into the bucket it owns. After that one bootstrap, every other stack has a bucket to write to, and the foundation is itself Terraform-managed like everything else.

One mega-state vs per-stack state

One state for everything — every project and environment in a single root module and a single state file. Tempting because it is one apply, but a routine change to a dev bucket locks and re-plans the entire production estate, and one bad apply can damage all of it. The blast radius is the whole org.

Per-stack state — one root module and one state prefix per project-environment. More directories, but a plan reads only its own stack, applies are fast, and the worst an apply can do is bounded to one stack. This is the default Hatch runs.

Common Mistakes
  • Putting every project and environment in one root module with one state file, then watching a routine apply to a dev bucket lock and re-plan the entire production estate — one state means one blast radius for the whole org.
  • Duplicating full resource definitions in each envs/ directory instead of factoring them into modules/ — the third environment is where copy-paste drift silently begins and a fix lands in one directory only.
  • Letting envs/hatch-app-prod and envs/hatch-app-staging share a single state prefix to "save a bucket" — a destroy in one then proposes deleting the other's resources.
  • Trying to bootstrap the state bucket into its own remote backend before it exists — the chicken-and-egg failure every new org hits on day one, fixed only by starting with local state.
  • Splitting into per-team repos on day one for "isolation," then maintaining a versioned module-release process across repos before any team boundary actually demanded it.
Best Practices
  • Define one root module per project-per-environment, each with its own state prefix in hatch-tfstate, so blast radius equals one stack.
  • Keep all reusable logic in modules/ and make envs/<project>/ directories thin instantiations carrying only per-environment values.
  • Start with a single hatch-io/infrastructure mono-repo and split to per-team repos only when permission boundaries genuinely demand it.
  • Stand up a dedicated bootstrap stack for the state bucket, admin project, and WIF pool, and migrate it to remote state with init -migrate-state once the bucket exists.
  • Choose the state seam first and let the repo layout follow, since the partitioning that bounds blast radius is the decision that actually matters.
Comparable tools Terragrunt DRY backend and layout generation over the same modules Terramate stack orchestration inside a mono-repo Pulumi stacks a stack per project/env as a first-class object Cloud Foundation Toolkit Google's reference layout repos

Knowledge Check

What defines a "stack" in this layout, and why does each get its own state?

  • A unit you plan and apply together; its own state bounds what an apply can see and change, so blast radius equals one stack
  • A single .tf file; each individual file in the directory is tracked in its own separate state instance for faster, smaller plans
  • A GCP folder in the resource hierarchy; folders map one-to-one to state files automatically
  • A reusable module under modules/; each module ships with its own dedicated state file

Why is putting the whole estate in one root module with one state file a problem?

  • A routine apply to one resource locks and re-plans the entire estate, so the blast radius is the whole org
  • Terraform hard-refuses to manage more than a fixed number of resources inside a single state file
  • A single state file that large cannot use a GCS backend at all and is forced to stay on the local disk forever
  • Each resource in that state would need its own unique provider configuration block

When should Hatch split its mono-repo into per-team repositories?

  • When team boundaries genuinely require different approvers and CI permissions, accepting cross-repo module versioning as the cost
  • On day one, because polyrepos are always strictly safer than a mono-repo regardless of team size
  • Once the repo exceeds a fixed number of files or directories, regardless of how the engineering teams happen to be structured around it
  • Never — a mono-repo can never be split apart once any state has been created against it

Why does the state bucket need a separate bootstrap stack?

  • A stack cannot store its state in a bucket that does not yet exist, so the bucket is created with local state and then migrated to remote
  • Bootstrap stacks run faster than the rest because they skip the plan phase entirely on every apply
  • GCS state buckets can only ever be created by hand through the Console UI, never by a google_storage_bucket resource declared in any config
  • The state bucket must be created in a different cloud provider first to guarantee redundancy

You got correct