Article

Terraform Environments, Done Properly: Why Workspaces Aren’t the Answer, and What to Build Instead

by Gary Worthington, More Than Monkeys

There’s a moment in every Terraform project’s life, usually about three weeks in. The dev environment works. The code is tidy. Someone says “right, let’s set up staging and production”, and somebody else — bright, well-meaning, about to cause eighteen months of low-grade misery — types terraform workspace --help and says "oh, this looks perfect".

It does look perfect. That’s the trap.

In the previous article I introduced Terraform, spent a long time on state, and made a claim I didn’t fully defend: workspaces are not environments, and you should use a directory per environment instead. A few people will have read that and thought fine, but why, and what does the alternative actually look like when you build it?

This article is the answer. It’s a practical one: real layouts, real code, and a migration path if you’re already knee-deep in workspaces and want out.

A quick recap

The previous piece covered how Terraform actually works: your configuration describes what you want, the state file records what Terraform believes exists, and every plan is a three-way diff between code, state, and reality. State is the single most important artefact in your setup — lose it, leak it, or corrupt it and you’re in for a memorable week.

The relevant point for today: a state file is Terraform’s entire worldview. Which state file you’re talking to determines what Terraform sees, what it will change, and what it will destroy.

Workspaces are a mechanism for switching between state files. Environments are a mechanism for keeping production safe from everything else. These are not the same problem, and the whole argument of this article is what happens when you pretend they are.

What workspaces actually do

Mechanically, a workspace is small. Run terraform workspace new staging and Terraform creates a fresh, empty state file alongside your existing one — in an S3 backend, it lands under an env:/staging/ prefix in the same bucket. Your code doesn't change. Your backend doesn't change. Your credentials don't change. The only thing that changes is which state file the next command reads and writes.

You switch with terraform workspace select, you check where you are with terraform workspace show, and your configuration can read the current name through terraform.workspace, which is how people end up writing things like:

resource "aws_instance" "app" {
instance_type = terraform.workspace == "prod" ? "m7i.xlarge" : "t3.small"
}

Every Terraform project also starts with a workspace called default, which nobody asked for and nobody remembers they're in.

So: same code, same backend, same account, same credentials — different state. Hold onto that list, because every problem workspaces cause for environment management is sitting in it already.

The invisible switch

The current workspace is session state. It lives in your terminal, invisibly, and it decides what terraform destroy points at.

I know of a team who lost a chunk of production to exactly this. An engineer had been debugging in the prod workspace late on a Thursday, got pulled onto something else, and came back Friday morning to tidy up their experiment — terraform destroy, yes I'm sure, confirmed. The terminal had been sitting in the prod workspace all night, like a loaded weapon in the kitchen drawer.

Nothing in the code was wrong. Nothing in the plan output looked wrong — a destroy plan for prod and a destroy plan for dev list the same resource types in the same colours. The only difference was one word in a status line nobody reads at 9am.

The rule: which environment you’re touching should be a physical fact — visible in your prompt, your directory, your pull request — never a piece of session state you have to remember to check. Anything that makes “am I pointing at production?” a memory test will eventually be failed by someone competent, tired, and unlucky.

The conditional creep

The second problem grows more slowly. Because workspaces give you one set of code for all environments, every difference between environments has to be expressed as a conditional inside that code.

It starts innocently — one ternary for instance size. Then production needs multi-AZ RDS and dev doesn’t. Then staging needs a smaller node group, prod needs deletion protection, dev needs none of the alerting, and prod-only compliance tagging arrives from somewhere upstairs. Eighteen months later every module reads like a choose-your-own-adventure book in which several of the endings delete production.

The deeper issue isn’t ugliness. It’s that you can no longer read the code and know what production looks like. The answer to “what does prod actually run?” is now “evaluate all the conditionals in your head”, which is exactly the kind of archaeology infrastructure as code was meant to abolish. And every change to shared code is a change to production’s code — even the ones you only meant for dev — because there is only one code path and every workspace walks it.

