Phase 4 · ORCHESTRATION & IAC
Terraform 3 — modules & workspaces
By the end of today
- Build a reusable child module and call it twice with different inputs
- Run terraform init, plan and apply and read the module output wiring
- Create workspaces and explain what they isolate — and what they do not
Modules and workspaces: write it once, run it many times
Day 76 got your first apply on the board; day 77 gave you variables, outputs and state. So far one folder has meant one thing, built once. Two features turn that into infrastructure a team can scale: modules for reuse, and workspaces for running one config more than once.
A module is just a folder of .tf files you call from another config with a module block. The folder doing the calling is the root module; the folder it points at with source is a child module. You hand the child inputs through its variable blocks and read results back through its output blocks — the same shape as calling a function with arguments and a return value. Call the same child twice with different inputs and you get two independent copies of the same infrastructure, with no copy-paste. That is the DRY win, and it is why real Terraform code is mostly module blocks.
Real world: A module is a function for infrastructure. Write the greeting logic once, then “call” it with
name = "Ada"and again withname = "Grace"— two results from one definition. Fix a bug inside the module and every caller inherits the fix on its next apply.
You rarely write the hard modules yourself. The Terraform Registry hosts published ones like terraform-aws-modules/vpc/aws — hundreds of lines that build a production-grade network, exposed as a handful of inputs. You call it, set a few variables, and skip the boilerplate entirely.
Workspaces: many states from one config
A workspace is a named instance of state for one configuration. Every config starts in the default workspace; terraform workspace new dev creates a second, empty state and switches to it, terraform workspace select moves between them, and the built-in terraform.workspace value lets the config read the current name — so one config can stamp dev or prod into a resource’s name. With the local backend, each non-default workspace keeps its state under terraform.tfstate.d/<name>/, completely separate from default.
Here is the part interviewers probe: a workspace only forks the state file. It does not fork the backend or the provider credentials — every workspace of a config shares the same backend and the same cloud account you configured. So workspaces are excellent for cheap, parallel copies (a per-branch test stack, a throwaway demo) and a poor fit for true staging-versus-production isolation, where a stray apply in the wrong workspace would still hit the same account. For environments that must never touch, HashiCorp’s own guidance is separate configurations, directories and backends — not workspaces.
Today’s lab stays entirely local and free: a greeting module, called twice, run across two workspaces, so you feel both features without a cloud bill.
Hands-On Lab
Budget about 30 minutes. Open your WSL2 Ubuntu 24.04 terminal with Terraform 1.10+ installed (day 76’s setup). Run every block in the same session so your working directory persists. This lab uses only the hashicorp/local provider, which writes files on your own disk — it touches no cloud and costs nothing. Identifiers you’ll see (.terraform.lock.hcl hashes, timing) vary per run; the local_file id values are the SHA1 of each file’s content, so they are deterministic and will match the samples below exactly.
# 1. Confirm Terraform is installed and new enough for this config's required_version.
terraform version
# Output (your minor and patch versions will differ — anything satisfying the version constraint works; anything 1.10 or newer works here):
# Terraform v1.15.2
# on linux_amd64
# 2. Make the folders and write the CHILD module. One .tf file can hold its
# inputs (variables), the resource it manages, and its output.
mkdir -p ~/tf-modules-lab/modules/greeting && cd ~/tf-modules-lab
cat > modules/greeting/main.tf <<'EOF'
variable "name" {
type = string
description = "Who to greet."
}
variable "env" {
type = string
description = "Environment name, woven into the filename and message."
default = "default"
}
resource "local_file" "greeting" {
filename = "${path.root}/greeting-${var.name}-${var.env}.txt"
content = "Hello, ${var.name}! (workspace: ${var.env})\n"
}
output "path" {
value = local_file.greeting.filename
}
EOF
ls modules/greeting
# Output:
# main.tf
# 3. Write the ROOT config: it requires the local provider and calls the greeting
# module TWICE with different names. terraform.workspace feeds the env input.
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.10"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
module "ada" {
source = "./modules/greeting"
name = "Ada"
env = terraform.workspace
}
module "grace" {
source = "./modules/greeting"
name = "Grace"
env = terraform.workspace
}
output "ada_file" { value = module.ada.path }
output "grace_file" { value = module.grace.path }
output "workspace" { value = terraform.workspace }
EOF
ls
# Output:
# main.tf modules
# 4. Initialise. Watch the "Initializing modules..." step install BOTH module calls,
# then the local provider download. init is what makes a new module usable.
terraform init
# Output (trimmed; provider version may differ — init installs the newest 2.x that satisfies ~> 2.5):
# Initializing the backend...
# Initializing modules...
# - ada in modules/greeting
# - grace in modules/greeting
# Initializing provider plugins...
# - Finding hashicorp/local versions matching "~> 2.5"...
# - Installing hashicorp/local v2.9.1...
# - Installed hashicorp/local v2.9.1 (signed by HashiCorp)
#
# Terraform has been successfully initialized!
# 5. Plan. Two resources — one per module call — both to be created.
terraform plan
# Output (trimmed):
# Terraform will perform the following actions:
#
# # module.ada.local_file.greeting will be created
# + resource "local_file" "greeting" {
# + content = "Hello, Ada! (workspace: default)\n"
# + filename = "./greeting-Ada-default.txt"
# + id = (known after apply)
# }
#
# # module.grace.local_file.greeting will be created
# + resource "local_file" "greeting" {
# + content = "Hello, Grace! (workspace: default)\n"
# + filename = "./greeting-Grace-default.txt"
# + id = (known after apply)
# }
#
# Plan: 2 to add, 0 to change, 0 to destroy.
#
# (The real plan lists several more computed hash/permission attributes, trimmed here for brevity; content_sha1 is the value that becomes the id.)
# 6. Apply. The default workspace's state now tracks both files.
terraform apply -auto-approve
# Output (ids are the SHA1 of each file's content — deterministic, so these match):
# module.ada.local_file.greeting: Creating...
# module.grace.local_file.greeting: Creating...
# module.ada.local_file.greeting: Creation complete after 0s [id=41bc03a9e953082c4ce8960fed7b3c4c6afb018a]
# module.grace.local_file.greeting: Creation complete after 0s [id=1eed483f16e78a26a3893e3bf91a2244310ac065]
#
# Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
#
# Outputs:
#
# ada_file = "./greeting-Ada-default.txt"
# grace_file = "./greeting-Grace-default.txt"
# workspace = "default"
# 7. Read the two files the module wrote. Same module, different inputs.
cat greeting-Ada-default.txt greeting-Grace-default.txt
# Output:
# Hello, Ada! (workspace: default)
# Hello, Grace! (workspace: default)
# 8. Create a second, independent state and switch to it. list marks the current one with *.
terraform workspace new dev
terraform workspace list
# Output:
# Created and switched to workspace "dev"!
#
# You're now on a new, empty workspace. Workspaces isolate their state,
# so if you run "terraform plan" Terraform will not see any existing state
# for this configuration.
#
# default
# * dev
# 9. Apply again — now terraform.workspace is "dev", so the module writes NEW files.
# dev's state lives in its own directory, separate from default's.
terraform apply -auto-approve
# Output (tail):
# module.ada.local_file.greeting: Creation complete after 0s [id=ba5581ff8a0ed03314f255f99841ce193751e19e]
# module.grace.local_file.greeting: Creation complete after 0s [id=588aad6d149988ab826cf3d7bd5fa5745cea4f8c]
#
# Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
ls greeting-*.txt
# Output (all four coexist — the dev apply did not touch default's files):
# greeting-Ada-default.txt greeting-Ada-dev.txt greeting-Grace-default.txt greeting-Grace-dev.txt
ls terraform.tfstate.d/dev/
# Output (the local backend keeps each non-default workspace's state here):
# terraform.tfstate
# 10. terraform.workspace in action: the output value and the file both carry "dev".
terraform output workspace
# Output:
# "dev"
cat greeting-Ada-dev.txt
# Output:
# Hello, Ada! (workspace: dev)
# 11. Clean up BOTH workspaces, then delete dev. destroy only ever touches the
# CURRENT workspace's state, so you must select each one in turn.
terraform destroy -auto-approve # still on dev — removes the two -dev files
terraform workspace select default
terraform destroy -auto-approve # removes the two -default files
terraform workspace delete dev
# Output (trimmed):
# Destroy complete! Resources: 2 destroyed.
# Switched to workspace "default".
# Destroy complete! Resources: 2 destroyed.
# Deleted workspace "dev"!
Read blocks 6 and 9 back to back: one module definition, called twice, produced two files — and switching workspaces reran the same config against a fresh, separate state to produce two more. That is the whole point of today — reuse the config with modules, run it many times with workspaces.
Common Errors & Fixes
These three catch people the first week they split a config into modules and reach for workspaces. Read the error text slowly — parsing it is the skill.
Common error: Adding a
moduleblock, then runningplanorapplywithoutterraform initfirst:╷ │ Error: Module not installed │ │ on main.tf line 11, in module "ada": │ 11: module "ada" { │ │ This module is not yet installed. Run "terraform init" to install all │ modules required by this configuration. ╵Why: When you add a module (or change its
source), Terraform has to install the child — copy or link it into.terraform/modulesand record it — before it can plan against it. That install happens ininit, not automatically onplan. So a fresh clone, or any newly added module, errors until you initialise.Fix: Run
terraform init. Re-run it every time you add a module or change asource; it is cheap and idempotent, and it also downloads any new providers a module pulls in.How you’d spot it in prod: A CI pipeline that runs
terraform planfails the first time someone adds a module, with “Module not installed.” The fix is a dedicatedterraform init -input=falsestage before plan — which every pipeline should already have.
Common error: A typo in a local module
sourcepath — heregreetingsinstead ofgreeting:╷ │ Error: Unreadable module directory │ │ Unable to evaluate directory symlink: lstat modules/greetings: no such │ file or directory ╵Why: A local
sourceis a path relative to the file that declares it, resolved atinit. If the folder does not exist, Terraform cannot read it and stops. A local path must also start with./or../— leave that prefix off and Terraform instead treats the string as a Registry address and tries to download it, which fails with a different “not found” message.Fix: Point
sourceat the real folder (./modules/greeting) and re-runterraform init. Remember local sources are relative to the calling file and must carry the./or../prefix.How you’d spot it in prod: Right after a refactor that moves module folders,
initfails with “Unreadable module directory” (or a Registry 404). It is almost always a path that did not get updated, or a missing./prefix — check thesourceline before anything else.
Common error: Trying to
terraform workspace deletea workspace that still has resources:╷ │ Error: Workspace is not empty │ │ Workspace "dev" is currently tracking the following resource instances: │ - module.ada.local_file.greeting │ - module.grace.local_file.greeting │ │ Deleting this workspace would cause Terraform to lose track of any │ associated remote objects, which would then require you to delete them │ manually. You should destroy these objects with Terraform before │ deleting the workspace. ╵Why: Each workspace keeps its own state, and
destroyonly ever touches the current workspace’s state. Deleting a non-empty workspace would orphan whatever that state tracks — the resources keep existing but nothing manages them. This is the same truth that makes workspaces a weak isolation tool: they fork state, not the backend or the credentials, so each one must be torn down on its own.Fix: Destroy the workspace’s resources first, then delete it:
terraform workspace select dev,terraform destroy -auto-approve,terraform workspace select default, thenterraform workspace delete dev. Reach for-forceonly when you truly mean to drop the state and clean up the real objects yourself.How you’d spot it in prod: “I deleted the workspace but the resources are still billing” means someone
-force-deleted a non-empty workspace and stranded live infrastructure. Always destroy before delete, and useterraform workspace listto see which states still exist.
Terraform Modules Interview Questions
Cover the answers below and say your own version out loud first — define a module, and what a workspace does and does not isolate, before you reveal each answer. Recalling before revealing is what makes these stick when an interviewer asks them cold. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~30 more minutes today:
- 5 min — Run
terraform consolein the lab folder, then typeterraform.workspaceandmodule.ada.pathto poke the live values the way an expression tester would. - 10 min — Open
terraform-aws-modules/vpc/awson the Terraform Registry and read its Inputs and Outputs tabs — that list is the module’s contract at real-world scale. - 15 min — Skim HashiCorp’s “when not to use workspaces” guidance, then install OpenTofu (the open-source Terraform fork) and rerun this exact lab with
tofu init/tofu apply— the commands and output are identical.
What is a Terraform module, and why would you use one? Both
A module is a folder of Terraform files you call from another config with a module block. The folder doing the calling is the root module; the one it points at with source is a child. I pass inputs through its variable blocks and read results back from its output blocks — it's basically a function for infrastructure. I reach for one for reuse: instead of copy-pasting the same twenty lines to build a network three times, I write it once and call it three times with different inputs. That keeps things DRY, gives me one place to fix bugs, and lets me pull battle-tested modules off the Terraform Registry instead of writing them myself.
What does a Terraform workspace give you, and what does it not isolate? Both
A workspace is a named instance of state for one configuration. Every config starts in the default workspace; terraform workspace new dev gives me a second, empty state, and terraform.workspace lets the config read the current name so I can stamp it into resource names. The catch — and interviewers love this — is that a workspace only forks the state file. It does not fork the backend or the provider credentials, so every workspace of a config still points at the same backend and the same cloud account. That makes workspaces great for cheap parallel copies like a per-branch test stack, but a poor tool for real staging-versus-prod isolation, where I'd use separate directories and backends instead.
How do inputs and outputs wire a module to the config that calls it? Service
Inputs are the module's variable blocks — the caller sets them in the module block, like name = "Ada". Outputs are the module's output blocks — the values it hands back, which the caller reads as module.<name>.<output>. So it's a clean contract: the caller only sees the inputs it's allowed to set and the outputs the module chose to expose; everything inside stays private. In my greeting module the input is the name to greet and the output is the file path it wrote, so the root config can print that path without knowing how the module built it. That encapsulation is what lets me swap a module's internals without touching any config that calls it.
Should you use workspaces to separate staging and production? Product
Short answer: not on their own. Workspaces isolate state, not the things that actually keep prod safe — they share the same backend and the same provider credentials, so a careless apply in the wrong workspace can still hit the wrong account. They're perfect for ephemeral or parallel copies of the same thing: a demo, a per-pull-request stack, a quick experiment. For staging and production, which must never touch each other, I keep separate configurations with separate backends and separate credentials, usually in separate directories or repos. That way switching environments is a deliberate act, not a one-word terraform workspace select that's easy to forget. HashiCorp's own docs say the same thing.
Mark Day 78 complete
Tomorrow Terraform meets a real cloud — you point the AWS provider at your account and provision the project's infrastructure for real.
Stuck on today’s lab? Ask in Mission 90 Q&A