Skip to content

Terraform Environments and Git: Directories, Workspaces or Repositories

Lesson 7 of 10Intermediate14 min readGit for DevOps & Infrastructure · Infrastructure as CodeVerified: Terraform workspaces and backend documentation, September 2026

Every Terraform team eventually asks how to handle dev, staging and production. There are four common answers, one of which is a trap.

The trap is branches. It is the answer that feels most natural to people who know Git well, it maps cleanly onto how many teams deploy applications, and it degrades in a way that is invisible for months and then very hard to unwind.

Before comparing mechanisms, be clear about what an environment strategy has to deliver.

Separate state. A dev apply must not be able to modify production’s record. This is non-negotiable and it is the property most strategies are really about.

Separate credentials. The identity applying to dev should not be able to reach production. Ideally a different account or subscription entirely, so the boundary is enforced by the cloud provider rather than by a policy document.

Shared configuration. Environments should differ in values, not in structure. When production has resources that exist nowhere else, staging has stopped predicting anything.

Visible differences. Somebody should be able to answer “how does production differ from staging?” by reading, not by running plans in both and comparing output.

A promotion path. A change proven in dev should reach production through a defined, reviewable route.

Every strategy below is a different trade against those five.

One directory per environment, each a root module with its own backend.

environments/
├── dev/
│ ├── main.tf
│ ├── backend.tf
│ └── terraform.tfvars
├── staging/
└── production/

Each calls shared modules with different values.

Separate state: yes, different backend keys. Separate credentials: yes, different roles per directory in CI. Shared configuration: yes, through modules. Visible differences: yes — diff the directories. Promotion: a pull request changing the target directory.

What it costs. Boilerplate repeated per environment. CI needs changed-directory detection or it plans everything. All environments share one repository’s permissions, mitigated by CODEOWNERS and rulesets rather than a hard boundary.

The failure mode to watch. Directories drift. Somebody adds a resource to production for an urgent reason and never adds it elsewhere. Six months later three directories have diverged and nobody knows which differences are intentional. The counter is keeping environment directories thin — a module call and variables, nothing else — and treating a resource that appears in only one environment as something requiring an explanation.

This is the right default for most teams.

Terraform workspaces give one configuration multiple state files, selected by terraform workspace select.

Terminal window
terraform workspace new staging
terraform workspace select production
terraform apply

Configuration branches on terraform.workspace:

locals {
instance_count = terraform.workspace == "production" ? 6 : 1
}

Separate state: yes. Separate credentials: no, not naturally. Workspaces share a backend configuration and therefore share the credentials used to reach it. Provider credentials come from the environment, so the same CI job with production credentials can select any workspace. Shared configuration: completely — one directory. Visible differences: poor. Differences are conditionals scattered through the code. Answering “how does production differ” means reading every ternary. Promotion: ambiguous. There is nothing to promote; the same code serves every workspace and applying to production is a different command rather than a different change.

The specific risk: the current workspace is ambient state on the machine. terraform apply in a directory does not tell you which environment you are targeting. terraform workspace show does, and nobody runs it before every apply. This has caused real incidents — the right command, the wrong workspace.

Where workspaces genuinely fit: many near-identical instances of the same thing where the set is dynamic — per-customer stacks, per-region deployments, ephemeral preview environments spun up per pull request. There, the alternative is generating directories, and workspaces are cleaner.

Where they do not: dev, staging and production. HashiCorp’s own guidance points away from workspaces for environment separation, precisely because environments usually want the credential and permission isolation workspaces do not provide.

infra-dev/ infra-staging/ infra-production/ infra-modules/

Separate state: yes. Separate credentials: yes, and enforced at the repository boundary — a real permission separation, auditable, with different rulesets per repository. Shared configuration: through published, versioned modules only. Visible differences: hard. Comparing environments means comparing repositories. Promotion: explicit — a pull request in the next repository bumping a module version.

What it costs. A change spanning environments becomes several pull requests with an ordering nobody enforces. Modules must be versioned and published, which is more discipline. CI configuration is duplicated or extracted into reusable workflows. Onboarding is harder.

When it is right. When the permission boundary is a requirement rather than a preference: a regulatory separation, a different team operating production, an audit regime that wants demonstrable access control. The overhead is real and buys something specific.

main is dev, a staging branch is staging, a production branch is production. Promotion is a merge.

It is appealing. It is also the strategy that most reliably degrades, and the reasons are structural rather than a matter of discipline.

Merges between long-lived branches accumulate conflicts. Every hotfix applied directly to production must be merged back. Miss one and the branches diverge permanently. Nothing alerts you.

Cherry-picking loses history. Teams that give up on merging start cherry-picking, and the commit in production is now a different commit with a different SHA. “Is this change in production?” stops having a reliable answer.

Environment configuration lives in the diff between branches. The difference between staging and production is the merge conflict everybody resolves the same way each time — and eventually somebody resolves it differently, and an environment changes silently.