One backend, one account, one blast radius

The third problem is the one that should settle the argument on its own. Workspaces share a backend, and in practice that means they share an AWS account and a credential set.

Real environment separation is an account boundary. Different AWS accounts mean production has its own IAM world, its own quotas, its own billing, and — the part that matters at 2am — its own blast radius. An engineer with dev credentials in a dev account cannot delete production, no matter how confused their terminal is. That’s not process; that’s physics.

Workspaces can’t give you this. The credentials that can apply dev are the credentials that can destroy prod, and your production state file sits in the same bucket as everyone’s experiments, one env:/ prefix away. You can bolt IAM conditions onto S3 key prefixes and pretend, but you're hand-rolling a fence exactly where an account boundary would have given you a wall.

The rule: environment isolation you have to remember to enforce isn’t isolation. It’s a rota.

Where workspaces earn their keep

None of this makes workspaces useless, and it’s worth being fair before demolishing further. Workspaces are the right tool when you want many genuinely identical, disposable copies of the same thing: a review environment per pull request, a sandbox per engineer, a test stack per integration run. Same code, same account, same shape, short lifespan, torn down without ceremony.

That’s the pattern they were built for. Spinning up pr-1847, running the tests, destroying pr-1847 — lovely, no complaints, carry on.

The mistake is only in stretching them across the one boundary that matters. Dev, staging, and prod are not identical copies of the same thing. They differ in size, configuration, permissions, and consequences, and the tool that manages them needs to respect that instead of flattening it.

What to build instead

Here’s the layout I use and recommend, and it’s aggressively boring:

infrastructure/
├── modules/
│ ├── networking/
│ ├── ecs-service/
│ └── rds/
└── environments/
├── dev/
│ ├── backend.tf
│ ├── main.tf
│ └── terraform.tfvars
├── staging/
└── prod/

The modules/ directory holds the real logic — your networking, your service definitions, your database wiring — written once, parameterised by input variables, with no idea which environment they're running in. No terraform.workspace, no environment ternaries, nothing conditional on context. A module that needs to know it's in production is a module that's doing too much.

Each environment directory is a thin root module: its own backend pointing at its own state, and a main.tf that does nothing but call shared modules with that environment's values.

# environments/prod/main.tf
module "app" {
source = "../../modules/ecs-service"
environment = "prod"
instance_type = "m7i.xlarge"
desired_count = 6
multi_az = true
deletion_protect = true
}

Dev’s version of the same file says t3.small, a count of 1, and no protection. The two files sit side by side, and the diff between them is the documented difference between your environments. Nobody evaluates conditionals in their head; they read two short files. When someone asks "what does prod run?", the answer is a file, not a séance.

Notice what fell out for free: to touch production you must be standing in environments/prod. It's in your shell prompt. It's in the path of every file in your pull request. The invisible switch became a visible location.

Different accounts, different keys to the building

Give each environment its own AWS account (AWS Organizations makes this cheap to administer), and give each environment directory its own backend and credentials:

# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "mtm-tfstate-prod"
key = "app/terraform.tfstate"
region = "eu-west-2"
encrypt = true
use_lockfile = true
}
}

The prod state bucket lives in the prod account. Dev credentials have no route to it — can’t read it, can’t lock it, can’t destroy what it describes. In CI, the dev pipeline assumes a role in the dev account and the prod pipeline assumes a role in the prod account, with the prod role gated behind whatever approval your organisation takes seriously.

The rule: the credentials that can break production should be the hardest ones to hold, and impossible to hold by accident.

Promotion becomes a walk, not a leap

With this layout, rolling out a change is pleasingly dull. You change a shared module — say, tightening a security group in modules/networking. Nothing anywhere is affected yet, because modules don't apply themselves. Then you walk it through: plan and apply in environments/dev, let it soak, then staging, then prod, reading the plan at each step.

