Part 4 of 929 min read · 7 diagramsAI-assisted

Workspaces, Environments & Real-World Repository Structure

Assumes you're comfortable with Part 2's state splitting and Part 3's module design — this chapter is where those two ideas combine into the actual directory layout and repository shape a real platform team runs day to day, at the scale of dozens of services, several environments, and (by the end of the chapter) more than one cloud.

Table of Contents#

  1. Why Repository Structure Deserves Its Own Chapter
  2. Terraform Workspaces — What They Actually Are
  3. Workspaces vs. Directory-Per-Environment — the Real Tradeoff
  4. The Directory-Per-Environment Pattern in Practice
  5. The DRY Problem Directory-Per-Environment Creates
  6. Terragrunt and the Infrastructure-Live Pattern
  7. A Variable Hierarchy: Global, Account, Region, Environment
  8. Monorepo vs. Polyrepo for Terraform at Scale
  9. A Concrete Repository Layout for a Mid-Size Platform Team
  10. Bootstrapping the State Backend Itself
  11. Promoting a Change From Dev to Prod
  12. Multi-Account AWS — Landing Zones and Organizations
  13. Cross-Account Access — the Hub-and-Spoke assume_role Pattern
  14. Multi-Cloud Directory Structure — Keeping Clouds Cleanly Separated
  15. HCP Terraform Stacks — a Native Alternative to Terragrunt
  16. Naming Conventions Across the Tree
  17. Choosing Your Structure — a Decision Framework
  18. Worked Scenario: Migrating Off Workspaces Into Directory-Per-Environment
  19. Worked Scenario: Standing Up a Second Cloud for Disaster Recovery
  20. Worked Scenario: the tfvars Override That Silently Hit the Wrong Account
  21. Part 4 CLI Cheat Sheet
  22. Common Mistakes and Interview Traps
  23. Worked Practice Problems
  24. Summary and What's Next

Why Repository Structure Deserves Its Own Chapter#

Almost every serious Terraform incident that isn't a state-management problem (Part 2) is a repository- structure problem — a change meant for staging that silently applied to production, a .tfvars file copied from the wrong environment, a shared module change that rippled into every account at once because nothing separated them. None of this is about HCL syntax. It's about how a codebase is physically laid out: which directory maps to which real environment, which account, and — once a company has more than one — which cloud, and how much of that mapping is enforced by structure itself versus trusted to a human reading a folder name correctly under pressure.

This is also the chapter where the throughline system stops being a convenient teaching device and starts looking like what a real platform team actually runs: checkout-service, catalog-service, and inventory-service, each needing dev/staging/prod, all inside one AWS Organization, with a second cloud (GCP) added for disaster recovery by the end of this chapter — and the actual directory tree that holds all of it.

Note

Everything in this chapter is provider-agnostic in principle but AWS-flavored in its concrete examples (account structure, assume_role), consistent with the rest of this series — the same directory-structure and DRY-vs-explicit tradeoffs apply identically under Azure subscriptions or GCP projects, which the multi-cloud section later in this chapter addresses directly.

Terraform Workspaces — What They Actually Are#

A CLI workspace is a named, isolated state file within the same backend configuration — terraform workspace new staging creates a second, independent state under the same bucket/key, addressable via terraform.workspace inside your configuration. This is a genuinely different feature from an "HCP Terraform workspace" (a whole managed run environment in the SaaS product) despite the identical name — a recurring source of confusion worth clearing up immediately.

terraform workspace new staging
terraform workspace new prod
terraform workspace select staging
terraform workspace list
#   default
# * staging
#   prod
locals {
  instance_type = terraform.workspace == "prod" ? "m6i.large" : "t3.micro"
}
TermWhat it actually is
CLI workspace (terraform workspace)A named state file, same backend, same configuration — a free-tier, built-in feature
HCP Terraform workspaceAn entire managed run environment (its own variables, run history, VCS connection) in HashiCorp's SaaS product

Warning

These two concepts sharing one name is a genuine, ongoing source of confusion in job interviews and real conversations alike — always clarify which one is meant before answering a question that uses the bare word "workspace." This series uses "CLI workspace" explicitly whenever the distinction matters.

Workspaces vs. Directory-Per-Environment — the Real Tradeoff#

