Terraform Modules and Workspaces

Modules: reusable, parameterized infrastructure

# modules/web-app/main.tf
variable "instance_count" {}
variable "instance_type" {}

resource "aws_instance" "web" {
  count         = var.instance_count
  instance_type = var.instance_type
}

output "instance_ids" {
  value = aws_instance.web[*].id
}
# root main.tf
module "staging_app" {
  source         = "./modules/web-app"
  instance_count = 2
  instance_type  = "t3.micro"
}

module "prod_app" {
  source         = "./modules/web-app"
  instance_count = 10
  instance_type  = "t3.large"
}
graph TD root["Root module"] -->|"instance_count=2, instance_type=t3.micro"| stagingmod["module: staging_app"] root -->|"instance_count=10, instance_type=t3.large"| prodmod["module: prod_app"] stagingmod --> stagingres["2x t3.micro instances"] prodmod --> prodres["10x t3.large instances"]

A module is a directory of .tf files with declared input variables and outputs — called from a root (or another) module like a function call, with different arguments producing differently configured infrastructure from the same underlying definition. This is the direct way to avoid copy-pasting near-identical resource blocks for staging vs. production — the module defines the shape once, and each caller supplies only what differs.

Workspaces: same config, isolated state — with a real caveat

terraform workspace new staging
terraform workspace select staging
terraform apply   # applies against staging's OWN state file

A workspace gives the same HCL configuration its own, separate state file per workspace — switching workspaces switches which state plan/apply compares against, without touching the .tf files at all. This sounds like clean environment separation, but the real gotcha is operational: which workspace is currently selected is not visually obvious in a typical terminal — running terraform apply without checking terraform workspace show first can apply changes intended for staging against whatever workspace happens to be currently selected, including production.

graph LR subgraph ws["Workspaces (shared backend config)"] d["workspace: dev"] --> statebackend["Same S3 bucket/backend"] s["workspace: staging"] --> statebackend p["workspace: prod"] --> statebackend end subgraph dirs["Separate directories (explicit backend per env)"] devdir["environments/dev/"] --> devstate["dev's own backend config"] proddir["environments/prod/"] --> prodstate["prod's own backend config"] end

Because of that risk, many teams prefer separate directories per environment (environments/dev/, environments/staging/, environments/prod/, each with its own explicit backend configuration and its own .tf files, possibly calling shared modules) over workspaces for environment separation specifically — being in the wrong directory is far more visible (the shell prompt, the files present) than being in the wrong workspace, which requires remembering to check an easily-forgotten piece of implicit state. Workspaces remain genuinely useful for other cases — short-lived per-feature-branch preview environments, where the lower ceremony of switching workspaces outweighs the wrong-environment risk, because the consequence of a mistake is smaller.

Variable precedence

From lowest to highest priority (a later source overrides an earlier one for the same variable):

graph LR a["1. Default value<br/>in variable block"] --> b["2. Environment variable<br/>TF_VAR_name"] b --> c["3. terraform.tfvars file"] c --> d["4. *.auto.tfvars files"] d --> e["5. -var / -var-file<br/>on the command line"]

The command-line -var flag always wins over any file-based value — useful for a one-off override, but also a common source of "why isn't my tfvars file taking effect" confusion when a CI pipeline or a personal shell alias is quietly passing a -var flag that overrides what looks, from the tfvars file alone, like it should be in effect.

Common pitfall

Running terraform apply after switching to a different task without re-checking terraform workspace show (or, for the directory-based approach, without re-checking which directory the shell is actually in) is the single most common way a change intended for one environment lands in another — the command itself gives no environment-specific warning, it just applies against whatever context is currently active. Making the current environment visible at a glance (a shell prompt that displays the active workspace, or simply defaulting to the safer directory-per-environment structure) closes this gap far more reliably than trusting everyone to remember to check manually every time.