Terraform Professional: Scaling Projects and Multiple Environments
Managing multiple environments in Terraform means running one infrastructure design as several isolated instances — dev, staging, prod or regions — using CLI workspaces, a root configuration per environment with shared modules, or Terraform Stacks deployments, chosen by how much isolation each environment needs. This lesson covers objectives 3a (manage and scale infrastructure projects) and 3f (manage multiple infrastructure environments) of the Terraform Infrastructure Engineer Professional (002) exam: when workspaces fit and when they do not, workspace commands and TF_WORKSPACE in pipelines, directory-per-environment layouts and version promotion, switching backends in one directory, splitting large configurations, flat module composition, parallelism tuning, Stacks deployments and approval rules, and HCP Terraform projects.
On this page10 sections
- Choosing how to separate environments: the three layouts
- CLI workspaces: what they share, when they fit and terraform.workspace
- Workspace commands and TF_WORKSPACE in automation
- Directory per environment with shared modules, and promoting changes
- One working directory for several environments: partial backends, -reconfigure and TF_DATA_DIR
- Scaling a large project: decompose by boundary, not by -target
- Module composition: keep the tree flat and invert dependencies
- Tuning parallelism for large graphs and rate limits
- Terraform Stacks: components once, one deployment per environment
- Organizing HCP Terraform with projects and variable sets
- Choose between CLI workspaces, a root configuration per environment and Terraform Stacks from stated isolation, credential and versioning requirements
- Use workspace new, select -or-create and delete correctly in automation, and use terraform.workspace and TF_WORKSPACE safely
- Promote module and provider changes environment by environment with exact pins and per-directory lock files
- Switch one working directory between environments with init -reconfigure and keep concurrent jobs apart with TF_DATA_DIR
- Split a monolithic configuration along lifecycle and ownership boundaries, compose modules flat, and avoid routine -target
- Tune -parallelism through TF_CLI_ARGS_name, including on HCP Terraform agents
- Configure Stacks deployments, deployment groups and auto-approve checks, and organize HCP Terraform with projects and priority variable sets
Choosing how to separate environments: the three layouts
There are three mainstream ways to run the same infrastructure design as several environments (dev, staging, prod) or regions, and the right one is decided by how much isolation each environment needs: CLI workspaces of one configuration, a separate root configuration per environment calling shared modules, or a Terraform Stack with one deployment per environment in HCP Terraform. Variable files alone (-var-file=prod.tfvars) only change input values; they are not an isolation mechanism.
| Need | CLI workspaces (one configuration) | Directory per environment (shared modules) | Terraform Stack (deployments) |
|---|---|---|---|
| Separate backend / state storage | No: every workspace uses the same backend | Yes: each root has its own backend | Yes: each deployment has isolated state |
| Separate credentials and access controls | No: HashiCorp says workspaces are not suitable for this | Yes | Yes: per-deployment inputs and identity |
| Different code or versions per environment | No: all workspaces evaluate the checked-out configuration | Yes: own module pins, required_providers, lock file | Same components; inputs differ per deployment |
| Value differences (sizes, name suffixes) | Yes, via terraform.workspace | Yes, via each root's inputs | Yes, via deployment inputs |
| Typical fit | Short-lived parallel copies, e.g. testing a branch | Long-lived environments that need strong isolation | One design repeated across many environment/region instances |
The deciding signal in a scenario is usually a constraint. A requirement that one environment's state or credentials be out of reach of the others rules out workspaces, because they share a backend. A short-lived copy that may share backend and credentials is what workspaces are designed for. One design repeated across many environment and region instances is the case Stacks were built for.
Workspace commands and TF_WORKSPACE in automation
In pipelines, workspace select fails on a missing workspace unless you add -or-create, workspace new fails on one that already exists, and workspace delete refuses the current workspace or one that still tracks resources:
| Command | Behaviour |
|---|---|
terraform workspace new NAME | Creates and selects the workspace; fails if it already exists. With -state=PATH it copies that state file into the new workspace, so it starts out tracking those resources without re-importing. |
terraform workspace select NAME | Switches to an existing workspace; fails if it does not exist. -or-create (default false) creates it when missing — ideal for a per-pull-request job that must create the workspace on the first run and reuse it later. |
terraform workspace delete NAME | Refuses if NAME is the currently selected workspace, or if its state still tracks resources. -force overrides the second rule but leaves the real objects running and unmanaged. |
terraform workspace list / show | List all workspaces / print the current one. |
The clean teardown for a short-lived workspace is therefore: terraform destroy in it, terraform workspace select default, then terraform workspace delete NAME.
Every configuration starts with one workspace called default, which can never be deleted. With a standard backend, terraform init does not create named workspaces; only the workspace subcommands do. (A cloud block that selects HCP Terraform workspaces by tags is the exception: init can prompt for a workspace name and create it.)
Selecting workspaces in CI with TF_WORKSPACE
In pipelines, set the TF_WORKSPACE environment variable to the job's workspace: every command in the job (init, plan, apply) then uses it without a separate select step, which also survives fresh containers where a select from an earlier stage is lost. HashiCorp recommends it only for non-interactive use, because in a local shell it is easy to forget the variable is set and apply changes to the wrong state.
While TF_WORKSPACE is set, it is the selection mechanism: terraform workspace select refuses with an error saying the selected workspace is overridden by the TF_WORKSPACE environment variable. To target another workspace in one step, set the variable to that workspace for the step (or unset it and select). No workspace subcommand flag gets around the override; the environment variable is the selection.
One working directory for several environments: partial backends, -reconfigure and TF_DATA_DIR
Some teams keep a single root directory and switch environments by supplying a different partial backend file at init, such as dev.s3.tfbackend and prod.s3.tfbackend. Switching is not a migration — prod already has its own state — so the right command is:
terraform init -reconfigure -backend-config=prod.s3.tfbackend
-reconfigure discards the saved backend settings and connects to the new backend without copying anything. -migrate-state (and -force-copy, which answers its prompts with yes) copies the current state into the new location, which would overwrite prod's state with dev's. -upgrade concerns provider and module versions only.
Terraform saves the backend choice in the working directory's data directory (.terraform by default). Two jobs running from the same checkout share it, so whichever init ran last decides which state the other job's plan reads. Setting a different TF_DATA_DIR per job keeps their .terraform data apart while the code stays shared. The jobs lock different states, so locking cannot catch the mix-up; the data directory is what has to differ. Backend argument rules themselves are covered in the remote backends lesson (objective 2a).
Scaling a large project: decompose by boundary, not by -target
When one root configuration grows to thousands of resources, HashiCorp's guidance is to split it into several root configurations along architectural boundaries. That shortens plans, shrinks the blast radius and removes lock contention in one move, because each part has its own state and lock. Good reasons to put resources in different roots:
- Rate of change: a VPC that changes a few times a year should not be in the same plan as weekly application releases.
- Ownership: different teams who approve and secure their own changes should own their own state.
On their own, these are weak reasons to split: region alone (provider aliases can manage several regions in one configuration, although blast radius sometimes justifies separating them), the number of providers (one configuration can use many), and heavy cross-references (tightly coupled resources belong together; splitting them just moves the coupling into data passed between projects).
Routine -target is not a scaling strategy. HashiCorp documents it for exceptional situations such as recovering from errors. Targeting module.app does plan that module and everything it depends on, so the run itself works — the damage is what it hides: changes and drift everywhere else never appear in a plan, and state gradually diverges from the code. Only separate root configurations give each part its own state and lock.
Module composition: keep the tree flat and invert dependencies
HashiCorp's module composition guidance is to keep the module tree flat: the root module calls the modules and wires them together, and each module receives the objects it depends on as input variables (dependency inversion) instead of creating or discovering them itself.
- If a database module internally calls a network module that creates its own VPC, it cannot be placed into a network that already exists. Refactor it to take the network IDs as inputs and call the network module beside it in the root; one root passes the new network's outputs, another passes an existing network's IDs. The module then has one code path and no nested dependency.
- A data source inside a module that looks up a VPC by tag has no reference to the module creating that VPC, so Terraform reads it during plan — before the VPC exists — and the first plan on an empty account fails with no matching VPC. Passing
module.network.vpc_idinto an input both orders the operations and keeps the module reusable. Ordering tricks that only delay the read leave the hidden coupling in place; the reference is the design fix. - Data-only modules package a shared lookup (for example, the shared VPC's subnets) as data sources in one maintained module. Consumers need cloud read permissions but no access to the producer's state, unlike
terraform_remote_state; importing the producer's objects creates competing ownership, and copied IDs drift.
Tuning parallelism for large graphs and rate limits
-parallelism=N limits how many resource operations Terraform walks at once during plan and apply; the default is 10. Lower it when a provider's API returns rate-limit errors (HTTP 429, "Rate exceeded") — the run is slower but stays under the limit; raising it adds pressure. It does not change state locking or drift detection, and it does not cut lock contention between teams.
When you cannot edit the commands (a shared pipeline template), pass flags through the environment:
| Variable | Effect |
|---|---|
TF_CLI_ARGS | Appended to every command — including init, which rejects -parallelism |
TF_CLI_ARGS_plan, TF_CLI_ARGS_apply | Applied only to that subcommand — the right way to set -parallelism for plan and apply |
TF_VAR_name | Sets an input variable value, never a CLI flag |
In HCP Terraform the TFE_PARALLELISM environment variable sets parallelism for plan and apply, but it is not supported on HCP Terraform agents. A workspace that must run on an agent pool (for example to reach a private network) uses the TF_CLI_ARGS_plan and TF_CLI_ARGS_apply environment variables instead.
Terraform Stacks: components once, one deployment per environment
Terraform Stacks (generally available in HCP Terraform) separate the definition of the infrastructure from its instances. Component blocks, each sourcing a module and wired to each other's outputs, live in .tfcomponent.hcl files. Each deployment block in a .tfdeploy.hcl file is one instance — an environment, a region, or an environment-region combination — with its own inputs and isolated state. This replaces the pattern of dozens of near-identical workspaces stitched together with run triggers and remote state sharing; for_each over environments inside one workspace would instead collapse everything into one state.
To retire a deployment, HashiCorp recommends setting destroy = true on its block (the argument defaults to false), applying the resulting destroy plan, and only then removing the block.
Approval rules come from two blocks in the deployment file:
deployment_auto_approve "small_plans" {
check {
condition = context.plan.changes.total <= 5
reason = "Plan changes more than five resources."
}
}
deployment_group "sandboxes" {
auto_approve_checks = [deployment_auto_approve.small_plans]
}
deployment "sandbox" {
inputs = { region = "eu-west-1" }
deployment_group = deployment_group.sandboxes
}
deployment "live" {
inputs = { region = "ap-southeast-2" }
}
- A
deployment_auto_approveblock holds checks overcontext.plan(such aschanges.total,changes.add,changes.removeorapplyable); a plan is auto-approved only if every check passes. - The rule takes effect only through a
deployment_groupthat lists it inauto_approve_checks, and a deployment joins a group with itsdeployment_groupargument. - A deployment with no group gets its own generated group,
<name>_default(herelive_default), which has no auto-approve checks of yours — so its plans with changes wait for manual approval. (HashiCorp's built-in rule can still auto-approve a plan that changes nothing.) A deployment inherits whatever checks its group lists, so which group it joins is the approval decision.
Auto-approve orchestration rules are a feature of the higher HCP Terraform editions, so confirm your organization's plan includes them before relying on them.
Organizing HCP Terraform with projects and variable sets
HCP Terraform projects are folders for workspaces and Stacks, and the scope at which you grant access and share configuration as an organization grows. Each workspace belongs to exactly one project (new ones go to the Default Project unless you choose otherwise).
- Team access at project level covers every workspace and Stack in the project, including those created later. Tags help filtering but do not carry permissions.
- Variable sets scoped to a project apply to all of its workspaces, current and future. A global variable set applies to every workspace in the organization, so scope a set to the project when only that project should receive it.
- Precedence: workspace-specific variables overwrite variables from standard (non-priority) variable sets, so a project set alone cannot enforce a value. Marking it a priority variable set makes it override same-key variables at more specific scopes, which is how a platform team forces, say, a compliance region.
- Tags: workspaces created in a project inherit the project's tags.
- Deleting: only an empty project — no workspaces or Stacks — can be deleted; nothing is moved or deleted automatically, so move or delete its workspaces first.
Tip. Expect production scenarios that state an isolation, ownership or automation constraint and ask which environment layout or command meets it with the least restructuring: separate credentials or state storage versus a temporary test copy, a per-pull-request workspace that must be created once and reused, a cleanup job whose workspace delete is refused, a pipeline that must target a workspace without relying on an earlier select, a shared runner whose parallel jobs read the wrong state, or a module release that must reach dev before prod. Short HCL snippets in the stem test terraform.workspace interpolation and map indexing, Stacks deployment groups and auto-approve checks, and module wiring that fails on a first plan. Other items cover when to split a monolithic configuration, what routine -target hides, setting -parallelism through environment variables in a template you cannot edit or on HCP Terraform agents, retiring a Stack deployment, and HCP Terraform project scoping, priority variable sets, tag inheritance and project deletion. Several are Select TWO.
- CLI workspaces share one configuration, backend and credentials; they fit temporary parallel copies, not environments that need separate credentials, access controls or state storage.
- workspace select fails on a missing workspace unless -or-create is set; workspace new fails on an existing one and can seed state with -state.
- workspace delete refuses the current workspace and one still tracking resources; -force leaves real objects unmanaged, and default can never be deleted.
- terraform.workspace is default when nothing else is selected; indexing a map with an unknown workspace fails, so use lookup() with a fallback.
- TF_WORKSPACE is for non-interactive use, and while it is set, workspace select refuses to switch.
- A root per environment gives each its own backend, credentials, constraints, lock file and module pins, so changes are promoted by bumping each root in turn.
- Switch a shared directory between environments with init -reconfigure, never -migrate-state; give concurrent jobs separate TF_DATA_DIR values.
- Scale by splitting roots along rate of change and ownership; -target is for exceptional use and hides drift.
- Lower -parallelism (default 10) for API rate limits; set it per command with TF_CLI_ARGS_plan/apply, which also replaces TFE_PARALLELISM on agents.
- Stacks: components in .tfcomponent.hcl, one deployment per environment in .tfdeploy.hcl; ungrouped deployments get <name>_default with no auto-approval; retire with destroy = true.
Frequently asked questions
Should I use Terraform workspaces for dev, staging and prod?
Only if the environments can share one backend, one set of credentials and the same code. HashiCorp documents CLI workspaces as unsuitable for deployments that need separate credentials and access controls, because every workspace of a configuration stores its state in the same backend. For long-lived environments with those needs, use a separate root configuration per environment calling shared modules, or Terraform Stacks deployments in HCP Terraform.
Why does terraform workspace delete refuse to delete a workspace?
Terraform refuses to delete the currently selected workspace and any workspace whose state still tracks resources. The -force flag overrides the resource check but leaves the real infrastructure running and unmanaged, so the clean sequence is terraform destroy in that workspace, terraform workspace select default, then terraform workspace delete. The default workspace itself can never be deleted.
How do I create a Terraform workspace only if it does not already exist?
Run terraform workspace select -or-create NAME. Plain select fails when the workspace is missing, and workspace new fails when it already exists, so -or-create is the idempotent choice for pipelines such as one workspace per pull request.
What does TF_WORKSPACE do and when should I use it?
TF_WORKSPACE selects the Terraform workspace for every command run in that environment, so a CI job needs no separate select step. HashiCorp recommends it only for non-interactive use, because in a local shell it is easy to forget it is set and change the wrong state. While it is set, terraform workspace select refuses to switch; change the variable instead.
What is the difference between terraform init -reconfigure and -migrate-state?
-reconfigure discards the saved backend settings and connects to the newly specified backend without copying any state, which is right when switching one directory between environments that each already have state. -migrate-state copies the existing state to the new backend, which is right for moving a configuration's state but would overwrite another environment's state if used to switch environments.
How do I promote a Terraform module upgrade from dev to prod?
Keep a root configuration per environment with an exact module version pin, then bump the pin and run terraform init in dev first, then staging, then prod after testing. A range constraint such as ~> lets every environment pick up the new release at its next init, and CLI workspaces cannot run different module versions because they share one configuration.
How do I reduce Terraform API rate limit errors?
Lower -parallelism below its default of 10 so fewer resource operations call the provider at once. To set it without editing pipeline commands, use the TF_CLI_ARGS_plan and TF_CLI_ARGS_apply environment variables; TF_CLI_ARGS would also reach terraform init, which does not accept -parallelism. On HCP Terraform agents, which do not support TFE_PARALLELISM, those same TF_CLI_ARGS variables are the way to set it.
What is a deployment in Terraform Stacks?
A deployment is one instance of a Stack, declared with a deployment block in a .tfdeploy.hcl file, with its own inputs and isolated state; typical deployments are environments or regions. The components they all share are defined once in .tfcomponent.hcl files. Deployment groups with deployment_auto_approve checks decide which plans apply automatically, and plans with changes for a deployment without a group wait for manual approval.
Source
This lesson covers the "Infrastructure lifecycle and operations" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.