CLI workspaces switch which state file you're pointed at without changing which .tf files are loaded — which is exactly their strength for genuinely ephemeral, structurally-identical environments, and exactly their weakness for anything long-lived and meaningfully different.

Diagram

The single sharpest real-world danger with CLI workspaces: switching workspaces is a silent, local CLI state changeterraform workspace select prod followed by an apply intended for staging, because the engineer forgot which workspace they'd last selected, is a genuinely common way to apply a staging-sized change against production. Directory-per-environment structurally prevents this exact mistake: the working directory itself (environments/prod/) is what you'd have to be in, and that's visible in every terminal prompt, every cd, every CI job's working-directory setting — there's no separate, invisible "current workspace" state to forget about.

QuestionPoints toward
Are the environments genuinely, structurally identical (a load-test copy of prod, a per-PR preview)?CLI workspaces
Do environments have real structural differences (prod is Multi-AZ, dev is single-AZ; prod has WAF, dev doesn't)?Directory-per-environment
Is accidentally applying to the wrong environment catastrophic?Directory-per-environment — the explicit directory is a real safety layer
Are environments created and destroyed constantly (dozens of short-lived PR previews)?CLI workspaces — creating a new directory per PR doesn't scale

Tip

Best practice, confirmed by wide production consensus: use directories for permanent environments (dev/staging/prod) and reserve CLI workspaces for genuinely ephemeral ones (a feature-branch preview, a load-test sandbox spun up and destroyed within hours). Many mature platform teams use both at once — directories for the permanent shape, workspaces layered inside a directory for that directory's own short-lived variants.

The Directory-Per-Environment Pattern in Practice#

The straightforward version of this pattern is one directory per environment, each a complete root module in its own right, each with its own backend configuration and its own .tfvars file.

environments/ ├── dev/ │ ├── main.tf │ ├── backend.tf │ └── terraform.tfvars ├── staging/ │ ├── main.tf │ ├── backend.tf │ └── terraform.tfvars └── prod/ ├── main.tf ├── backend.tf └── terraform.tfvars
# environments/prod/main.tf
module "checkout_database" {
  source = "../../modules/database"

  instance_class     = "db.r6g.large"
  multi_az           = true
  backup_retention   = 30
}
# environments/dev/main.tf
module "checkout_database" {
  source = "../../modules/database"

  instance_class     = "db.t4g.small"
  multi_az           = false
  backup_retention   = 1
}

Each environment's main.tf is a real, honest description of what that environment actually is — a reader opening environments/prod/main.tf sees multi_az = true right there, with no conditional expression to trace through to figure out what prod actually gets. This directness is the entire value proposition of the pattern, and it's also exactly what creates the next section's problem.

The DRY Problem Directory-Per-Environment Creates#

The moment three environment directories share 90% identical structure and differ only in a handful of values, plain directory-per-environment starts to hurt — a change to checkout_database's module call (a new argument, a renamed variable) now has to be made in three places, by hand, and it's easy for one environment to quietly drift out of sync with the other two.

Diagram

This is precisely the gap the two mechanisms in the next two sections exist to close — a variable hierarchy (pulling shared defaults up and out) and Terragrunt (a wrapper generating the repetitive parts) both attack the same underlying problem from different angles: keep each environment's actual differences explicit and readable, while eliminating the accidental duplication of everything that's genuinely the same.

Terragrunt and the Infrastructure-Live Pattern#

Terragrunt is a thin wrapper around the terraform CLI, popularized by Gruntwork, that generates boilerplate (backend config, provider config, common variables) from a DRY hierarchy of small .hcl files — the most widely adopted answer to directory-per-environment's duplication problem.

infrastructure-live/ ├── terragrunt.hcl # Root: shared backend config generation ├── prod/ │ ├── account.hcl # Account-level values (account ID, environment name) │ ├── us-east-1/ │ │ ├── region.hcl # Region-level values │ │ └── checkout-database/ │ │ └── terragrunt.hcl # Points at the module + this deployment's own overrides
# prod/us-east-1/checkout-database/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://github.com/meridian-platform/terraform-modules.git//database?ref=v2.3.1"
}

inputs = {
  instance_class   = "db.r6g.large"
  multi_az         = true
  backup_retention = 30
}