The plan depends on which branch CI checked out. Reviewing a pull request into staging shows a plan against staging, and the same change into production may plan differently. You cannot see the production effect until you open the production pull request.

Rollback is a merge, not a revert. Reverting production means constructing a state that never existed on any other branch.

It fights every other tool. GitHub environments, CODEOWNERS path rules, required checks and rulesets are all easier to express against paths than against a branch topology.

The one legitimate use of a long-lived branch here is not an environment at all: a release branch that exists to receive backported fixes for a version still in service. That is a different concept with a defined end date, covered in release branching.

DirectoriesWorkspacesRepositoriesBranches
Separate stateYesYesYesYes
Separate credentialsPer jobNot naturallyRepository-enforcedPer job
Differences visibleYes, by diffPoor — conditionalsHard — cross-repoIn merge conflicts
Promotion pathPR to a directoryAmbiguousPR bumping a versionMerge
Accidental wrong targetWrong path — visibleWrong workspace — ambientWrong repositoryWrong branch
CI complexityChange detectionSimpleSimple, duplicatedAwkward
Drift between envsVisibleVisible in codeHiddenHidden
VerdictDefaultDynamic sets onlyHard isolationAvoid

A question that precedes the mechanism, and one teams answer by accumulation rather than by design.

Two is a legitimate answer. Dev and production, where dev is genuinely used and production is protected. Many small teams run this and are correct to. A staging environment that nobody deploys to before production is overhead pretending to be rigour.

Three is the common shape. Dev for iteration, staging as a production-shaped rehearsal, production. The value of staging depends entirely on it being production-shaped — same module versions, same topology, different sizes. A staging environment configured differently from production tests a system you do not run.

Four or more needs a reason. A separate integration environment for cross-team testing, a performance environment sized like production, a disaster-recovery region. Each is defensible; each also multiplies the promotion path, the plan time, the cost and the number of places a change can be forgotten.

The question to ask about any environment: what decision does it inform? If the answer is “we would not ship if it broke there”, it is earning its keep. If nobody can name a time it changed a decision, it is a directory that costs money and slows promotion.

The failure that follows from getting this wrong is environments that stop being trusted. A staging environment that is frequently broken for unrelated reasons stops being a signal, and teams begin promoting past it — at which point it is worse than not having one, because the process still says it exists.

Whichever strategy you pick, the strongest isolation is not in Git at all.

Separate cloud accounts, subscriptions or projects per environment mean a misconfigured Terraform run cannot reach production, because the credentials it holds do not exist there. That is enforced by the cloud provider rather than by your repository structure, and it survives every mistake in this lesson.

With account separation in place:

  • Directories become a safe default, because the credential boundary is elsewhere.
  • Workspaces become less dangerous, though still not recommended for environments.
  • Separate repositories become less necessary, because the isolation they buy is already present.

The practical guidance: get account separation right first, then choose the Git strategy for reviewability rather than for isolation. Teams that try to achieve isolation purely through repository structure are compensating in the wrong layer.

The tension in every strategy: environments should be as similar as possible, and they are genuinely not identical.

Everything structural goes in modules. The resources, their relationships, their names. Shared by construction.

Only values differ per environment. Instance sizes, replica counts, retention periods, feature flags.

Make differences declarative and adjacent. A terraform.tfvars per environment is readable and diffable. A conditional inside a module keyed on environment name is not.

environments/production/terraform.tfvars
instance_class = "db.r6g.xlarge"
replica_count = 3
backup_retention = 35
deletion_protection = true
environments/dev/terraform.tfvars
instance_class = "db.t4g.micro"
replica_count = 0
backup_retention = 1
deletion_protection = false

Two files, diffable side by side, and the answer to “how does production differ” takes ten seconds.

Resist environment conditionals in modules. count = var.environment == "production" ? 3 : 1 moves the difference from a values file into logic, and it accumulates. A module with six such conditionals is a module nobody can reason about.

Accept some genuine asymmetry. Production has a disaster-recovery replica that dev does not; that is real. Express it as a module flag (enable_dr_replica = true) rather than as a resource that exists in only one directory. The flag is visible in the values file; the extra resource is not.

What “promotion” means when the artifact model does not apply.

For containers, promotion moves a digest. For Terraform, there is no equivalent artifact — a plan is computed against state and the real world at a moment, and is stale immediately. So promotion here means moving a configuration reference:

A module version. Dev consumes v2.4.0, it works, a pull request bumps staging to v2.4.0, then production. One-line diffs, and the same module code reaches every environment.

A commit of the shared configuration, where environments read from a common path.

A variable value that has been validated lower down.

  1. Change and apply in dev. Observe it working.

  2. Open a promotion pull request for staging. Usually a one-line version bump.

  3. Read the staging plan. It will differ from dev’s — different sizes, different counts — and the differences should be explainable by the values files.

  4. Merge and apply. Leave it. The value of staging is time spent running.

  5. Promote to production, with its own review and approval.

