Workspaces, Environments and Project Structure
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Sooner or later the same system has to exist more than once: a cheap dev copy for experiments, a staging copy for
release checks and a prod copy that customers use. Terraform and OpenTofu do not have a single built-in notion of an
"environment". They offer several mechanisms that look similar, namely CLI workspaces, separate directories (separate
root modules), HCP Terraform workspaces and projects, and the layers that wrap them such as Terragrunt, and each of them
answers a different question. Choosing the wrong one is a common source of production incidents, because the mechanism that
is easiest to start with (a CLI workspace) is documented as unsuitable for the case people most often use it for (isolating
production from everything else).
This page describes these mechanisms as documented for Terraform 1.17 and OpenTofu 1.13. It explains what each one really isolates, how to lay out a repository, how to keep several environments from drifting apart without copying code, how Terragrunt fits in, and which naming and tagging conventions make the result operable. It follows Backends and Remote State in the reading path, because workspaces are only a way to name several states inside one backend, and it leads into Modules and Module Development and Publishing, which supply the shared code that every environment calls. The platform side (organizations, projects, runs) is expanded in HCP Terraform and Terraform Enterprise, the pipeline side in CI/CD and Automation, and the newer deployment model that replaces a lot of this plumbing in Terraform Stacks.
The examples use the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket, network,
DNS, secrets and IAM). Bucket names, organization names, account IDs and tokens are placeholders. The snippets are
illustrative and are not run here, because no cloud account is involved.
What an environment is, and what must differ between them
An environment is not a Terraform concept. It is a deployment of the same system with a different purpose, a different
audience and a different blast radius. For Bookshelf the three environments share the same architecture (a network, a
PostgreSQL database, a Redis cache, the catalog-api, reviews-api and web services, a covers bucket, DNS records and the
IAM roles that connect them) and differ in a few dimensions:
-
Size and cost.
devruns a single small database instance and one replica of each service.prodruns a replicated database and several replicas. -
Identity and credentials.
devandprodnormally live in different cloud accounts, subscriptions or projects, and the principal that appliesdevmust not be able to touchprod. -
Change velocity and approval.
devmay apply automatically on every merge.prodapplies after a reviewed plan. -
Names and addresses. Two environments must not collide on globally unique names such as bucket names, so names carry the environment.
-
State. Each environment needs its own state, otherwise a
terraform applyindevwould plan to destroyprod.
The last point is the one every Terraform mechanism ultimately serves. A state snapshot belongs to one working directory plus one backend plus one workspace name (described in State), so "an environment" in practice means "a distinct state, a distinct set of input values and, ideally, distinct credentials". The mechanisms below differ in how much of that triple they separate.
Source: Terraform Docs — Style guide
CLI workspaces
A CLI workspace is a named instance of state data inside one working directory and one backend. The documentation describes the difference from HCP Terraform workspaces plainly: CLI workspaces are separate instances of state data within the same working directory, whereas HCP Terraform workspaces each have their own configuration and act as separate working directories. It also says that a CLI workspace is "technically equivalent to renaming your state file".
Every initialized working directory starts with a workspace named default, which cannot be deleted. Creating more
workspaces gives the same configuration several independent states. A plan in a new workspace does not see the resources of
other workspaces; to manage those resources you switch workspaces.
The terraform workspace commands
| Command | Behaviour |
|---|---|
|
Lists the workspaces of the current backend. The selected one is marked. |
|
Prints the name of the selected workspace. |
|
Creates a workspace and switches to it. The name must not exist. |
|
Switches the selected workspace. The workspace must exist unless |
|
Deletes a workspace. It must exist, it must not be the current workspace, and it must not track resources unless
|
Source: Terraform Docs — terraform workspace, Terraform Docs — workspace new, Terraform Docs — workspace select, Terraform Docs — workspace delete
$ terraform workspace list
* default
$ terraform workspace new staging
Created and switched to workspace "staging"!
...
$ terraform workspace select -or-create production
Switched to workspace "production".
The output above is abbreviated and illustrative. The commands change which state the following plan and apply read and
write, and nothing else: they do not copy configuration, variables or credentials.
Source: Terraform Docs — CLI workspaces
Where workspace state is stored
-
Local backend. Terraform keeps the state of non-default workspaces in a
terraform.tfstate.ddirectory next to the root module. The documentation advises treating it like a localterraform.tfstatefile, and recommends a remote backend as soon as several people collaborate. -
Remote backends. The backend stores each workspace under a key derived from the workspace name. For the
s3backend non-default workspaces live at<workspace_key_prefix>/<workspace_name>/<key>, withenv:as the default prefix. Thepgbackend uses a row per workspace, thekubernetesbackend a Secret per workspace, and thegcsbackend an object per workspace below theprefix. These details are listed in Backends and Remote State. Workspace names must be valid in a URL path segment without escaping. -
The selected workspace. The name of the currently selected workspace is stored locally in the git-ignored
.terraformdirectory, so two people (or two terminals) can work on different workspaces of the same configuration at the same time. -
Supported backends. The documentation lists AzureRM, Consul, COS, GCS, Kubernetes, Local, OSS, Postgres, Remote and S3 as backends that support multiple workspaces. Check the backend’s own page for any backend outside that list.
Source: Terraform Docs — Workspaces
Using terraform.workspace
Inside the configuration, terraform.workspace is a string with the name of the selected workspace. It can be used wherever
an expression is allowed, for example to select a settings object from a map or to build a name. OpenTofu keeps the same name.
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
locals {
settings = {
default = { db_instance_class = "db.t4g.micro", api_replicas = 1 }
staging = { db_instance_class = "db.t4g.small", api_replicas = 2 }
prod = { db_instance_class = "db.r6g.large", api_replicas = 4 }
}
# Fails the plan for a workspace that has no entry, instead of guessing.
env = local.settings[terraform.workspace]
name_prefix = "bookshelf-${terraform.workspace}"
}
resource "aws_s3_bucket" "covers" {
bucket = "${local.name_prefix}-covers-example"
tags = {
Application = "bookshelf"
Environment = terraform.workspace
}
}
output "api_replicas" {
value = local.env.api_replicas
}
Source: Terraform Docs — Workspaces
The lookup local.settings[terraform.workspace] is deliberate. An unknown key makes the plan fail, which is much safer than
a conditional such as terraform.workspace == "prod" ? 4 : 1 that silently treats every other name (including a typo) as the
small case. The documentation’s own examples use the same name in count expressions and in tags.
TF_WORKSPACE
TF_WORKSPACE selects a workspace without running terraform workspace select. The documentation recommends it only for
non-interactive use, because a forgotten export in a shell profile makes later commands apply to the wrong state. A
pipeline that runs one job per environment can set it in the job definition.
TF_WORKSPACE=staging terraform plan -var-file=staging.tfvars -out=staging.plan
What CLI workspaces do not give you
The official CLI workspaces page is explicit about the limits, and they are the reason this page spends most of its length on other mechanisms.
-
Workspaces share one backend. The same bucket, the same credentials to read it and the same access policy cover every workspace. The documentation states that organizations often need strong separation between deployments for different development stages or teams, that such deployments often need different credentials and access controls, and that CLI workspaces "are not a suitable isolation mechanism for this scenario". The state page repeats that workspaces are "not appropriate for system decomposition or deployments requiring separate credentials and access controls".
-
Workspaces share one configuration and one provider configuration. A provider block for
awsis evaluated once per working directory. Usingterraform.workspacein a provider block to switch accounts is possible, but the same code and the same pipeline credentials then reach every environment, which is the opposite of isolation. -
Workspaces are invisible in the code. Nothing in the files says which workspaces exist, which one is meant for which environment or which variables go with which workspace. The selected name lives in
.terraformon a laptop, so reviewing a pull request tells you nothing about where a change will land. -
The wrong workspace is one command away.
terraform applyin a terminal wherestagingis still selected changes staging; with a stale selection it changes production. Directories make the target obvious from the prompt and the path. -
No decomposition. One configuration with one state is one blast radius. The documentation recommends separate configurations for each architectural boundary, each with its own backend, and the use of reusable modules for the shared logic.
-
Per-workspace variables are manual. You normally need different input values for each workspace, and Terraform does not associate a
.tfvarsfile with a workspace. A map keyed byterraform.workspace, or a convention such as-var-file="${workspace}.tfvars", is something you build yourself.
The conclusion in the official text is that workspaces let you switch quickly between multiple instances of a single
configuration within its single backend, and that they are not designed to solve every problem. Good uses are short-lived
copies of one stack: a per-branch review environment of a single service in the same account, a copy of a stack to test a
migration, or a scratch environment created and destroyed within a day. For long-lived, differently-privileged environments
the documentation points to a configuration (a directory) per deployment with its own backend, sharing modules, and to
terraform_remote_state, tag-based data source lookups or DNS to share data between them.
Source: Terraform Docs — CLI workspaces
The documentation of the remote backend and of the cloud block adds one more subtlety: with the legacy remote backend
in prefix mode terraform.workspace can mean different things for local and remote operations, which is one more reason to
prefer the cloud block (see Backends and Remote State).
HCP Terraform workspaces, projects and tags
With HCP Terraform (and Terraform Enterprise) the word workspace means something much heavier. The documentation says that HCP Terraform workspaces replace local working directories as the unit for managing a collection of infrastructure: separate workspaces "function like completely separate working directories". A workspace holds the configuration (linked to a version control repository, or uploaded through the API or CLI), its variable values, its state with all earlier state versions, its credentials stored as sensitive variables, and the history of runs. Runs execute remotely on disposable virtual machines by default. You must have at least one workspace to manage resources, and permissions are granted per workspace or per project, which makes workspaces a real access-control boundary, unlike CLI workspaces, which are optional and have no effect on access control.
| CLI workspace | HCP Terraform workspace | |
|---|---|---|
Configuration |
One working directory shared by all workspaces |
Its own configuration per workspace (VCS repository and working directory, or uploads) |
State |
Named state in the configured backend |
Stored in the workspace with state versions |
Variables |
Whatever you pass with |
Stored in the workspace, plus variable sets |
Credentials |
Shell environment or prompts |
Sensitive variables in the workspace (or dynamic credentials) |
Access control |
None of its own; set by the backend |
Teams and permissions per workspace and per project |
Execution |
Where you run the command |
Remote by default, local or agent optional |
Source: HCP Terraform Docs — Workspaces
Naming and creating workspaces
Workspace names must be unique within the organization and can contain letters, numbers, hyphens and underscores. The
documentation recommends 90 characters or fewer, and suggests a consistent pattern that combines component, environment and
region, for example networking-prod-us-east. A workspace created from a VCS workflow defaults to the repository name. Every
workspace belongs to a project.
For Bookshelf the pattern is <component>-<environment>: network-dev, database-dev, services-dev, network-prod,
database-prod and services-prod. This follows the style guide, which recommends a separate workspace per environment and,
for large codebases, splitting resources by component (prod-compute, prod-database, prod-networking).
Projects
A project is a folder that holds one or more workspaces, Stacks, or both. Projects organize resources and scope access: a team can be given a role on a project (this needs a paid edition that supports project permissions) instead of on each workspace. Creating projects needs a team with the Manage all projects permission. The documentation also lists these project-level settings:
-
Tags. Key-value tags on a project are inherited by the workspaces created in it, and can be attached to private registry modules, providers and Stacks component configurations. Up to 10 unique tags per project and up to 10 per workspace (20 in total per workspace) are allowed.
-
Auto-destroy. A default inactivity period after which each workspace’s infrastructure is destroyed, overridable per workspace. This is the HCP Terraform answer to short-lived review environments and is available on the Standard and Premium editions.
-
Deletion. A project can only be deleted when it contains no workspaces or Stacks.
A natural Bookshelf layout is two projects, bookshelf-dev and bookshelf-prod, each holding the three workspaces for that
environment. The platform team gets admin on both, the application teams get write on bookshelf-dev and plan-only on
bookshelf-prod, and the production credentials exist only as sensitive variables (or dynamic credentials) in the
bookshelf-prod workspaces.
Selecting workspaces from the cloud block
A configuration connects to HCP Terraform with the cloud block inside the terraform block (details in
Backends and Remote State). Its workspaces argument selects either
one workspace by name, or a set of workspaces by tags; the two cannot be combined. workspaces.project narrows the match
to a project. If no workspace matches the tags during initialization, the CLI offers to create one with those tags.
terraform {
required_version = ">= 1.10"
cloud {
organization = "bookshelf-example-org"
workspaces {
project = "bookshelf-dev"
tags = { app = "bookshelf", layer = "services" }
}
}
}
With tags, the same directory can serve several workspaces and terraform workspace select chooses between them; with
name the directory is bound to one workspace. TF_CLOUD_ORGANIZATION, TF_CLOUD_PROJECT and TF_WORKSPACE can supply the
values from the environment, so one directory can be reused by different pipelines without edits. HCP Terraform stores
workspace tags as key-value pairs or key-only tags. Tags must have between one and 255 characters, using letters, numbers,
colons, hyphens and underscores, and creating or updating them needs plan permission or higher on the workspace.
Variable sets
Per-environment values and credentials in HCP Terraform live in the workspace, or in variable sets: reusable groups of variables that can be applied to every workspace of the organization, to selected projects or Stacks, or to selected workspaces (a set owned by a project can be applied to the whole project or to some of its workspaces). The documented precedence is:
-
Run-specific values (
-var,-var-fileandTF_VAR_environment variables) override workspace and variable-set values with the same key. -
A workspace-specific variable overrides a variable-set variable with the same key.
-
Variable sets applied to the same workspace can overwrite each other’s keys, and HCP Terraform reports an error if two global variable sets declare the same key.
-
Priority variable sets override even command-line flags and
.tfvarsfiles; policy checks or run tasks are recommended for stricter enforcement.
Files named terraform.tfvars or ending in .auto.tfvars in the configuration are loaded on each run but are not saved to
the workspace and do not show in the UI. Sensitive variables are write-only: their value cannot be read through the UI or the
API, only replaced. Variable sets are not evaluated for workspaces that use local execution mode.
Managing the platform with the tfe provider
The organization’s own structure should itself be code. The hashicorp/tfe provider has resources for projects and
workspaces. The example creates the bookshelf-prod project and one workspace in it; the organization name is a placeholder
and the token comes from terraform login or the TFE_TOKEN environment variable, never from the file.
terraform {
required_version = ">= 1.10"
required_providers {
tfe = {
source = "hashicorp/tfe"
version = "~> 0.60"
}
}
}
provider "tfe" {
organization = "bookshelf-example-org"
}
resource "tfe_project" "prod" {
name = "bookshelf-prod"
description = "Production infrastructure of the Bookshelf system"
tags = {
app = "bookshelf"
environment = "prod"
}
}
resource "tfe_workspace" "database_prod" {
name = "database-prod"
project_id = tfe_project.prod.id
working_directory = "envs/prod/database"
terraform_version = "~> 1.17.0"
tags = {
app = "bookshelf"
environment = "prod"
layer = "database"
}
}
The registry page for tfe_workspace documents that setting the execution mode and agent pool directly on the workspace is
deprecated in favour of the separate tfe_workspace_settings and tfe_organization_default_settings resources, and that the
same property must not be managed in both tfe_workspace and tfe_workspace_settings, because it causes permanent drift.
terraform_version accepts an exact version or a constraint; with a constraint the workspace always uses the newest release
that satisfies it. working_directory is the relative path in which Terraform runs and defaults to the repository root,
which is how one repository with envs/dev and envs/prod directories is mapped onto several workspaces.
Directories, workspaces and HCP Terraform compared
| Question | Directory per environment | CLI workspaces | HCP Terraform workspaces and projects |
|---|---|---|---|
Separate state? |
Yes, each directory has its own backend configuration |
Yes, but in the same backend |
Yes, one state per workspace |
Separate credentials and access control? |
Yes: each backend and each pipeline job can use different identities |
No, one backend and one provider configuration |
Yes: per-workspace and per-project permissions and variables |
Different code per environment? |
Possible; usually thin root modules that call the same modules |
No, same code (differences only through variables) |
Possible per workspace (different directory, branch or repository) |
Visible in code review? |
Yes, the path shows the target |
No, the selection lives outside the code |
Partly: the VCS workspace mapping is configuration, preferably managed with the |
Cost of a new environment |
Copy a thin directory and add a backend key |
One command |
Create a workspace (or use the |
Good for |
Long-lived environments with different accounts |
Short-lived copies of one stack in one account |
Organizations that want managed state, runs, policy and access control |
The style guide gives the rule of thumb the rest of this page follows. With HCP Terraform or Terraform Enterprise, use a
separate workspace per environment. Without it, put the shared logic in modules and create one directory per environment (such
as dev, staging and prod), each with its own backend.tf and its own variables, so that every environment has a
separate state file. In both cases the repository’s main branch is the source of truth for all environments.
Directory-per-environment layout
The most common layout for a self-managed setup puts one root module per environment (and often per component) in its own
directory, each of which is thin: it declares the backend and provider, sets the input values for that environment and calls
shared modules. The shared modules are written once, in a modules directory or in separate repositories (see
Modules).
bookshelf-infrastructure/
├── README.md
├── .terraform-version
├── modules/
│ ├── network/
│ ├── database/
│ └── service/
└── envs/
├── dev/
│ ├── backend.tf
│ ├── providers.tf
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── dev.tfvars
└── prod/
├── backend.tf
├── providers.tf
├── main.tf
├── variables.tf
├── outputs.tf
└── prod.tfvars
The style guide’s standard file names (backend.tf, main.tf, outputs.tf, providers.tf, terraform.tf for version
constraints, variables.tf and locals.tf) apply to every root module, and it recommends keeping local modules in
./modules/<module_name>. Which commands operate on which directory is explicit: you run terraform -chdir=envs/prod plan,
or you cd there, and the working directory decides the backend, the lock file and the .terraform directory.
# envs/prod/backend.tf
terraform {
required_version = ">= 1.10"
backend "s3" {
bucket = "bookshelf-prod-tfstate-example"
key = "bookshelf/prod/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}
# envs/prod/providers.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
Application = "bookshelf"
Environment = var.environment
ManagedBy = "terraform"
Repository = "bookshelf-infrastructure"
}
}
}
# envs/prod/main.tf
module "network" {
source = "../../modules/network"
name = "bookshelf-${var.environment}"
cidr_block = var.vpc_cidr
}
module "database" {
source = "../../modules/database"
name = "bookshelf-${var.environment}"
subnet_ids = module.network.private_subnet_ids
instance_class = var.db_instance_class
multi_az = var.db_multi_az
}
module "catalog_api" {
source = "../../modules/service"
name = "catalog-api"
environment = var.environment
replicas = var.api_replicas
subnet_ids = module.network.private_subnet_ids
database_url = module.database.connection_secret_arn
}
module "reviews_api" {
source = "../../modules/service"
name = "reviews-api"
environment = var.environment
replicas = var.api_replicas
subnet_ids = module.network.private_subnet_ids
database_url = module.database.connection_secret_arn
}
# envs/prod/prod.tfvars
environment = "prod"
region = "eu-west-1"
vpc_cidr = "10.20.0.0/16"
db_instance_class = "db.r6g.large"
db_multi_az = true
api_replicas = 4
terraform -chdir=envs/prod init
terraform -chdir=envs/prod plan -var-file=prod.tfvars -out=prod.plan
terraform -chdir=envs/prod apply prod.plan
Source: Terraform Docs — Style guide, Terraform Docs — Backend type: s3, Terraform Registry — AWS provider, Terraform Docs — Terraform CLI commands
The envs/dev directory is identical in shape: a different backend key (and, ideally, a different bucket in a different
account), a different dev.tfvars and possibly a provider configuration that assumes a different role. Every difference
between the environments is therefore visible in a pull request as a diff of small files, and the commands that touch
production run in an explicit directory with an explicit credential.
The diagram below shows the resulting layout and the flow of the shared code.
Root-module layouts
Two layouts for root modules come up repeatedly, and they can be combined.
Environment-as-root
Each environment is one root module (the layout above, envs/dev and envs/prod). It is easy to understand and cheap to
operate for a small system, because one apply converges one whole environment. The cost is a large blast radius and a long
plan: a typo in the reviews-api service change is planned and applied together with the network and the database, and a
refresh touches every resource.
Application-as-root (component-as-root)
Each component or layer is a root module with its own state, and each environment is an instance of that root module:
envs/
├── dev/
│ ├── network/
│ ├── data/ # PostgreSQL, Redis, covers bucket
│ └── services/ # catalog-api, reviews-api, web
└── prod/
├── network/
├── data/
└── services/
State becomes small and the blast radius shrinks: a change to the services layer cannot recreate the database. The layers are
joined by outputs. The services root reads values published by network and data through terraform_remote_state
(covered in Backends and Remote State), through a data source that
looks the objects up by tag or name, or through a shared parameter store. The official CLI workspaces page itself lists these alternatives (key/value stores, tag-based lookups and DNS) next to terraform_remote_state, which needs read access to the whole state of the producer.
# envs/prod/services/main.tf
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "bookshelf-prod-tfstate-example"
key = "bookshelf/prod/network/terraform.tfstate"
region = "eu-west-1"
}
}
module "catalog_api" {
source = "../../../modules/service"
name = "catalog-api"
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
}
The trade-offs are an ordering requirement (network before data before services, which Terraform cannot infer across
states, so a pipeline or Terragrunt or a Stack must encode it), more directories and more pipeline jobs, and the need to keep
the published outputs a stable contract. Terraform Stacks (Terraform Stacks)
exist to describe exactly this arrangement of components and deployments in one place.
A pragmatic path is to start with environment-as-root and split a layer out when it has a different change cadence, a different owner or a noticeably higher risk. The database is usually the first candidate, because a mistake there is the most expensive.
Monorepo versus polyrepo
Where the code lives matters as much as how it is laid out.
| Monorepo | Polyrepo | |
|---|---|---|
Contents |
Root modules and local modules in one repository |
Root modules in an infrastructure repository, each shared module in its own repository |
Change across modules and environments |
One pull request, atomic, easy to review together |
Several pull requests and releases, coordinated by version bumps |
Module versioning |
Implicit (the same commit), or by directory |
Explicit SemVer tags ( |
CI/CD |
Must target only changed directories; a path filter or a graph tool is needed |
Simple per repository, but needs automation for dependency updates |
Access control |
Coarse: whoever can read the repository reads everything |
Fine: per repository permissions |
Reuse outside the team |
Hard to publish a single module |
Natural: a module repository named |
Source: Terraform Docs — Style guide
The style guide’s wording is that module repositories should keep each module in its own repository so that it can be versioned and published independently, and that infrastructure repositories should be kept separate from module code and group the related resources (for example the compute, networking and database of one web application). It also describes the monorepo option, a single repository with local modules and the root configuration, and warns that it complicates CI/CD, because workflows must target only the modified directories, and makes access control coarser.
A typical Bookshelf arrangement follows from that guidance: one bookshelf-infrastructure repository holding envs/dev and
envs/prod, plus versioned modules (terraform-aws-bookshelf-network, terraform-aws-bookshelf-database and
terraform-aws-bookshelf-service) in their own repositories, consumed with a pinned Git ref or a registry version.
Early in a project the same modules can live under modules/ in the infrastructure repository (a monorepo) and be extracted
once a second consumer appears. See Module Development and
Publishing for the extraction and for the tagging scheme.
Keeping environments DRY
The directory layout trades isolation for repetition: envs/dev/main.tf and envs/prod/main.tf are mostly the same text.
Several techniques keep the repetition small, in this order of preference.
-
Put logic in modules. If both
main.tffiles contain more than a screen of resources, the resources belong in a module and the environment files should only call it. The environment-specific part then shrinks to arguments. -
Put values in
.tfvarsfiles. The differences between environments are values, and values belong in a variable definitions file, one per environment, with the declarations (variables.tf) shared or identical. -
Share the declarations. Because every root module declares its own variables, keep
variables.tfandoutputs.tfthe same by convention, enforced in CI, or generate them with a tool such as Terragrunt. -
Accept a little duplication. Backend and provider blocks are short, they differ legitimately (accounts, keys), and explicit text is easier to review than clever indirection.
tfvars files and loading order
Terraform loads variable values from several sources and applies a fixed precedence (it is described in detail, with a
diagram, in Variables, Outputs and Locals). For environments the
relevant part is: terraform.tfvars, terraform.tfvars.json and any *.auto.tfvars or *.auto.tfvars.json file in the
working directory are loaded automatically, later sources win, and -var-file and -var on the command line override
them, with TF_VAR_name environment variables ranking just below those. A file such as prod.tfvars is not loaded
automatically, so the pipeline must pass -var-file=prod.tfvars explicitly, which is the point: the choice of environment is
visible in the command.
# envs/prod/variables.tf
variable "environment" {
type = string
description = "Environment name used in resource names and tags."
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
variable "region" {
type = string
description = "Cloud region for the environment."
}
variable "vpc_cidr" {
type = string
description = "CIDR block of the environment's network."
}
variable "db_instance_class" {
type = string
description = "Instance class of the PostgreSQL database."
}
variable "db_multi_az" {
type = bool
description = "Whether the database is replicated across availability zones."
default = false
}
variable "api_replicas" {
type = number
description = "Replicas of catalog-api and reviews-api."
default = 1
}
Source: Terraform Docs — Input variables
Defaults in variables.tf should be the safe, cheap value (one replica, no multi-AZ) so that forgetting a value in dev
costs little. A variable that must be decided for each environment (such as environment or vpc_cidr) has no default, so
that omitting it is an error instead of a silent choice. Secrets never go into a .tfvars file that is committed: pass them
with TF_VAR_ environment variables from the pipeline’s secret store or, better, read them at run time from a secrets
manager and use ephemeral or write-only values where the provider supports them (see
Sensitive Data, Ephemeral and Write-only Values).
Backends cannot use variables
The one place that cannot be made DRY with variables is the backend block. The documentation states that the backend block
cannot refer to named values such as input variables, locals or data sources. The standard workaround is partial
configuration: leave the environment-specific arguments out of the block and supply them with -backend-config= files or
flags at init time.
# backend.tf (identical in every environment)
terraform {
backend "s3" {}
}
# backend-prod.tfbackend
bucket = "bookshelf-prod-tfstate-example"
key = "bookshelf/prod/terraform.tfstate"
region = "eu-west-1"
use_lockfile = true
terraform init -backend-config=backend-prod.tfbackend
OpenTofu relaxed this restriction. Since OpenTofu 1.8 early evaluation allows input variables and locals in the backend
block, module sources and encryption configuration, so an OpenTofu project can write key = "bookshelf/${var.environment}/terraform.tfstate"
directly. The details and the version in which each part appeared are in
Backends and Remote State and
OpenTofu. A configuration that must run on both tools should keep to partial
configuration.
Symlinks and copied files
Teams sometimes symlink variables.tf or providers.tf from a common directory into every environment. It works, but
symlinks are fragile on Windows, invisible in some code-review tools and confusing for editors, and Terraform itself offers
nothing to manage them. If you find yourself building that, it is a signal to look at Terragrunt (below), at Stacks, or at
HCP Terraform with one repository and one workspace per environment.
Terragrunt
Terragrunt is an open-source wrapper from Gruntwork that sits in front of OpenTofu or Terraform and removes the
repetition that the directory-per-environment layout creates. It is a separate tool with its own documentation and release
cycle, not part of Terraform or OpenTofu, and the HashiCorp documentation does not describe it. You run terragrunt instead of
terraform or tofu; to adopt it on an existing directory you add a terragrunt.hcl file and Terragrunt runs init for you
when needed.
The vocabulary of its documentation is:
-
Unit. A directory containing a
terragrunt.hclfile. It is the smallest deployable entity and can be operated on independently. -
Stack. A group of units managed together, such as an environment (
dev,staging,prod) or a whole project. -
DAG. The dependency structure between units, from which Terragrunt derives the order of operations.
A unit’s terragrunt.hcl has two main parts. The terraform block with source points at a module (a local path or a Git
URL with a ref that pins a tag, commit or branch, which makes the deployment reproducible), and inputs sets the
environment-specific values for the module variables; Terragrunt passes them to Terraform or OpenTofu as TF_VAR_ environment
variables. An include block pulls in shared configuration, usually from a root file that holds the remote state and
provider configuration for every unit. find_in_parent_folders locates that file in the parent directories, so each unit does
not hard-code a relative path.
live/
├── root.hcl
├── dev/
│ ├── env.hcl
│ ├── network/terragrunt.hcl
│ ├── database/terragrunt.hcl
│ └── services/terragrunt.hcl
└── prod/
├── env.hcl
├── network/terragrunt.hcl
├── database/terragrunt.hcl
└── services/terragrunt.hcl
# live/root.hcl
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "bookshelf-tfstate-example"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "eu-west-1"
encrypt = true
}
}
# live/prod/database/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::https://git.example.com/bookshelf/terraform-aws-bookshelf-database.git?ref=v1.2.0"
}
dependency "network" {
config_path = "../network"
mock_outputs = {
private_subnet_ids = ["subnet-00000000"]
}
}
inputs = {
name = "bookshelf-prod"
subnet_ids = dependency.network.outputs.private_subnet_ids
instance_class = "db.r6g.large"
multi_az = true
}
cd live/prod
terragrunt run --all plan
terragrunt run --all apply
What each element does:
-
includeandfind_in_parent_folders. The sharedroot.hclis merged into the unit as if it had been copied into it. Putting the remote state and provider settings there is the documented way to avoid repeating them in every unit. -
remote_statewithgenerate. Terragrunt writes thebackend.tffile into the working directory, which sidesteps the rule that backends cannot use variables.path_relative_to_include()returns the path of the unit relative to the included file and gives each unit its own state key, so the layout of the directories becomes the layout of the states. -
generateblock namedprovider. Agenerateblock can also write aprovider.tffile (for example with a role to assume) into every unit, so provider configuration is defined once. -
dependencywithmock_outputs. A unit reads another unit’s outputs, and Terragrunt orders the runs accordingly.mock_outputssupply placeholder values so thatplancan run before the dependency has ever been applied. -
run --all.terragrunt run --all planshows the plan for every unit in the stack, dependencies first, andterragrunt run --all applyapplies every unit, asking for confirmation unless--non-interactiveis given.
When Terragrunt helps and when it does not
Terragrunt is worth considering when the system has many environments, many components per environment, the same backend and
provider boilerplate everywhere and a need to run several roots in dependency order. It adds a second configuration language
layer, a second binary to version and install in CI, its own
documentation and its own failure modes, and it can hide what Terraform actually executes. Two alternatives cover part of the
same ground: HCP Terraform workspaces with run triggers and the tfe provider for the platform side, and Terraform Stacks,
which describe components and deployments natively in the product (see
Terraform Stacks). For a system the size of Bookshelf, plain directories and
modules are enough; Terragrunt becomes attractive when the number of root modules reaches the dozens.
The syntax shown above follows the current Terragrunt documentation; its command line and conventions change between releases, so check the documentation for the version in use.
Naming and tagging conventions
Environments only stay manageable if everyone names and labels things the same way. The conventions below combine the official style guide with what the platforms document.
Terraform identifiers
-
Resource and module labels are descriptive nouns with underscores, and do not repeat the resource type, because the address already contains it: the block
resource "aws_instance" "web_api", notweb-api-aws-instance. For Bookshelf:aws_s3_bucket.covers,module.catalog_api. -
Variables and outputs are descriptive nouns separated by underscores, unique within the configuration, with a
typeand adescription, and a sensible default only when one exists. -
Files follow the standard names:
backend.tf,main.tf,outputs.tf,providers.tf,terraform.tf,variables.tfandlocals.tf, and when a file grows, a file per logical group (network.tf,storage.tf,compute.tf). -
Module repositories are named
terraform-<PROVIDER>-<NAME>, which is also what the public and private registries require.
Names of real objects
Resource names inside the cloud provider are the identity that humans and tools see. A consistent scheme such as
<application>-<environment>-<component>[-<suffix>] (bookshelf-prod-covers, bookshelf-dev-catalog-api) makes ownership
and environment obvious in consoles, bills and logs. Build the prefix once in a local value, so it cannot drift:
locals {
name_prefix = "${var.application}-${var.environment}"
}
resource "aws_s3_bucket" "covers" {
bucket = "${local.name_prefix}-covers-${var.name_suffix}"
}
Source: Terraform Docs — Style guide
Globally unique names (object storage buckets, DNS zones, registry names) need a suffix that the environment alone does not
provide, such as an account identifier or a short random value from the random provider (see
Resources and Data Sources). Mind the length and character limits of
each service; a prefix built from four variables is easy to push past a 63-character limit.
Tags and labels
Tags (called labels in Google Cloud) carry the metadata that names cannot: owner, cost centre, environment, data
classification and the fact that Terraform manages the object. A small mandatory set, enforced by policy or by a check, is
better than a long optional one. For Bookshelf: Application, Environment, Owner, CostCentre and ManagedBy.
In the AWS provider the way to avoid repeating the set on every resource is the default_tags block in the provider
configuration. The documentation states that the tags apply to all resources the provider manages that support tags (with an
exception for aws_autoscaling_group), that a resource’s own tags are merged with them, that a resource can override a
default by using the same key but cannot exclude one, and that the merged result is visible in the tags_all attribute while
tags shows only the resource-level tags. Default tags can also be supplied through TF_AWS_DEFAULT_TAGS_<tag_key>
environment variables, with the provider configuration winning on a conflict. The provider-level ignore_tags block hides
tag keys or key prefixes that another system adds (a cost-allocation tool, for example), but a resource that still sets an ignored
key shows a perpetual difference.
provider "aws" {
region = var.region
default_tags {
tags = {
Application = "bookshelf"
Environment = var.environment
ManagedBy = "terraform"
}
}
ignore_tags {
key_prefixes = ["CostTool:"]
}
}
resource "aws_s3_bucket" "covers" {
bucket = "${local.name_prefix}-covers-${var.name_suffix}"
tags = {
Component = "covers"
}
}
Other providers may not offer an equivalent block, in which case a common_tags local value merged into each resource with merge() is the portable pattern; check each provider’s documentation for what it supports. See the cloud-specific pages
Terraform on AWS,
Terraform on Azure and
Terraform on Google Cloud.
HCP Terraform has its own tagging layer: key-value tags on projects and workspaces (inherited from the project, up to the
limits above) and on private registry modules, used to filter lists and, through the cloud block, to select workspaces.
Workspace, state key and variable naming
Use the same words everywhere. If the environment is prod in the directory (envs/prod), in the state key
(bookshelf/prod/terraform.tfstate), in the HCP Terraform workspace (database-prod), in the variable
(the variable environment) and in the tag, then a person looking at any one of them can find the others. Mixing prod,
production and prd is the most common naming defect and costs a surprising amount of debugging time.
Choosing a structure for Bookshelf
A decision path for the Bookshelf team:
-
One person, one environment, learning. A single root module with local state, then a remote backend. No environments yet.
-
Two or three environments in one cloud account, one small team. Directories
envs/devandenvs/prod, shared local modules, one.tfvarsper environment, partial backend configuration or separate backend blocks. -
Different accounts or different people allowed to change each environment. Directories (or HCP Terraform workspaces) with a separate backend and separate credentials per environment. CLI workspaces are out.
-
Several teams, many components, need for approvals and access control. HCP Terraform or Terraform Enterprise: projects per environment, workspaces per component, variable sets for shared values, the
tfeprovider to manage them, VCS or API-driven runs from CI. Terraform Stacks when the same set of components is deployed to many environments or regions. -
Dozens of root modules, one repository, boilerplate everywhere. Add Terragrunt or Stacks on top of the same module library.
-
Short-lived copies for review. CLI workspaces in a sandbox account, or HCP Terraform workspaces with auto-destroy.
Whatever the choice, keep three invariants: production state is separate and access to it is restricted, every environment is created from the same modules, and the pipeline (not a laptop) applies production.
Moving from workspaces to directories
Teams that started with CLI workspaces and now need real isolation can migrate without recreating infrastructure, because a state snapshot can be moved between backends.
# 1. Pull the state of the workspace to be moved.
terraform workspace select prod
terraform state pull > prod.tfstate.json
# 2. Create the new directory with its own backend block, then initialise it.
terraform -chdir=envs/prod init
# 3. Push the state into the new backend, and check that the plan is empty.
terraform -chdir=envs/prod state push "$PWD/prod.tfstate.json"
terraform -chdir=envs/prod plan -var-file=prod.tfvars
Two precautions apply. Take a backup of the state first (see State), and run the new
directory’s plan and check that it shows no changes before touching anything: any difference means the new configuration
or variable values do not match what the workspace had, and the plan is telling you what would change. Alternatively, run
terraform init -migrate-state after changing the backend block to move the state of the selected workspace, which also
offers to copy the other workspaces. Only after the new directory has applied cleanly should the old workspace be deleted with
terraform workspace delete, which refuses to delete a workspace that still tracks resources unless -force is given.
Source: Terraform Docs — terraform init
The reverse (from directories to HCP Terraform workspaces) uses the cloud block and terraform init -migrate-state in the
same way. The platform page describes the details.
Pitfalls
-
Treating workspaces as environments. They share a backend and credentials, so production state is readable and writable by whoever can deploy to
dev. -
Copy-pasting directories. Environment directories that grow different resources, rather than different values, drift; the next promotion then contains surprises. Keep the differences in
.tfvarsand in module arguments. -
A forgotten
TF_WORKSPACEorTF_CLI_ARGS. Both change what the next command does without appearing on the command line. -
Backend keys that collide. Two environments pointing at the same
keyshare a state. Put the environment in the key and protect the bucket with a policy that restricts writers per prefix. -
One giant root. A single state for the network, database and all services makes every plan slow and every mistake large.
-
Unpinned modules.
sourcewithout a version or arefmeans thatmainof the module is production’s code. Pin modules with a tag or registry version and promote the version through the environments. -
Variables with defaults that silently select
prodvalues. Defaults should be the cheap, safe case. -
Inconsistent environment words.
prod,productionandprdin different places. -
Cross-environment data sources. A
devconfiguration that readsprodstate throughterraform_remote_statecouples the two environments and givesdevaccess to production data.
What changed recently
The facts below are taken from the changelogs and documentation referenced at the end of the page.
-
Workspace tags. HCP Terraform stores workspace tags as key-value pairs or key-only tags. The documentation attributes the older single-value tags to Terraform 1.10 and earlier and describes a conversion through the API.
-
HCP Terraform projects. Project-level tags, auto-destroy defaults and the project selector in the
cloudblock (workspaces.project, with theTF_CLOUD_PROJECTvariable) are now documented features, and projects can hold Stacks as well as workspaces. -
OpenTofu 1.8. Early evaluation lets variables and locals appear in the backend block,
modulesources and encryption configuration; Terraform still forbids it. -
Terragrunt. The current documentation uses
run --alland a root file namedroot.hcl; older material usesrun-alland a rootterragrunt.hcl. -
The
terraform envcommand. It was an older name forterraform workspaceand is deprecated; useterraform workspace.
Where the books are outdated or wrong
The books on the shelf present several approaches that need qualification today.
-
Workspaces as the environment mechanism. Older tutorials switch environments with
terraform workspace select prod. The current documentation says CLI workspaces are not a suitable isolation mechanism for deployments that need separate credentials and access controls, which is the main reason for having environments. -
Variables in the backend block. Several books show
key = "${var.environment}/terraform.tfstate"in the backend. Terraform rejects it; only OpenTofu 1.8 and later accept it. Use partial configuration or Terragrunt’sgenerate. -
DynamoDB lock tables for the S3 backend. Examples that create a
dynamodb_tablefor each environment’s lock are superseded by the S3 nativeuse_lockfileargument; the DynamoDB arguments are deprecated (see Backends and Remote State). -
Terraform Cloud naming. The product that older books call Terraform Cloud is now HCP Terraform, and editions and limits differ by plan, so check the current HCP Terraform documentation before relying on a feature.
-
terragrunt run-all. Books written before the CLI redesign useterragrunt run-all plan, and call the root fileterragrunt.hcl. The current documentation usesrun --allandroot.hcl. -
Single-value workspace tags. Examples of
cloud { workspaces { tags = ["app"] } }still work but are the legacy list form of tags.
Terraform and OpenTofu differences
-
CLI workspaces behave the same, and
terraform.workspaceis the expression in both tools. -
Variables in
backend,modulesources and encryption blocks are possible only in OpenTofu (early evaluation). -
State and plan encryption is configured per backend in OpenTofu and can differ per environment directory; see State.
-
HCP Terraform and the
cloudblock are Terraform features. OpenTofu has no equivalent hosted platform from the project itself; teams use thes3,gcs,azurermorpgbackends or third-party platforms, and the layouts of this page apply unchanged. -
Terragrunt supports both OpenTofu and Terraform.
-
Stacks are a Terraform feature (HCP Terraform); OpenTofu has no equivalent.