The include "root" block pulls in shared configuration (backend generation, provider generation) defined once at the repository root, so every leaf terragrunt.hcl file only ever states what's genuinely specific to that one deployment — the module source/version and the environment-specific input values, nothing else. Running terragrunt apply in this leaf directory generates the full Terraform configuration (backend block, provider block, module call) on the fly from the hierarchy, applies it, and leaves the generated files as disposable artifacts, not something committed to version control.

ConcernPlain Terraform directoriesTerragrunt
Backend config repetitionCopy-pasted per environmentGenerated once, inherited everywhere
Provider config repetitionCopy-pasted per environmentGenerated once, inherited everywhere
Module version pinningPer environment's main.tfPer leaf terragrunt.hcl, same DRY inheritance
Learning curveNone beyond Terraform itselfA second tool, its own HCL-like syntax and mental model
terraform plan -all-equivalent across many deploymentsNot built interragrunt run-all plan — built-in orchestration across the whole tree

Tip

Best practice: adopt Terragrunt once genuine, growing duplication (per the quadrant chart above) is a real, felt pain — not preemptively for a three-environment setup that plain directories already handle comfortably. It's a real second tool with its own learning curve and failure modes; the DRY payoff needs to be worth that cost, which it usually is somewhere between "a handful of environments" and "dozens of accounts times regions times environments," not before.

A Variable Hierarchy: Global, Account, Region, Environment#

Whether implemented via Terragrunt's find_in_parent_folders() mechanism or a simpler hand-rolled .tfvars merge, the underlying idea is the same: values that genuinely vary by scope should be defined at that scope, once, and inherited downward — not copy-pasted into every leaf that needs them.

Diagram

This chapter's caption: each layer only ever states what's genuinely new at that scope — region.hcl never repeats org_name, and the leaf terragrunt.hcl never repeats the region's AZ list; every layer below inherits everything above it automatically.

A hand-rolled equivalent without Terragrunt is achievable too, using Terraform's own -var-file layering (later files override earlier ones):

terraform apply \
  -var-file=../../global.tfvars \
  -var-file=../account.tfvars \
  -var-file=./region.tfvars \
  -var-file=./prod.tfvars

Note

This layered-.tfvars approach gets real DRY benefit without adopting a second tool, at the cost of the caller (a human or a CI job) needing to remember the correct file order every time — a real, ongoing discipline burden Terragrunt's automatic parent-folder inheritance removes entirely. Weigh this against the earlier "adopt Terragrunt once the pain is real" guidance rather than treating either as an automatic default.

Monorepo vs. Polyrepo for Terraform at Scale#

Whether all of this infrastructure code lives in one repository or is split across many is a genuinely separate decision from directory structure — and, like CLI-workspaces-vs-directories, the right answer tracks team size and ownership boundaries more than any technical property of Terraform itself.

FactorFavors monorepoFavors polyrepo
Team size2-15 engineers, tightly collaborating50+ engineers, autonomous teams
Module change frequencyModules and their consumers change together oftenModules have independent release cycles from consumers
Access control granularityEveryone reasonably needs broad read accessDifferent teams need genuinely separate repo-level permissions
Shared toolingOne CI pipeline, one linting config, applies everywhereEach team wants its own pipeline cadence and gating rules
Cross-cutting refactorsOne PR touches every affected environment at onceA refactor requires coordinating PRs across several repos