The anti-pattern: applying the same change to all three environments in one pull request. It is faster and it removes the entire purpose of having environments — you find out about the problem in production at the same time as everywhere else.

The case that does not fit the dev/staging/production model, and where workspaces earn their place.

A preview environment per pull request, torn down on merge. A load-test environment that exists for an afternoon. A per-customer stack created on signup. What these share is that the set of environments is dynamic — nobody can enumerate them in a directory tree in advance.

Workspaces fit well here. One configuration, a workspace per instance, created and destroyed programmatically. The objection about ambient selection matters less because a pipeline selects the workspace explicitly and no human is applying by hand.

The rules that keep it safe:

A separate account or project from anything real. Ephemeral environments are created by automation from proposed code, which is the highest-risk input in the whole pillar. They must not share a credential boundary with production.

Guaranteed teardown. A destroy job that runs on pull request close, plus a scheduled sweep for the ones that failed. Without the sweep, the account accumulates orphans until somebody notices the bill.

A hard cap. Limit the number of concurrent ephemeral environments so a bad loop cannot create hundreds.

A time limit. Anything older than a few days is destroyed regardless of whether its pull request is open.

Nothing stateful with real data. Seeded fixtures only. A preview environment restored from a production snapshot is a copy of production data in an environment created from unreviewed code.

Long-lived environment branches. The trap this lesson exists to name.

Workspaces for dev/staging/production. Shared credentials and an ambient current selection.

Environment conditionals scattered through modules. Differences become logic instead of values.

Resources that exist in only one environment directory. How environments diverge.

One state file for all environments. A dev mistake reaches production’s record.

Same cloud account for everything. No enforced boundary, whatever the repository looks like.

Promoting to all environments in one pull request. Removes the point of having them.

Not verifying which workspace is selected. The right command against the wrong environment.

If you have inherited the branch strategy, unwinding it is a project rather than a commit. The order that avoids an outage:

  1. Find the true differences. For each pair of branches, diff them and classify every difference as intentional, accidental, or unknown. The unknown pile is usually the largest and is the actual work.

  2. Resolve the unknowns before moving anything. A difference nobody can explain is either a forgotten hotfix or an abandoned experiment, and you need to know which before you consolidate.

  3. Build the directory layout on main, alongside the existing branches. Nothing applies from it yet.

  4. Point each environment directory at the existing state. Same backend key. Nothing is created or destroyed; you are changing which configuration file describes the same resources.

  5. Plan each environment from the new directories and expect empty plans. This is the verification step and the one you must not skip. A non-empty plan means the new configuration does not match reality — fix that before proceeding.

  6. Cut over one environment at a time, starting with dev. Apply from the new layout, leave it running for a while, then move the next.

  7. Delete the environment branches last, once nothing has applied from them for a full cycle.

Step 5 is the whole migration. An empty plan from the new layout against existing state proves the two describe the same infrastructure, and everything after it is routine. A team that skips it discovers the mismatch during an apply instead.

Expect the unknown differences to take longer than the mechanical work. That is not a failure of the migration; it is the accumulated cost of the branch strategy becoming visible all at once.

An environment is a state file plus a credential boundary. The Git mechanism you choose decides how visible the differences are and how a change travels — it does not, on its own, provide the isolation.

Directories make differences visible and promotion explicit. Repositories make the credential boundary structural. Workspaces make both harder. Branches make both harder and add merge topology on top.

  • An environment strategy must deliver separate state, separate credentials, shared structure, visible differences and a promotion path
  • Directories are the right default: differences are diffable, promotion is a pull request
  • Workspaces share backend configuration and credentials, and the current selection is ambient — fine for dynamic sets, poor for environments
  • Separate repositories buy a real permission boundary at the cost of cross-environment coordination
  • Branch-per-environment degrades structurally, and its worst failure is a hotfix silently reverted by a later promotion
  • Account separation is the strongest boundary and is not a Git decision
  • Differences belong in values files, not in module conditionals

Use a disposable repository. No cloud credentialslocal_file is enough.

  1. Build the directory strategy with dev/ and production/, both calling one module with different .tfvars.

  2. diff environments/dev/terraform.tfvars environments/production/terraform.tfvars. Predict: does this fully explain the differences?

  3. Convert to workspaces: one directory, terraform workspace new production, a conditional on terraform.workspace. Run terraform apply without checking the workspace first. Predict: which one did you target?

  4. Run terraform workspace show. Note that nothing in the apply output made this obvious.

  5. Add a resource to production/ only. Diff the directories again. Predict: is the difference still explainable from the values files?

  6. Express the same asymmetry as a module flag instead. Compare which version a reviewer would understand faster.

  7. Delete the repository.

Lab: ship an infrastructure change through a pull requestPractise the review discipline that makes an infrastructure change reviewable before it is applied.

The infrastructure-as-code repository template and deployment PR checklist are in the Professional Toolkit.