Each environment picks up the change when you choose, not when the code changes. If dev explodes, staging and prod are untouched and you’ve learned something at the cheapest possible price. Compare that with the workspace model, where there is one code path and the only record of “which version is prod actually running?” is whoever last ran apply and their recollection of events.

For a single team in a monorepo, relative module paths like the ones above are all you need — promotion is ordering, and git history is your audit trail. Once multiple teams consume shared modules, tag the modules repo and pin versions per environment (source = "git::...//ecs-service?ref=v1.4.2"), so prod upgrades by deliberate version bump. Start with the simple one. You'll know when you've outgrown it, because it'll hurt.

But it’s not DRY

The standard objection: three environment directories mean three backend files and three main.tf files that look similar. Repetition. Surely we should abstract it away.

My opinion, for what it’s worth after a lot of years doing this: no. That repetition is roughly thirty lines per environment, it changes a few times a year, and it’s the thirty lines an incident responder most needs to be plainly readable at speed. DRY is a principle for logic, not for configuration — configuration that varies by environment is supposed to be visible in each environment, because the visibility is the feature. Abstracting it buys you nothing except one more layer between an on-call engineer and the truth.

Tools like Terragrunt exist to manage this repetition and plenty of teams use them happily at scale. But it’s another tool, another config language, and another thing to explain at 3am. Earn the complexity before you buy it — most teams reaching for Terragrunt on day one are solving a problem they don’t have yet with a dependency they’ll have forever.

Getting off workspaces without breaking anything

If you’re already running environments as workspaces, the escape is careful but not difficult, and — done right — touches no infrastructure at all. You’re moving Terraform’s memory, not the resources. For each workspace:

  1. Build the destination first. Create the environment directory with its thin root module and its own backend config. The code should produce the same resources the workspace manages — same module calls, same values that the conditionals used to compute.
  2. Export the state. terraform workspace select staging, then terraform state pull > staging.tfstate. That's the workspace's entire memory in one JSON file. Treat it like the credentials file it is.
  3. Import it into the new home. In the new directory, terraform init against the new backend, then terraform state push staging.tfstate. If Terraform grumbles about lineage, that's it noticing this state was born elsewhere — read the warning properly before reaching for -force.
  4. Prove nothing changed. Run terraform plan. The only acceptable output is No changes. Anything else means your new code and the old workspace's effective configuration disagree, and you stop and reconcile before going anywhere near apply. This plan is the whole safety case for the migration; don't talk yourself past it on a Friday.
  5. Burn the bridge. Once the new directory is proven and CI points at it, delete the old workspace so nobody can accidentally apply from it. Two sources of truth is one more than you want.

Do one environment at a time, dev first, prod last, with a colleague watching the prod one. Migrating state is routine plumbing right up until the moment it isn’t.

The boring answer

Workspaces for environments keep being reinvented because the alternative looks unsophisticated. A directory per environment, some near-duplicate files, promotion by walking changes up a folder structure by hand — where’s the cleverness? Surely the professional answer has more indirection than folders.

It doesn’t. The professional answer is the one where pointing at production requires standing in a directory called prod, holding credentials only production grants, reading a plan that could only ever describe production. Every piece of cleverness you remove from that path is a failure mode you've deleted.

The previous article ended by saying Terraform makes you an engineer whose infrastructure is written down. This one has a companion: good infrastructure code isn’t the code that impresses other engineers. It’s the code that can be safely operated by a tired one.

Gary Worthington is a software engineer, delivery consultant, and fractional CTO who helps teams move fast, learn faster, and scale when it matters. He writes about modern engineering, product thinking, and helping teams ship things that matter.

Through his consultancy, More Than Monkeys, Gary helps startups and scaleups improve how they build software — from tech strategy and agile delivery to product validation and team development.

Visit morethanmonkeys.co.uk to learn how we can help you build better, faster.

Follow Gary on LinkedIn for practical insights into engineering leadership, agile delivery, and team performance.