For the platform team running checkout-service, catalog-service, and inventory-service, a monorepo (infrastructure-live/, one repository, directory-per-service-per-environment inside it) fits comfortably — one small team, tightly coupled ownership, and the cross-cutting refactor benefit (Part 3's module extraction scenario touched all three services in one coordinated change) genuinely matters. A larger organization with dozens of autonomous product teams, each owning its own infrastructure independently, would reasonably split by team or by service instead — Google and Meta's internal monorepo-at-massive-scale approach works because of enormous, purpose-built tooling investment most organizations don't have and shouldn't try to replicate.

Tip

Best practice: default to a monorepo for infrastructure code up to the point where a genuine cross-team ownership or access-control boundary appears — splitting later, once that boundary is real, is far less painful than prematurely fragmenting a small team's infrastructure across repositories they all need broad visibility into anyway.

A Concrete Repository Layout for a Mid-Size Platform Team#

Pulling every pattern in this chapter together, here's the platform team's actual infrastructure-live repository, at the point in the series where checkout-service, catalog-service, and inventory-service all run across dev/staging/prod in one AWS Organization:

infrastructure-live/ ├── terragrunt.hcl # Root: backend + provider generation ├── global.hcl # org_name, common tags ├── dev/ │ ├── account.hcl # Dev account ID │ └── us-east-1/ │ ├── region.hcl │ ├── network/terragrunt.hcl │ ├── checkout-service/ │ │ ├── database/terragrunt.hcl │ │ └── eks-node-group/terragrunt.hcl │ ├── catalog-service/... │ └── inventory-service/... ├── staging/ # Same shape as dev/ ├── prod/ │ └── us-east-1/ │ ├── network/terragrunt.hcl │ ├── checkout-service/... │ ├── catalog-service/... │ └── inventory-service/... └── modules/ # (or a separate terraform-modules repo, per Part 3) ├── network/ ├── database/ └── eks-node-group/

Each service's directory under each environment mirrors Part 2's state-splitting boundaries exactly — one Terragrunt deployment (and therefore one state file) per service per environment, all consuming the shared network deployment's outputs via dependency blocks (Terragrunt's own equivalent of Part 2's terraform_remote_state, resolved automatically from the tree structure rather than a hand-typed backend config).

Important

Notice the structure directly encodes the org chart this chapter opened with: environment (dev/staging/ prod) is the outermost split, matching the blast-radius priority from Part 2 — a mistake in dev should never even be structurally capable of touching prod, and this layout enforces that by construction, not by convention alone.

Bootstrapping the State Backend Itself#

A genuine chicken-and-egg problem every new environment/account hits: Part 2's remote backend (an S3 bucket with locking enabled) is itself infrastructure — so what manages it, if Terraform needs a working backend before it can safely run at all?

The standard resolution is a small, deliberately separate "bootstrap" configuration, using local state (the one legitimate long-term use of local state this series endorses, beyond a throwaway sandbox) purely for the handful of resources — the state bucket, its versioning and locking configuration, and the IAM policy scoping access to it — that every other configuration in that account will depend on:

infrastructure-live/ ├── bootstrap/ │ └── prod/ │ ├── main.tf # Creates the prod account's own state bucket │ └── terraform.tfstate # LOCAL state — deliberately, for this one directory only ├── prod/ │ └── us-east-1/ │ └── network/ # Uses the bucket bootstrap/ just created, as ITS backend
# bootstrap/prod/main.tf
resource "aws_s3_bucket" "tfstate" {
  bucket = "meridian-platform-tfstate-prod"
}

resource "aws_s3_bucket_versioning" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  versioning_configuration { status = "Enabled" }
}

Once applied, every other configuration in that account references this bucket in its own backend "s3" block — but the bootstrap configuration itself has nowhere "further down" to delegate its own state to, which is exactly why it's the one deliberate, permanent exception to "always use a remote backend." Its local state file is small, rarely changes after initial creation, and — critically — should still be backed up (committed to a tightly-access-controlled internal repository, or copied somewhere durable) since it's the literal foundation every other state in that account depends on existing correctly.

Important

This bootstrap configuration should be the smallest possible surface — the state bucket and its locking/versioning/IAM configuration, nothing else. Resist the temptation to add "just one more thing" (a shared IAM role, a KMS key) to it once it exists; each addition makes the one configuration in the whole system without a proper remote backend larger and riskier to lose track of.

Promoting a Change From Dev to Prod#

With environments split into their own directories (or Terragrunt deployments), "promotion" is the actual mechanism by which a change reaches production — and the discipline here is what turns directory isolation from a nice structural property into an enforced deployment process.

Diagram

This chapter's caption: the same underlying change (a module version bump, or a .tfvars value) moves through three separate, reviewed PRs rather than one PR touching all three environments simultaneously — each promotion step is its own decision point, not an automatic cascade.

The concrete mechanism varies by how modules are sourced: if staging/ and prod/ pin a module by git tag or registry version (Part 3), promotion is literally bumping that pinned version in one directory's terragrunt.hcl (or main.tf) at a time, in order, each its own reviewed PR — never one PR editing all three environments' pins simultaneously, which would defeat the entire point of graduated, observed promotion.

Tip

