Terraform / Infrastructure as Code
A Complete 11-Chapter Infrastructure Provisioning Course
Table of Contents
- What Terraform Is & Why It Exists
- Installing Terraform & Your First Configuration
- The Terraform Language In Depth
- Variables, Outputs & Locals
- State Management
- Remote Backends & Team Collaboration
- Modules
- Workspaces & Multi-Environment Patterns
- Provisioners, Import & Handling Drift
- Terraform in CI/CD & Testing
- Capstone: Provisioning a Real Multi-Resource Environment
What Terraform Is & Why It Exists
Terraform / Infrastructure as Code
Chapter 1 Β· What Terraform Is & Why It Exists
cloud1-11 gave Infrastructure as Code a first look β declarative vs. imperative, Terraform named as the cross-provider option, plan/apply and configuration drift introduced at a conceptual level. This course goes deep specifically on Terraform itself: not IaC in general, but this one tool, in real depth.
The Configuration Drift Problem
A server gets provisioned by hand through a cloud console. Six months later, nobody fully remembers which settings were changed, when, or why β a firewall rule added during an incident, a instance size bumped up "temporarily" during a traffic spike and never reverted. Staging quietly stops matching production. This is configuration drift, and it's the exact problem cloud1-11 named but didn't solve: infrastructure that exists only in a console, with no record of its own history, and no reliable way to reproduce it.
Declarative vs. Imperative Infrastructure
An imperative script says exactly what steps to run, in order: "create a VM, then attach a disk, then open port 443." Run it twice and it may fail the second time (the VM already exists) or duplicate resources. A declarative tool like Terraform instead says what the end state should look like β "there should be one VM, with this disk, with this port open" β and lets Terraform figure out what actions are needed to get there, including doing nothing if reality already matches.
That block doesn't say "run these commands" β it says "this instance should exist, with these properties." Terraform compares that declared intent against what's actually running and works out the difference itself.
Terraform vs. the Cloud-Native Alternatives
| Tool | Provider Scope | Language |
|---|---|---|
| Terraform | Cross-provider β AWS, Azure, GCP, and hundreds of others via a shared provider model | HCL (HashiCorp Configuration Language) |
| CloudFormation | AWS only | JSON/YAML |
| ARM / Bicep | Azure only | JSON / Bicep DSL |
| Deployment Manager | GCP only | YAML/Jinja2/Python |
Each cloud-native tool is genuinely well-integrated with its own provider β but locked to it. Terraform's core value, especially relevant given cloud1-2's own cross-provider terminology map, is expressing infrastructure across providers (or a multi-cloud environment) using one consistent language and workflow.
Terraform vs. Ansible β Two Different Jobs
Terraform provisions infrastructure β it creates the VM, the network, the database instance. It has no concept of "log in and install a package." Ansible configures infrastructure that already exists β installing software, editing config files, restarting services, on machines Terraform (or something else) has already brought into being. They're complementary, not competing: a real pipeline commonly runs Terraform first to create the servers, then Ansible to configure them.
The Terraform Workflow: Write β Plan β Apply β Destroy
- Write β describe desired infrastructure in
.tfconfiguration files - Plan β Terraform compares desired state against actual state and shows exactly what it intends to change, before touching anything
- Apply β Terraform executes that plan, creating/updating/deleting only what's needed
- Destroy β Terraform tears down everything it manages, cleanly, in dependency order
The plan step is the single biggest practical difference from clicking through a console: you see the change before it happens, every time.
When Terraform Fits β and When It Doesn't
A one-off resource for quick local testing, or a genuinely single, tiny, rarely-touched environment, often isn't worth the ceremony. Terraform earns its keep once infrastructure needs to be reproducible, reviewed by a team, or exist in more than one environment (dev/staging/prod) β exactly the scenarios where console-driven drift becomes a real, recurring problem.
plan. It's the single practice most responsible for catching unintended changes before they happen.
Hands-On Exercises
Explain, in your own words, why a declarative tool comparing desired state against actual state avoids the "run it twice and it breaks" problem that an imperative script has.
π View solutionA team wants to manage infrastructure spread across AWS and Azure with one consistent workflow. Explain why CloudFormation is a poor fit here, and why Terraform is.
π View solutionA colleague suggests using Terraform to install and configure nginx on an existing server. Explain why this is the wrong tool for that specific job, and name the right one.
π View solutionChapter 1 Quick Reference
- Configuration drift β infrastructure changed by hand, with no record, that quietly diverges from what's expected
- Declarative β describe the desired end state; the tool figures out the steps
- Terraform vs. CloudFormation/ARM/Deployment Manager β cross-provider vs. single-cloud-native
- Terraform vs. Ansible β provisions infrastructure vs. configures infrastructure that already exists
- Workflow β write β plan β apply β destroy, with
planas the review step before anything changes
Installing Terraform & Your First Configuration
Terraform / Infrastructure as Code
Chapter 2 Β· Installing Terraform & Your First Configuration
Chapter 1's resource block was a preview. This chapter installs the real CLI and runs an actual configuration end to end β deliberately using a provider that needs no cloud account or credentials, so the workflow itself is the entire focus.
Installing Terraform
Terraform ships as a single binary β download it from HashiCorp's releases, or install via a package manager (brew install terraform on macOS, choco install terraform on Windows, or an apt/dnf repository on Linux). Verify the install:
The terraform and provider Blocks
Every configuration starts with a terraform block declaring which providers it needs, and a provider block configuring one of them. A provider is a plugin that knows how to talk to a specific system β AWS, Azure, a local filesystem, even things like GitHub or Cloudflare all have providers.
terraform init
init reads the required_providers block, downloads the matching provider plugin(s) into a local .terraform/ directory, and initializes the backend (by default, a plain local state file β remote backends are Chapter 6's own topic).
The Dependency Lock File
init also writes .terraform.lock.hcl, recording the exact provider version and checksum actually installed. This is the same idea as node1-2's package-lock.json or ruby2-7's Gemfile.lock: it guarantees everyone running init against this configuration gets the identical provider build, not just "something matching the version constraint."
- Commit
.terraform.lock.hclto git β it's what makes provider versions reproducible across machines and teammates - Never commit
.terraform/β it's a local download cache, regenerated byiniton any machine, exactly likenode_modules/or a Rusttarget/directory
Provider Version Pinning
The version = "~> 2.5" constraint means "any 2.5.x release, but not 2.6.0 or higher." Pinning matters for the same reason the lock file does β an un-pinned provider could silently pick up a newer version with different behavior, producing configuration drift of its own, this time in the tool itself rather than the infrastructure it manages.
The resource Block In Depth
A resource has the shape resource "<type>" "<local name>" { ... }. The type (local_file) is defined by the provider; the local name (hello) is how this configuration refers to it. Together they form a unique resource address, local_file.hello, used throughout the rest of the configuration and in CLI commands.
terraform plan
Run plan before ever applying β it shows exactly what will change, using + for create, - for destroy, and ~ for update-in-place.
terraform apply
apply re-runs the same plan and, after an interactive confirmation, executes it.
hello.txt now exists on disk. Run terraform plan again with nothing changed, and it reports "No changes" β the same idempotency property from Chapter 1's Exercise 1, now demonstrated for real.
terraform destroy
Tears down everything this configuration manages, in dependency order, after its own interactive confirmation.
local provider needs no account, no credentials, and no cost β deliberately chosen here so the workflow itself (init β plan β apply β destroy) is what gets practiced first, before real cloud providers enter the picture.
provider "aws" {}) should read credentials from environment variables or a credentials file β never write an access key directly into a .tf file. Exactly the rule pipelines1-5 and crypto1-11 already established: a committed credential is compromised the moment it's pushed, permanently, even if later removed.
Hands-On Exercises
After applying the hello.txt example, running terraform plan again with nothing changed prints "No changes." Explain what this output confirms about the workflow, connecting it back to Chapter 1's idempotency exercise.
Explain why .terraform.lock.hcl should be committed to git while the .terraform/ directory should not, drawing the parallel to another ecosystem's own lock-file convention.
A colleague writes provider "aws" { access_key = "AKIA..." secret_key = "..." } directly in a committed .tf file. Explain why this is dangerous and what to do instead.
Chapter 2 Quick Reference
terraform initβ downloads providers, initializes the backend, writes the lock fileterraform planβ shows what would change (+/-/~), changes nothingterraform applyβ executes the plan after confirmationterraform destroyβ tears down every managed resource- Commit
.terraform.lock.hclβ never commit.terraform/ - Never hardcode credentials in a provider block β use environment variables or a credentials file
The Terraform Language In Depth
Terraform / Infrastructure as Code
Chapter 3 Β· The Terraform Language In Depth
Chapter 2 wrote one resource in isolation. Real infrastructure is many resources referencing each other β this chapter covers how HCL expresses those relationships, and the meta-arguments that control how resources are created.
HCL Syntax Building Blocks
A block has a type, zero or more labels, and a body of arguments: resource "type" "name" { key = value }. Values can be strings, numbers, booleans, lists (["a", "b"]), or maps ({ key = "value" }). String interpolation embeds an expression inside a string with ${...}, and comments use # or /* */.
Data Sources
A resource block creates and manages something. A data block only reads something that already exists β infrastructure this configuration doesn't own, like a shared VPC created elsewhere, or the latest AMI ID published by a cloud provider.
The Implicit Dependency Graph
data.aws_ami.ubuntu.id above is a reference β and referencing one resource's attribute from inside another is exactly what tells Terraform "create/read this one first." Terraform builds this into a full dependency graph (a DAG) automatically, from every reference in the configuration, and applies resources in the correct order β running independent resources in parallel where nothing connects them. File order never matters β only the actual references do.
count
Creates multiple copies of a resource from a single block, indexed numerically via count.index.
Resource addresses become local_file.server_config[0], [1], [2].
for_each
Creates multiple copies keyed by a map or set of strings, via each.key/each.value β the modern default for anything beyond a fixed, never-changing count.
Resource addresses become local_file.server_config["web"], ["api"], ["worker"] β keyed by a stable name, not a position.
count = 3, removing the resource at index 1 shifts index 2 down to fill the gap β Terraform sees this as "destroy index 1 AND index 2, then recreate a new index 1," not "destroy the one item that was actually removed." With for_each, deleting "api" from the set only ever affects the resource keyed "api" β the other two are untouched, because their addresses were never based on position in the first place.
depends_on
For the rare case where one resource genuinely depends on another but no attribute reference exists to express it (e.g. IAM permission propagation delays), depends_on states the dependency explicitly. Used sparingly β an implicit reference is almost always preferable when one is available, since it stays correct even if the configuration is later refactored.
The lifecycle Block
A meta-argument block controlling special-case behavior for how a resource is created, updated, or destroyed:
create_before_destroyβ builds the replacement before tearing down the original, avoiding downtime on a forced replacementprevent_destroyβ makesterraform destroy(or a plan that would destroy this resource) fail outright, a safety rail for genuinely critical resourcesignore_changesβ tells Terraform to stop reporting drift on specific attributes, e.g. tags a separate system updates automatically
for_each's stable, name-based addressing avoids the reordering trap count can cause.
Hands-On Exercises
Explain why referencing one resource's attribute inside another resource's argument is what creates Terraform's dependency graph, and why the order the blocks appear in the file doesn't matter.
π View solutionA team manages 3 identical resources with count = 3 and needs to delete only the middle one (index 1). Explain what Terraform plans to do to the other two resources, and why for_each avoids this problem.
Explain what prevent_destroy protects against, and describe a concrete real-world resource where it's genuinely worth setting.
Chapter 3 Quick Reference
- data β reads existing infrastructure; never creates or manages it
- Dependency graph β built from references (
resource.attribute), not file order - count β numeric index, position-based addresses, reordering risk
- for_each β key-based addresses, stable under add/remove β the modern default
- depends_on β explicit dependency, used only when no implicit reference exists
- lifecycle β
create_before_destroy,prevent_destroy,ignore_changes
Variables, Outputs & Locals
Terraform / Infrastructure as Code
Chapter 4 Β· Variables, Outputs & Locals
Every value so far has been hardcoded directly into a resource block. Real configurations need to be parameterized β the same configuration deployed to dev, staging, and prod, with only a handful of values actually differing between them.
Input Variables
A variable block declares a named input, with an optional type, default, and description. Reference it anywhere in the configuration with var.<name>.
Omitting default makes the variable required β Terraform refuses to plan until a value is supplied, useful for forcing an explicit choice (like an environment name) rather than silently falling back to a guess.
Variable Validation
A validation block rejects bad input at plan time, before anything is ever created β cheaper than discovering the mistake mid-apply.
Sensitive Variables
Marking a variable sensitive = true redacts its value from CLI plan/apply output β Terraform prints (sensitive value) instead of the real string.
sensitive = true only redacts CLI output β the real value is still written into the state file in plaintext. Protecting that file is Chapter 5's own topic; marking a variable sensitive is not, by itself, a complete solution to keeping a secret safe.
Locals
A locals block defines internal computed values β not inputs, just names for expressions used more than once. Unlike variables, locals aren't set from outside the configuration; they exist purely to avoid repeating the same expression.
.tfvars Files
Instead of typing -var flags on every run, values can live in a .tfvars file. terraform.tfvars and any *.auto.tfvars file load automatically; anything else needs an explicit -var-file flag β the pattern Chapter 8's workspaces build on for genuinely separate dev.tfvars/prod.tfvars files per environment.
Variable Precedence Order
Lowest to highest priority β later sources override earlier ones:
- Environment variables (
TF_VAR_name) terraform.tfvars, if presentterraform.tfvars.json, if present- Any
*.auto.tfvars/*.auto.tfvars.jsonfiles, in alphabetical order -varand-var-filecommand-line flags, in the order given β always win
-var flag on the command line overrides every file-based source β useful for one-off overrides during testing, but easy to forget about when a plan doesn't match what the .tfvars file seems to say.
Hands-On Exercises
Explain why sensitive = true alone is not sufficient to protect a secret value, and name what else actually needs protecting.
terraform.tfvars sets region = "us-east-1", and the run also passes -var="region=us-west-2" on the command line. Which value does Terraform actually use, and why?
Explain the practical difference between a variable and a local, and describe a scenario where each is the right choice.
π View solutionChapter 4 Quick Reference
- variable β an input; omit
defaultto make it required - validation β rejects bad input at plan time
- sensitive = true β redacts CLI output only, not the state file
- locals β internal computed values, not external inputs
- Precedence (low β high) β env vars β
terraform.tfvarsβ*.auto.tfvars(alphabetical) β-var/-var-fileflags
State Management
Terraform / Infrastructure as Code
Chapter 5 Β· State Management
Chapter 4's warn-box left a thread open: state stores real secret values in plaintext. This chapter explains what state actually is, why Terraform can't function without it, and what that means for how it must be handled.
What State Is & Why Terraform Needs It
Every apply writes terraform.tfstate β a JSON file mapping every resource address (local_file.hello) to the real-world object it created and every attribute that object actually has. Without it, Terraform would have no way to distinguish "a resource this configuration already created" from "a resource with the same name that happens to exist for some other reason" β every plan would risk creating duplicates rather than recognizing something as already satisfied. State is the literal record that makes the desired-vs-actual comparison from Chapter 1 possible at all.
Local State
By default, state lives as a plain file on local disk β fine for solo experimentation, but it means only one machine has any record of what Terraform manages. Anyone else running apply against the same configuration, from a different machine, has no shared state to compare against.
sensitive in Chapter 4, stored here in plain, unredacted text. Committing it is exactly the same mistake as Chapter 2's hardcoded-credential warning: permanently exposed in git history the moment it's pushed. Add *.tfstate* to .gitignore, the same discipline git1-6 already covers.
Remote State β A Preview
Local state has two problems at once: secrets sitting in a file that's tempting to accidentally commit, and no shared record for a team. Chapter 6 covers remote backends in full β storing state in a shared, access-controlled location instead of on one person's disk, solving both problems together.
terraform state Commands
Inspecting and safely modifying state without hand-editing the file:
state mv renames a resource in state without destroying and recreating the real object β Terraform otherwise treats a renamed block exactly like Chapter 3's addressing rules imply: a different address means a different resource, and a different resource means destroy-then-create.
terraform state mv/rm/list/show instead; they modify state safely, through Terraform's own understanding of its structure.
Drift Detection, In Full
cloud1-11 introduced drift conceptually. Here's the actual mechanism: terraform plan performs a three-way comparison β the desired configuration (the .tf files), the last-known state (what Terraform believes exists), and a fresh refresh against the real infrastructure itself. If a manual console change altered something Terraform manages, the refresh step detects that the real world no longer matches state, and plan reports it β exactly the "manual fix gets silently reverted by the next automated apply" scenario cloud1-11 warned about.
| Source | What it represents |
|---|---|
| Configuration | What the .tf files say should exist |
| State | What Terraform last recorded as existing |
| Real infrastructure | What's actually running right now, checked via refresh |
Automatic Backups
Every state-modifying command writes terraform.tfstate.backup before making its change β a single-generation safety net if a state operation goes wrong.
Hands-On Exercises
Explain, in your own words, why Terraform needs a state file at all β what specifically would break if it tried to operate without one?
π View solutionExplain the three-way comparison terraform plan performs, and how it's able to detect a manual console change as drift.
A teammate wants to rename a resource block from "web" to "app_server" without Terraform destroying and recreating the real infrastructure. What command should they use, and why is hand-editing the state file instead a real risk?
Chapter 5 Quick Reference
- State β the record mapping configuration to real-world resources; required for desired-vs-actual comparison
- Never commit
*.tfstate*β plaintext secrets, same risk as a hardcoded credential terraform state list/show/mv/rmβ safe ways to inspect and modify state- Never hand-edit the state file directly
- Drift β detected by comparing configuration, state, and a real-infrastructure refresh, three ways
Remote Backends & Team Collaboration
Terraform / Infrastructure as Code
Chapter 6 Β· Remote Backends & Team Collaboration
Chapter 5 flagged two problems with local state: secrets sitting in a file tempting to accidentally commit, and no shared record for a team. Remote backends solve both.
Why Local State Breaks With a Team
A second teammate cloning the repository has zero record of what the first person's apply already created β their local terraform.tfstate simply doesn't exist yet. Worse, if two people do somehow share a state file and both run apply at nearly the same time, each reads the same starting state, computes a plan against it, and both try to write an updated state back afterward β whichever write happens last silently overwrites the other, potentially losing track of resources the first apply just created.
Remote Backends
A backend block inside terraform {} moves state storage off local disk entirely, into a shared, access-controlled location every team member and CI runner reads from and writes to.
The S3 + DynamoDB Backend
The classic AWS pattern: an S3 bucket holds the state file itself (with encrypt = true for encryption at rest β the same principle dbsec1-5/crypto1-5/6 already covered), and a DynamoDB table provides locking. Every teammate and CI job reads/writes the exact same object in S3, so there's genuinely one shared source of truth instead of N different local copies.
State Locking
Before writing to state, Terraform first acquires a lock β a conditional write to the DynamoDB table that only succeeds if no lock currently exists. If someone else's apply already holds the lock, Terraform blocks with a clear error rather than racing to write state at the same time:
This is exactly what prevents the concurrent-write corruption described above β only one apply can ever be mid-flight against a given piece of state at a time.
Terraform Cloud / HCP Terraform
A managed alternative to hand-rolling S3+DynamoDB β built-in state storage, locking, a web UI, and run history, with no separate bucket or lock table to provision yourself. Genuinely the "batteries included" option, at the cost of depending on a hosted service rather than infrastructure the team fully owns.
Backend Migration
Switching from local state to a remote backend (or between two remote backends) isn't automatic β it requires an explicit migration:
After migrating, every teammate needs to re-run terraform init themselves β their local Terraform still points at the old backend configuration until they do.
sensitive flag. An encrypted S3 bucket with IAM access restricted to only the people/systems that genuinely need it is the real answer to that open question β protecting the storage location itself, since the file's contents can't be hidden from anyone who has already been granted read access.
terraform force-unlock <lock-id> exists for exactly this β but confirm no apply is genuinely still in progress first. Force-unlocking while a real apply is actively running risks the exact concurrent-write corruption locking exists to prevent.
Hands-On Exercises
Explain concretely what can go wrong if two team members run terraform apply at nearly the same time against shared local state with no locking mechanism.
In the S3 + DynamoDB backend pattern, explain DynamoDB's specific role, and what could still go wrong even with state safely stored remotely in S3 if DynamoDB locking weren't part of the setup.
π View solutionA CI job crashes mid-apply and leaves a stuck lock, blocking the team. Explain why terraform force-unlock should be used cautiously, and what should be verified before running it.
Chapter 6 Quick Reference
- Remote backend β moves state to a shared, access-controlled location; solves both the secrets-in-git risk and team collaboration
- S3 + DynamoDB β S3 stores state (encrypted at rest), DynamoDB provides locking
- Lock β only one
applycan hold it at a time, preventing concurrent state writes - Terraform Cloud/HCP β managed alternative with built-in storage, locking, and a web UI
terraform init -migrate-stateβ required for any backend change; every teammate must re-init afterwardterraform force-unlockβ last resort only, after confirming no apply is genuinely still running
Modules
Terraform / Infrastructure as Code
Chapter 7 Β· Modules
Everything so far has lived in a single, flat set of files. Real infrastructure repeats the same shapes β a web server, a VPC, a database tier β over and over, across environments and projects. Modules package a shape once and reuse it.
What a Module Is
Any directory of .tf files is a module. Whichever one terraform commands are run from is the root module β every configuration in this course so far has technically already been one, just never explicitly calling any others.
Child Modules
A module block calls another module from the root (or from another module), passing inputs as arguments matching that module's own declared variables, and reading its declared outputs back.
Module Structure & Composition
A module's own directory follows the same variables.tf/main.tf/outputs.tf convention as any configuration β inputs come in through its variable blocks, results go out through its output blocks. Everything else inside stays private to the module; the caller never sees its internal resource names.
The Public Terraform Registry
registry.terraform.io hosts community and vendor-maintained modules β a well-tested VPC module, for instance, covers subnet layout, route tables, NAT gateways, and dozens of edge cases a first attempt at writing one from scratch would likely miss.
Module Versioning
Registry modules use the same ~> version-constraint syntax as providers (Chapter 2) β pinning a module version protects against an upstream update introducing a breaking change without warning, exactly the same reproducibility guarantee the provider lock file already provides.
DRY Infrastructure
Module blocks accept for_each and count too β calling the same module multiple times with different inputs, rather than copy-pasting a resource shape once per environment.
Hands-On Exercises
Explain the difference between the root module and a child module, and explain in what sense every Terraform configuration in this course so far has technically already "been" a module.
π View solutionA team needs a VPC matching a very common, well-established pattern. Explain why using a vetted community module from the Terraform Registry is often preferable to writing the VPC resources from scratch, and what version pinning specifically protects against.
π View solutionA colleague wraps a single resource block, with no meaningful added logic, in its own module. Explain why this might be premature abstraction, and what tradeoff is actually being made.
π View solutionChapter 7 Quick Reference
- Root module β where commands are run from; child module β called via a
moduleblock - Every configuration is technically a module β the root module, specifically
- Registry β
registry.terraform.io, vetted community/vendor modules - Module
versionβ same~>syntax as providers, protects against breaking upstream changes for_each/counton amoduleblock β DRY, reused across environments- Don't modularize a single resource with no real logic behind it
Workspaces & Multi-Environment Patterns
Terraform / Infrastructure as Code
Chapter 8 Β· Workspaces & Multi-Environment Patterns
Chapter 4's .tfvars files and Chapter 7's for_each-over-a-module both hinted at multiple environments. This chapter names the real options directly, and the tradeoffs between them.
The Multi-Environment Problem
Dev, staging, and prod are almost always the same infrastructure shape with different values β smaller instance sizes in dev, different domain names, different scaling targets. The question is how to run one configuration against three genuinely separate sets of real infrastructure without copy-pasting the whole thing three times.
Terraform Workspaces
A workspace gives each named environment its own state file, within the same backend and configuration. terraform.workspace is available inside the configuration itself to vary behavior by environment.
Workspaces vs. Separate State Keys
An alternative to workspace commands entirely: give each environment its own backend key (Chapter 6's S3 key argument) via -backend-config, with no terraform workspace involved. Functionally similar β separate state per environment β but explicit at the backend-configuration level rather than an in-CLI selection that's easy to forget you've made.
Workspaces vs. Separate Directories
The strongest-isolation option: a genuinely separate directory (and root module) per environment β environments/dev/, environments/prod/ β each with its own main.tf calling the same shared modules from Chapter 7's registry pattern, but with entirely separate state, separate backend configuration, and often separate provider credentials.
| Approach | Isolation | Duplication |
|---|---|---|
| Workspaces | Separate state only β same config, backend, credentials | Lowest β one configuration |
| Separate state keys | Separate state, explicit backend config per environment | Low β one configuration, per-env backend files |
| Separate directories | Strongest β state, backend, and often credentials all separate | Highest β a root module per environment |
The Workspace-Isn't-a-Full-Isolation-Boundary Gotcha
Workspaces separate state β nothing else. The configuration, the backend, and the provider credentials are all shared across every workspace. Selecting the wrong workspace by mistake β intending dev but currently sitting in prod β still runs apply against real production infrastructure, using real production credentials, because nothing in the setup itself enforces which workspace "should" be safe to touch right now.
terraform workspace show before running apply is a cheap habit against exactly this mistake β easy to forget which workspace is currently selected, especially after switching between projects.
Hands-On Exercises
Explain precisely what terraform.workspace isolates, and what it does not isolate β and why that makes running apply in the wrong workspace still genuinely dangerous.
Compare workspaces against separate directories in terms of isolation strength and duplication cost. Which would a team managing a genuinely high-stakes production environment likely prefer, and why?
π View solutionGiven count = terraform.workspace == "prod" ? 3 : 1, a team creates a third workspace called staging. Explain exactly what count evaluates to while the staging workspace is selected, and why.
Chapter 8 Quick Reference
terraform workspace new/select/list/showβ manage and check the current workspaceterraform.workspaceβ the current workspace name, usable inside configuration- Workspaces isolate state only β configuration, backend, and credentials are shared
- Separate directories β strongest isolation, highest duplication, often preferred for prod
- Always check
terraform workspace showbeforeapply
Provisioners, Import & Handling Drift
Terraform / Infrastructure as Code
Chapter 9 Β· Provisioners, Import & Handling Drift
Every prior chapter assumed clean, fully-managed resources. Real infrastructure has messier edges β a one-time bootstrap step with no native equivalent, or a resource that already existed before Terraform ever touched it. This chapter covers the deliberate escape hatches for exactly those cases.
Provisioners β local-exec and remote-exec
A provisioner block runs a command as a side effect of a resource's creation β local-exec on the machine running Terraform itself, remote-exec on the resource that was just created, via SSH or WinRM.
Why Provisioners Are Discouraged
A provisioner's command is imperative code embedded inside a declarative resource β the exact split Chapter 1 opened with. Terraform has no visibility into what that script actually did, can't record its effect in state, and can't detect or reconcile drift on it the way it can for a real resource attribute. HashiCorp's own guidance is to treat provisioners as a last resort β and per Chapter 1's Terraform-vs-Ansible distinction, using remote-exec to configure a server is arguably doing Ansible's job from inside Terraform, the exact mixing of responsibilities Chapter 1's warn-box already cautioned against.
When Provisioners Are Still Justified
A genuinely one-time bootstrap action with no native resource or provider API equivalent, or triggering an external signal/webhook right after creation. Not a substitute for real configuration management β if the goal is installing and maintaining software on a server, that's still Ansible's job.
apply. Editing a provisioner's command doesn't re-run it against a resource that already exists; only creating a new one (or an explicit when = destroy provisioner, or a null_resource with triggers) causes it to run again.
Importing Existing Infrastructure
terraform import brings a resource created outside Terraform β by hand in a console, or by another tool β under Terraform's management, without destroying and recreating it.
import populates state β it does not write the matching resource block for you. The resource block still has to be written by hand first, as a best guess at the real object's actual configuration, or the next plan will show Terraform trying to "fix" every attribute it thinks is wrong.
The Import Workflow in Practice
- Write a
resourceblock matching the real object as closely as possible - Run
terraform import <address> <real-world-id> - Run
terraform planβ any remaining mismatch shows up as a planned change - Adjust the resource block, repeat step 3, until
planreports no changes
Newer Terraform versions (1.5+) support a declarative import block plus config generation, letting Terraform draft the resource block for you β a materially easier modern alternative to hand-writing it from scratch, though the underlying iterate-until-plan-shows-nothing workflow is unchanged.
plan. It's the only reliable signal that the hand-written resource block genuinely matches reality β the same "plan before you trust anything" habit from Chapter 1, applied here to a freshly-imported resource specifically.
Resolving cloud1-11's Own Gotcha
cloud1-11 warned that a manual console fix gets silently reverted by the next automated apply. Now the real fix: if a manual change genuinely needs to persist, either (a) update the .tf configuration itself to match the new reality, so the next plan shows no diff, or (b) if the resource was created entirely outside Terraform, bring it under management with import first. Re-running apply repeatedly and hoping the drift stops happening is never a real fix β Terraform will keep reverting toward whatever the configuration says, every single time, until the configuration itself changes.
Hands-On Exercises
Explain why embedding a local-exec/remote-exec provisioner breaks Terraform's own desired-state model from Chapter 1 β specifically, what can Terraform no longer track about the provisioner's effect?
A resource was created by hand in the AWS console before this project's Terraform configuration existed. Describe the two-step workflow needed to bring it under Terraform management safely, including the common surprise about what import alone does and doesn't do.
A manual console fix was applied during an incident to keep a service running. Explain the two legitimate options for stopping the automated apply from reverting it, and why "just don't run apply for a while" isn't a real fix.
π View solutionChapter 9 Quick Reference
- Provisioners β imperative escape hatch, discouraged, last-resort only
- Provisioners run once, on creation, by default β not on every apply
terraform importβ brings an existing resource under management without recreating it- Import only writes state β the resource block must still be written by hand (or via a config-generation import block on 1.5+)
- Always
planimmediately after an import to verify the resource block truly matches reality - Fixing drift for real: update configuration to match, or import β never just keep re-applying
Terraform in CI/CD & Testing
Terraform / Infrastructure as Code
Chapter 10 Β· Terraform in CI/CD & Testing
Every command so far has been run by hand. A real team runs Terraform through CI β this chapter ties the whole workflow together using pipelines1's own material, applied specifically to infrastructure changes.
terraform fmt & validate
fmt rewrites files into Terraform's canonical style; fmt -check exits non-zero without changing anything, suitable for a CI gate. validate catches syntax and type errors β without touching real infrastructure or needing full provider credentials, just an initialized backend.
The Plan-as-PR-Comment Pattern
A CI job runs terraform plan on every pull request and posts the output as a comment directly on that PR β reviewers see exactly what would change in real infrastructure before ever approving the merge, not just the raw .tf diff. A one-line variable change can produce a plan that recreates a dozen resources; the diff alone would never show that.
A Real GitHub Actions Workflow
Following pipelines1-3's workflow-YAML pattern and pipelines1-6's job-dependency gating:
Policy as Code
Automated rules that block a plan from ever being applied if it violates a defined policy β "no public S3 buckets," "no unencrypted volumes," enforced mechanically rather than relying on a reviewer noticing. Sentinel is HashiCorp's own policy engine (Terraform Cloud/Enterprise); OPA (via conftest) is the open-source alternative usable in any CI pipeline. Both directly enforce the security practices dbsec1/crypto1/owasp1 already covered β the difference is these rules run automatically on every plan, not just when a reviewer happens to remember to check.
terraform test & Terratest
Terraform's native testing framework (1.6+) uses .tftest.hcl files with run blocks and assert conditions, and can execute against mocked providers β fast, free tests with no real infrastructure spun up. Terratest (a third-party, Go-based library, older and still widely used) takes the opposite approach: it actually provisions real infrastructure to test against, then tears it down β slower and billable, but validates genuinely real provider behavior rather than a mock's approximation of it.
Bringing It Together β the Full CI/CD Gate
fmt/validate β test β plan + policy check β human review of the posted plan comment β merge β apply. The same test β build β staging β production chain pipelines1-9's own capstone assembled for application code, applied here to infrastructure changes instead.
plan output remains the single most valuable checkpoint β automation reliably catches format/policy/syntax violations, but "wait, that's not actually what I meant to change" is still a human judgment call.
main, after review, mirroring pipelines1-8's protected-environment gating. Worth stating honestly: this CI-driven model is push-based β the CI runner itself needs real deployment credentials, the same tradeoff k8s2-9 named for traditional CI/CD. Tools like Atlantis or Terraform Cloud's own run system move toward a more pull-based model, but the GitHub Actions pattern shown here is push-based by default.
Hands-On Exercises
Explain why terraform fmt -check and validate run before plan in a CI pipeline rather than after, and what each one catches that the other doesn't.
Explain the value of the plan-as-PR-comment pattern β what does it let a human reviewer see that reading the raw .tf file diff alone wouldn't show?
Contrast policy as code (Sentinel/OPA) enforcement against a manual code-review checklist for catching something like "no public S3 buckets." Why can automated enforcement catch cases a checklist-based review might miss?
π View solutionChapter 10 Quick Reference
terraform fmt -checkβ style;validateβ syntax/type errors, no real infra touched- Plan-as-PR-comment β shows the real infrastructure impact before merge, not just the file diff
- Policy as code β Sentinel (HashiCorp/Enterprise) or OPA/conftest (open), automated policy enforcement on every plan
terraform testβ native, mockable, fast; Terratest β real infrastructure, slower, more realistic- Apply only from the reviewed, merged branch β this CI model is push-based, so the runner holds real credentials
Capstone: Provisioning a Real Multi-Resource Environment
Terraform / Infrastructure as Code
Chapter 11 Β· Capstone β Provisioning a Real Multi-Resource Environment
Ten chapters, one piece at a time. This closing chapter combines every one of them into a single, realistic three-tier environment β a VPC, a compute tier, and a database β built and deployed the way a real team actually would.
The Target Environment
A VPC with public and private subnets, a web tier running in the public subnets, and a database in the private subnets β a genuinely common shape, deliberately chosen so every chapter's own technique has a real place to be applied.
Step 1 β Remote Backend & Locking
Configured first, before anything else exists: Chapter 6's S3+DynamoDB backend, so state is safely shared and locked from the very first apply onward β never starting on local state and migrating later.
Step 2 β The VPC via a Registry Module
Chapter 7: the VPC itself is a vetted community module, not hand-written β its subnet layout, route tables, and NAT gateways already cover edge cases a first attempt wouldn't.
Step 3 β Variables & Environment-Specific Values
Chapter 4: instance_count, an environment variable with validation, and a sensitive db_password β with Chapter 5's own honest caveat still true: sensitive hides CLI output, the encrypted backend from Step 1 is what actually protects the value at rest.
Step 4 β The Compute Tier With for_each
Chapter 3: web servers created with for_each over a stable set of names, referencing the VPC module's own subnet-ID outputs β the dependency graph resolving automatically, exactly as Chapter 3 first demonstrated with a data source.
Step 5 β The Database Tier With lifecycle Protection
Chapter 3's own Exercise 3 scenario, applied for real: the database gets prevent_destroy = true β a deliberate safeguard against exactly the accidental-destroy risk that exercise walked through.
Step 6 β Environments: Separate Directories, Not Workspaces
Chapter 8 concluded that workspaces alone aren't a strong enough boundary for a genuinely high-stakes production environment β this capstone deliberately follows that conclusion, using separate environments/dev/ and environments/prod/ directories, each with its own backend key and credentials, rather than terraform workspace select.
Step 7 β Handling a Pre-Existing Resource
The DNS record for this environment was created by hand, before this project existed. Chapter 9's two-step import workflow brings it under management: write the matching resource block, run terraform import, then iterate against plan until it reports no changes.
Step 8 β The CI/CD Gate
Chapter 10's full pipeline wraps the whole thing: fmt/validate β terraform test β plan posted as a PR comment, gated by a policy-as-code check β human review β merge β apply, running only from the reviewed main branch.
The Full Picture
| Step | Chapter |
|---|---|
| 1 | Ch.6 β Remote backend & locking |
| 2 | Ch.7 β VPC via a registry module |
| 3 | Ch.4 β Variables, validation, sensitive values |
| 4 | Ch.3 β for_each and the dependency graph |
| 5 | Ch.3 β lifecycle protection |
| 6 | Ch.8 β Environment isolation strategy |
| 7 | Ch.9 β Import |
| 8 | Ch.10 β CI/CD gate |
What's Still Out of Scope, Honestly
This capstone provisions infrastructure β it deliberately does not configure servers or install software, exactly the Terraform-vs-Ansible boundary Chapter 1 drew from the very first page; that's still a genuinely separate, still-outstanding piece of this site's own bucket list. Multi-region disaster recovery, cost optimization at real scale (cloud1-9/cloud2-7), and a dedicated secrets manager beyond an encrypted backend are all real next steps a production system would eventually need, named honestly rather than treated as solved here.
Closing the Course
From Chapter 1's configuration-drift problem to a real, CI-gated, multi-resource environment β every chapter in between solved one specific, genuine piece of that original problem. State, locking, modules, environments, import, and automated review together are what actually make infrastructure reproducible, reviewable, and safe to change β not any single command on its own.
Hands-On Exercises
Explain why Step 1 (the remote backend) had to be configured before any other resource in this capstone, rather than being added later once the environment already existed.
π View solutionExplain why this capstone chose separate directories over Terraform workspaces for dev/prod isolation, tying your answer back to the specific reasoning Chapter 8 gave.
π View solutionAcross the entire course, name the single idea that recurs most often, and explain in your own words why understanding it matters more than memorizing any individual command.
π View solutionChapter 11 Quick Reference β Course Complete
- Every capstone step is a direct, unmodified application of an earlier chapter's own technique
- Still out of scope: server configuration (Ansible's job), DR at scale, dedicated secrets management
- Terraform provisions infrastructure; it never configures what runs on top of it
- Course complete β Terraform / Infrastructure as Code, 11 chapters