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. dev runs a single small database instance and one replica of each service. prod runs a replicated database and several replicas.

  • Identity and credentials. dev and prod normally live in different cloud accounts, subscriptions or projects, and the principal that applies dev must not be able to touch prod.

  • Change velocity and approval. dev may apply automatically on every merge. prod applies 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 apply in dev would plan to destroy prod.

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.

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

terraform workspace list

Lists the workspaces of the current backend. The selected one is marked.

terraform workspace show

Prints the name of the selected workspace.

terraform workspace new NAME

Creates a workspace and switches to it. The name must not exist. -state=path copies an existing state file into the new workspace, -lock=false skips locking and -lock-timeout=DURATION (default 0s) sets how long to retry the lock.

terraform workspace select NAME

Switches the selected workspace. The workspace must exist unless -or-create is given.

terraform workspace delete NAME

Deletes a workspace. It must exist, it must not be the current workspace, and it must not track resources unless -force is given, in which case those resources can be left dangling (still existing but no longer managed). -lock=false and -lock-timeout apply as for new. Some backends add their own checks, such as refusing to delete a locked workspace.

$ 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.

Where workspace state is stored

  • Local backend. Terraform keeps the state of non-default workspaces in a terraform.tfstate.d directory next to the root module. The documentation advises treating it like a local terraform.tfstate file, 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 s3 backend non-default workspaces live at <workspace_key_prefix>/<workspace_name>/<key>, with env: as the default prefix. The pg backend uses a row per workspace, the kubernetes backend a Secret per workspace, and the gcs backend an object per workspace below the prefix. 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 .terraform directory, 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.

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
}

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 aws is evaluated once per working directory. Using terraform.workspace in 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 .terraform on a laptop, so reviewing a pull request tells you nothing about where a change will land.

  • The wrong workspace is one command away. terraform apply in a terminal where staging is 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 .tfvars file with a workspace. A map keyed by terraform.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.

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 -var, -var-file, TF_VAR_ and tfvars files

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

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:

  1. Run-specific values (-var, -var-file and TF_VAR_ environment variables) override workspace and variable-set values with the same key.

  2. A workspace-specific variable overrides a variable-set variable with the same key.

  3. 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.

  4. Priority variable sets override even command-line flags and .tfvars files; 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 tfe provider

Cost of a new environment

Copy a thin directory and add a backend key

One command

Create a workspace (or use the tfe provider or a Stack)

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

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.

flowchart TD subgraph repo["bookshelf-infrastructure (Git)"] subgraph mods["modules (shared code)"] M1["network"] M2["database"] M3["service"] end subgraph dev["envs/dev"] D1["backend.tf, providers.tf"] D2["main.tf, variables.tf"] D3["dev.tfvars"] end subgraph prod["envs/prod"] P1["backend.tf, providers.tf"] P2["main.tf, variables.tf"] P3["prod.tfvars"] end end D2 -->|"calls"| M1 D2 -->|"calls"| M2 D2 -->|"calls"| M3 P2 -->|"calls"| M1 P2 -->|"calls"| M2 P2 -->|"calls"| M3 D1 -->|"state key bookshelf/dev"| SD[("dev state and lock")] P1 -->|"state key bookshelf/prod"| SP[("prod state and lock")] SD --- AD["dev account (dev role)"] SP --- AP["prod account (prod role)"]

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 (?ref=v1.2.0) or registry versions

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 terraform-<PROVIDER>-<NAME> can be published to a registry

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.

  1. Put logic in modules. If both main.tf files 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.

  2. Put values in .tfvars files. 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.

  3. Share the declarations. Because every root module declares its own variables, keep variables.tf and outputs.tf the same by convention, enforced in CI, or generate them with a tool such as Terragrunt.

  4. 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
}

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.

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.hcl file. 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:

  • include and find_in_parent_folders. The shared root.hcl is 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_state with generate. Terragrunt writes the backend.tf file 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.

  • generate block named provider. A generate block can also write a provider.tf file (for example with a role to assume) into every unit, so provider configuration is defined once.

  • dependency with mock_outputs. A unit reads another unit’s outputs, and Terragrunt orders the runs accordingly. mock_outputs supply placeholder values so that plan can run before the dependency has ever been applied.

  • run --all. terragrunt run --all plan shows the plan for every unit in the stack, dependencies first, and terragrunt run --all apply applies every unit, asking for confirmation unless --non-interactive is 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", not web-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 type and a description, and a sensible default only when one exists.

  • Files follow the standard names: backend.tf, main.tf, outputs.tf, providers.tf, terraform.tf, variables.tf and locals.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}"
}

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:

  1. One person, one environment, learning. A single root module with local state, then a remote backend. No environments yet.

  2. Two or three environments in one cloud account, one small team. Directories envs/dev and envs/prod, shared local modules, one .tfvars per environment, partial backend configuration or separate backend blocks.

  3. 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.

  4. 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 tfe provider 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.

  5. Dozens of root modules, one repository, boilerplate everywhere. Add Terragrunt or Stacks on top of the same module library.

  6. 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.

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 .tfvars and in module arguments.

  • A forgotten TF_WORKSPACE or TF_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 key share 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. source without a version or a ref means that main of 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 prod values. Defaults should be the cheap, safe case.

  • Inconsistent environment words. prod, production and prd in different places.

  • Cross-environment data sources. A dev configuration that reads prod state through terraform_remote_state couples the two environments and gives dev access 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 cloud block (workspaces.project, with the TF_CLOUD_PROJECT variable) 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, module sources and encryption configuration; Terraform still forbids it.

  • Terragrunt. The current documentation uses run --all and a root file named root.hcl; older material uses run-all and a root terragrunt.hcl.

  • The terraform env command. It was an older name for terraform workspace and is deprecated; use terraform 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’s generate.

  • DynamoDB lock tables for the S3 backend. Examples that create a dynamodb_table for each environment’s lock are superseded by the S3 native use_lockfile argument; 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 use terragrunt run-all plan, and call the root file terragrunt.hcl. The current documentation uses run --all and root.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.workspace is the expression in both tools.

  • Variables in backend, module sources 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 cloud block are Terraform features. OpenTofu has no equivalent hosted platform from the project itself; teams use the s3, gcs, azurerm or pg backends 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.