Best practice: never let staging or prod point at an unpinned branch ref ("always deploy latest") the way a dev environment reasonably might for fast iteration — pinned, deliberately-bumped versions are what make "promote this exact, already-verified-in-dev change" a meaningful, auditable action rather than an ambiguous "whatever the branch currently contains right now."

Multi-Account AWS — Landing Zones and Organizations#

At real scale, "environment" and "AWS account" become the same boundary — dev, staging, and prod each get their own AWS account under one AWS Organization, not just separate directories or .tfvars files inside a single shared account. This is the single strongest blast-radius control available: an IAM policy mistake, a leaked credential, or a runaway resource in the dev account has no path to reach prod at all, because they're different accounts with different credentials entirely — not a permissions boundary inside one account that a misconfiguration could erode.

Diagram

This chapter's caption: dev, staging, and prod are peer accounts under one OU, not nested inside each other — none of them can reach another by default, and every cross-account interaction (state bucket access, CI role assumption) has to be explicitly granted, which is exactly the property a single-account, directory-only isolation strategy can't offer.

For a mid-size organization this typically lands at 10-50 accounts (fewer, coarser OUs); a large enterprise runs 100-500+. AWS Control Tower (or the newer Terraform-native "Account Factory for Terraform," AFT) is the common automation layer for account creation itself — genuinely out of scope for a Terraform-content-only series, but worth knowing exists as the tool that provisions the accounts this chapter's directory structure then targets.

Tip

Best practice: even a small team should seriously consider account-per-environment over account-per-team-with-directory-isolation the moment the cost of a dev-to-prod blast-radius mistake is genuinely unacceptable — the account boundary is a structural guarantee that requires deliberate, explicit, and auditable configuration to cross, where directory isolation alone only requires a human reading the right folder correctly.

Cross-Account Access — the Hub-and-Spoke assume_role Pattern#

With separate AWS accounts, a Terraform run needs a way to act in the target account without holding that account's own long-lived credentials directly — the standard pattern is a "hub" identity (a CI role, or a human's SSO identity) that assumes a "spoke" role scoped to exactly one target account.

provider "aws" {
  alias  = "prod"
  region = "us-east-1"

  assume_role {
    role_arn     = "arn:aws:iam::444455556666:role/terraform-prod-deployer"
    session_name = "terraform-ci-${terraform.workspace}"
  }
}
Diagram

The target account's terraform-prod-deployer role's trust policy is the actual security control — it names exactly which hub identity is permitted to assume it, and the role's own IAM policy scopes exactly what that session can do once assumed. No long-lived credential for the prod account ever needs to exist outside that account itself; the CI hub only ever holds credentials for assuming into other accounts, each session expiring automatically.

Tip

Best practice: scope each spoke role as tightly as the workload genuinely needs (least privilege, not a blanket AdministratorAccess "because it's easier") and give each environment its own distinct spoke role rather than one shared "terraform-deployer" role reused with different session names — a distinct role per environment means the trust policy and permission boundary are independently auditable and independently revocable per environment, not one shared blast radius across all of them.

Multi-Cloud Directory Structure — Keeping Clouds Cleanly Separated#

When a second cloud provider enters the picture — GCP for disaster recovery, per this chapter's closing scenario — the same discipline that separates environments applies again, one level up: never mix two clouds' resources inside the same state file, and keep cloud-specific configuration in its own directory branch rather than interleaved with the primary cloud's.

infrastructure-live/ ├── aws/ │ ├── dev/us-east-1/... │ ├── staging/us-east-1/... │ └── prod/us-east-1/... └── gcp/ └── dr/us-central1/ ├── network/terragrunt.hcl └── checkout-service-replica/terragrunt.hcl

Splitting at the cloud level, above environment, keeps each cloud's account/project structure, IAM model, and provider configuration fully independent — an AWS Organizational Unit and a GCP Folder are conceptually similar but never actually related, and forcing them into a shared directory branch (interleaving aws-prod/ next to gcp-prod/ as siblings under one prod/ folder, for instance) tends to produce configuration that quietly assumes cross-cloud symmetry that doesn't actually exist.

PracticeWhy it matters across clouds
Never share one state file across two providersState corruption on one cloud's API hiccup shouldn't be able to affect the other cloud's tracked resources at all
Cloud-specific modules stay cloud-specificAn aws-vpc module and a gcp-vpc module solve conceptually similar problems with entirely different arguments — don't force one shared interface
A consistent tagging/labeling convention across cloudsAWS tags and GCP labels are different mechanisms, but naming them consistently (team, environment, managed-by) is what makes cross-cloud cost/ownership reporting (Part 9) possible at all
One root-level split by cloud, not interleaved per-environmentKeeps each cloud's real account/project/IAM structure independently legible, rather than forcing an artificial parallel structure

Note

Part 9 goes deep on the actual multi-cloud provisioning patterns (provider aliasing across clouds in one configuration, when that's appropriate vs. when full directory separation is better) — this chapter's concern is specifically the repository/directory shape, which is the decision that has to be made before any of Part 9's provisioning patterns are written.

HCP Terraform Stacks — a Native Alternative to Terragrunt#

Stacks, HashiCorp's own answer to the DRY-multi-deployment problem, reached broader general availability through 2026 (including monorepo support) — a native, first-party alternative to Terragrunt worth knowing about even for a team not currently on HCP Terraform, since it represents where the ecosystem is heading.

A Stack defines reusable "components" (similar in spirit to modules) and "deployments" (similar in spirit to Terragrunt's per-environment leaf configuration), with HCL-native orchestration across them instead of a separate wrapper tool — the goal being Terragrunt's DRY, multi-environment ergonomics without introducing a second tool and syntax dialect on top of Terraform itself.

TerragruntHCP Terraform Stacks
Maturity (2026)Long-established, widely adoptedNewly GA'd, actively expanding feature set
ToolingSeparate CLI, separate .hcl dialectNative to Terraform/HCP Terraform, no second tool
Works with self-hosted backendsYes, backend-agnosticTied to HCP Terraform
Works with OpenTofuYesNo — HCP Terraform/Terraform-specific

Note

Given Stacks' current HCP Terraform coupling, a team already committed to a self-hosted backend (Part 2's S3-native-locking pattern) or to OpenTofu specifically (Part 1's licensing discussion) doesn't have a Stacks option today — Terragrunt remains the practical DRY answer for that combination. Teams already planning to consolidate onto HCP Terraform for its managed run pipeline (Part 8) are the ones for whom evaluating Stacks against Terragrunt is a live, current decision.

Naming Conventions Across the Tree#

A consistent naming and tagging scheme, applied identically across every environment/account/cloud directory, is what makes the structure this chapter builds actually navigable and query-able at scale — a different naming habit per environment quietly undermines every other practice in this chapter.

locals {
  name_prefix = "${var.org}-${var.environment}-${var.service}"
  # e.g. "meridian-prod-checkout"

  common_tags = {
    Organization = var.org
    Environment  = var.environment
    Service      = var.service
    ManagedBy    = "terraform"
    Repository   = "infrastructure-live"
  }
}

resource "aws_db_instance" "this" {
  identifier = "${local.name_prefix}-db"
  tags       = local.common_tags
}
ConventionExampleWhy it matters
A single, shared name_prefix local, computed identically everywheremeridian-prod-checkout-dbMakes every resource's environment and service ownership readable from its name alone, in the AWS console, in a cost report, anywhere
common_tags merged into every taggable resourceEnvironment, Service, ManagedBy, RepositoryFeeds Part 9's cost-attribution and governance queries directly — untagged or inconsistently-tagged resources are invisible to them
A Repository tag pointing back to the exact repo (and, ideally, exact directory) that manages a resourceinfrastructure-liveAnswers "where's the code for this" instantly during an incident, without guessing across a monorepo/polyrepo split

Tip

Best practice: define name_prefix and common_tags once, in the shared root-level configuration every environment inherits from (Terragrunt's global.hcl, or a shared locals.tf sourced by every directory) — never let each environment or service redefine its own tagging shape independently. A single naming/tagging bug fixed once, at the shared source, is far better than the same bug silently diverging three different ways across dev/staging/prod.

Choosing Your Structure — a Decision Framework#

Pulling this entire chapter into one decision sequence:

Diagram

Worked Scenario: Migrating Off Workspaces Into Directory-Per-Environment#

The platform team's earliest Terraform setup used CLI workspaces for dev/staging/prod, driven by terraform.workspace conditionals throughout. As checkout-service grew genuinely divergent prod-only requirements (a read replica, WAF rules, a different backup schedule), the conditional logic sprawled across a dozen terraform.workspace == "prod" ? ... : ... expressions scattered through the configuration, increasingly hard for a new team member to reconstruct "what does prod actually look like" from.

The migration itself used the state-splitting mechanics from Part 2 — each CLI workspace's state was terraform state pulled independently, and each became the seed state for a new, dedicated environments/<name>/ directory with its own explicit backend key. No resources were destroyed or recreated; only the organizing structure around already-applied infrastructure changed. Six months post- migration, environments/prod/main.tf reads as a complete, honest description of production on its own, with zero conditional expressions — exactly the payoff this chapter's second section promised.

Worked Scenario: Standing Up a Second Cloud for Disaster Recovery#

Following an executive mandate for cross-cloud disaster recovery (not just cross-region), the team stood up a GCP replica of checkout-service's critical path — Cloud SQL as the replicated read target, GKE as the standby compute layer — under a brand-new gcp/ directory branch, exactly per this chapter's multi-cloud structure guidance. The team deliberately did not try to reuse the AWS-side database or eks-node-group modules with cloud-specific conditionals bolted in — a lesson learned directly from Part 3's "god module" anti-pattern — and instead wrote genuinely separate gcp-cloudsql and gke-node-pool modules, accepting real duplication of structure (both provision "a managed database" and "a Kubernetes node pool" conceptually) in exchange for each module staying simple, provider-native, and easy to reason about on its own.

Note

This is the practical, worked version of the "multi-cloud directory structure" table's guidance from earlier in the chapter — the team chose duplicated, cloud-native modules over one forced shared interface, and considers that the correct call in hindsight, specifically because AWS RDS and GCP Cloud SQL differ enough in their real configuration surface that a shared abstraction would have either leaked provider details anyway or forced awkward, disabled-feature compromises on both sides.

Worked Scenario: the tfvars Override That Silently Hit the Wrong Account#

An engineer running a routine catalog-service change in staging used a shell alias that, on this particular machine, still pointed AWS_PROFILE at the prod account from an unrelated task earlier that day. The staging/ directory's own explicit backend.tf correctly targeted the staging state bucket — Part 2's directory-explicit-backend discipline worked exactly as designed — but the provider credentials used to actually execute the plan came from the shell's ambient AWS profile, which the directory structure has no way to enforce on its own.

The plan showed changes against real prod resources (visible instance IDs, ARNs the engineer immediately recognized as wrong), and the review discipline from Part 1 caught it before apply — but the near-miss prompted a structural fix: every environment directory's own generated backend/provider configuration (via Terragrunt, adopted shortly after this incident) now also asserts the AWS account ID it expects to be operating against, failing plan outright with a clear error if the assumed role's actual account ID doesn't match what that directory declares.

data "aws_caller_identity" "current" {}

resource "terraform_data" "account_guard" {
  lifecycle {
    precondition {
      condition     = data.aws_caller_identity.current.account_id == "222233334444"
      error_message = "This directory targets the staging account (222233334444) — got ${data.aws_caller_identity.current.account_id}."
    }
  }
}

Caution

Directory structure alone communicates intent — it doesn't enforce which credentials actually get used. An ambient environment variable, a stale AWS profile, or a misconfigured CI secret can all silently override what the directory name implies. An explicit account-ID assertion (shown above) or an equivalent provider-level guard is the actual enforcement mechanism; treat directory naming as documentation for humans, and a runtime check as the real safety net.

Part 4 CLI Cheat Sheet#

CommandPurpose
terraform workspace list / new / selectManage CLI workspaces
terraform apply -var-file=prod.tfvarsApply with an explicit, layered variable file
terragrunt plan / apply (in a leaf directory)Generate config from the DRY hierarchy and run Terraform
terragrunt run-all planPlan every deployment under the current tree at once
aws sts assume-role --role-arn ... --role-session-name ...Manually test a cross-account role assumption outside Terraform
aws sts get-caller-identityConfirm which account/identity is actually active before running anything destructive

Common Mistakes and Interview Traps#

MistakeWhy it's wrongCorrect approach
Confusing CLI workspaces with HCP Terraform workspacesThey're unrelated concepts sharing one nameAlways clarify "CLI workspace" vs. "HCP Terraform workspace" explicitly
Using CLI workspaces for long-lived, structurally different environmentsDifferences live in scattered conditionals, easy to get subtly wrong, and switching workspaces is a silent local state changeUse directory-per-environment for permanent, structurally distinct environments
Adopting Terragrunt (or Stacks) before duplication is a real, felt problemAdds a second tool's learning curve and failure modes for a benefit that doesn't yet existStart with plain directories; adopt DRY tooling once duplication pain is genuinely growing
Isolating environments by directory alone, with a shared AWS accountA directory boundary doesn't stop a misconfigured credential from crossing itUse account-per-environment for anything where a cross-environment mistake is unacceptable
Assuming directory structure enforces which credentials get usedAmbient env vars/profiles can silently override the directory's implied targetAdd an explicit account/project-ID assertion as a runtime guard, not just directory naming
Interleaving two clouds' resources under one shared per-environment folderEncourages a false assumption of cross-cloud symmetry that rarely actually holdsSplit at the cloud level first, environment second, within each cloud's own branch

Worked Practice Problems#

Problem 1: A team runs three near-identical, disposable load-test environments, created and destroyed multiple times per week, alongside a permanent, structurally distinct production environment. What structure fits each, and why shouldn't they use the same mechanism for both?

Answer: The load-test environments fit CLI workspaces well — they're genuinely ephemeral and structurally identical to each other, and creating/destroying a workspace is fast with no new directory scaffolding needed each time. Production should be its own explicit directory (and, following this chapter's stronger guidance, its own AWS account) — it's long-lived, structurally distinct from anything else, and the stakes of an accidental wrong-target apply are high enough to want the structural, not just conventional, protection a dedicated directory (and account) provides. Using CLI workspaces for both would put production one terraform workspace select mistake away from an accidental apply; using a full directory-per-instance structure for the load-test environments would mean constant directory scaffolding for something meant to be quick and disposable.

Problem 2: A platform team has 4 engineers, one shared set of infrastructure modules, and 3 services that change together frequently during releases. Should they use a monorepo or split into per-service repositories, and what would change their answer?

Answer: A monorepo fits this team well — small, tightly-collaborating team, modules and consumers that change together often, and no genuine need yet for per-repository access control. The concrete condition that would change this answer: the team growing into several genuinely autonomous groups each owning one service independently, needing separate release cadences and separate repository-level permissions — at that point, the polyrepo factors from this chapter's comparison table start outweighing the monorepo's current cross-cutting-refactor convenience.

Problem 3: An engineer's terraform plan for the staging directory shows changes against resources with ARNs the engineer doesn't recognize from staging at all. The directory's backend.tf correctly points at the staging state bucket. What's the most likely explanation, and what structural fix prevents this class of mistake going forward?

Answer: The backend (state location) is correct, but the provider credentials actually being used during this run are pointed at a different account than the directory implies — an ambient AWS_PROFILE/session left over from unrelated work, exactly per this chapter's tfvars-override scenario. The state backend being correct doesn't guarantee the provider's actual target account matches; the structural fix is an explicit account-ID (or project-ID, on GCP) assertion via a precondition (or equivalent guard) that fails the plan outright the moment the assumed identity's real account doesn't match what that directory declares, rather than relying on directory naming and human attention alone.

Summary and What's Next#

Repository and directory structure is where Part 2's state-splitting and Part 3's module design actually meet the real world: CLI workspaces for genuinely ephemeral, identical environments; explicit directories for anything permanent and structurally distinct; a DRY tool (Terragrunt today, Stacks increasingly as it matures) once duplication pain across those directories is real; and, at genuine scale, account-per- environment and cloud-level directory separation as the actual enforcement mechanisms behind what a folder name only implies. None of this is Terraform syntax — it's the organizational discipline that keeps a mistake in dev from ever having a structural path to reach prod, which is worth more than almost any single language feature this series covers.

Part 5 returns to configuration-language depth: providers beyond the single-region default this chapter's examples assumed, data sources for reading infrastructure Terraform doesn't manage, and provisioners' narrow, correctly-scoped role — all of it informed by the directory and account structure this chapter just established as the ground everything else in the series stands on.