DevOps · 06

Infrastructure as Code

Infrastructure as code turns a cloud console into reviewed text files. This page builds Terraform from the ground up: the declarative idea and the plan-apply loop, the HCL language, state and the S3 backend (including the current locking options), modules and environments, how the dependency graph and lifecycle rules behave, a real AWS example that was validated and tested, how to handle drift, imports and refactors, how to run it all safely in CI, and how Terraform compares with OpenTofu, CloudFormation and the CDK.

13
Chapters
1.17
Terraform tested
S3
Lockfile backend
OIDC
CI auth
01
Chapter One - Why

Why Infrastructure as Code, and Declarative vs Imperative

Clicking through a cloud console is fine for learning. It is a disaster for running a business. Nobody remembers which boxes were ticked, the staging environment slowly differs from production, rebuilding after a mistake means guesswork, and the only audit trail is "someone changed something on Tuesday". Infrastructure as Code (IaC) replaces the clicking with text files that describe the infrastructure, kept in Git and applied by a tool.

Without IaCWith IaC
State of the environment lives in people's heads and the consoleState is described in files, reviewable and diffable
Environments drift apart (staging is not like production)The same code creates every environment; differences are explicit variables
Rebuilding is a manual, error-prone projectRebuilding is "apply the code again"
No review of infrastructure changesPull request, review, plan output, approval
No historygit log and git blame answer who changed what and why
Disaster recovery is a hopeRecreate the environment from code (data excluded: back that up separately)
Declarative vs imperative

An imperative tool is a script of steps: "create a VPC, then create a subnet, then attach a gateway". Running it twice is your problem: it may fail, or create duplicates. A declarative tool is given the desired end state: "there is a VPC with this CIDR and two subnets". The tool compares that to what exists and works out the steps itself. Running it twice changes nothing the second time. That property is called idempotence.

Imperative (shell, SDK scripts)Declarative (Terraform, CloudFormation)
You describeThe stepsThe end state
Second runRepeats the steps (may fail or duplicate)No-op if nothing changed
Deleting somethingYou must write the delete stepRemove it from the code and the tool deletes it
Preview of changesNone unless you build itBuilt in (terraform plan)
Knows what it ownsNoYes, via recorded state
Good forOne-off tasks, glue, data migrationsLong-lived infrastructure
Key insight: An imperative script is a recipe ("add two eggs"). A declarative file is the photo of the finished dish. The cook (Terraform) looks at the kitchen, sees one egg already there, and adds only what is missing.

Terraform is HashiCorp's declarative IaC tool. You write configuration in HCL (HashiCorp Configuration Language). Terraform talks to clouds and services through providers (plugins), including AWS, Azure, Google Cloud, Kubernetes and many SaaS products. The same workflow then applies to all of them. Chapter 11 compares it with OpenTofu, CloudFormation and the AWS CDK.

Scope of this page

Examples use AWS because the rest of this guide does. Everything about the workflow, state, modules and CI applies to any provider. Page 05 covered pipelines in general; chapter 10 here applies them to Terraform.

02
Chapter Two - The Workflow

init, validate, plan, apply, destroy

Everything Terraform does fits in a short loop. Learn the loop first, then the language.

Terraform workflow: configuration + state + real world -> plan -> apply
Configuration *.tf files (desired state) State terraform.tfstate (what Terraform owns) terraform plan diff: desired vs known terraform apply reviewed plan Provider plugin (aws, azurerm, ...) Cloud API (AWS, Azure, ...) create/update/delete API calls refresh: read real state record results plan reads real resources through the provider (refresh), compares them with state and config, and prints what it WOULD do.
CommandWhat it doesChanges real infrastructure?
terraform initDownloads providers and modules, configures the backend, writes .terraform/ and the lock fileNo
terraform fmtRewrites files in the canonical style (-check fails if changes are needed)No
terraform validateChecks syntax and internal consistency (types, references) without contacting any APINo
terraform planComputes the difference between configuration, state and reality, and prints the actionsNo (but it reads, and locks the state)
terraform applyPlans (or takes a saved plan), asks for approval, executes, updates stateYes
terraform destroyPlans and executes deletion of everything in the stateYes
terraform outputPrints the root module's outputsNo
terraform state list/showInspects what is in the stateNo
Shell - the daily loop
$ terraform init $ terraform fmt -check $ terraform validate $ terraform plan -out=tfplan $ terraform apply tfplan

terraform plan -out=tfplan saves the plan, and terraform apply tfplan executes exactly that plan, with no second guess. This is the safe pattern for pipelines: the plan that was reviewed is the plan that runs. Running a bare terraform apply computes a new plan and asks for confirmation; -auto-approve skips the question.

How to read a plan
Plan output (abridged, illustrative)
Terraform will perform the following actions: # aws_s3_bucket.artifacts will be created + resource "aws_s3_bucket" "artifacts" { + bucket = "orders-dev-artifacts-111122223333" ... } # aws_iam_role.app will be updated in-place ~ resource "aws_iam_role" "app" { ~ description = "old" -> "new" } # aws_instance.web must be replaced -/+ resource "aws_instance" "web" { ~ ami = "ami-0aaa" -> "ami-0bbb" # forces replacement } Plan: 2 to add, 1 to change, 1 to destroy.
SymbolMeaning
+Create
-Destroy
~Update in place
-/+Destroy and recreate (replacement). Look for the "forces replacement" comment, and ask whether that is acceptable for this resource
  • The last line (Plan: X to add, Y to change, Z to destroy) is the first thing to read. A surprise "destroy" on a database is the reason plans exist.
  • -detailed-exitcode makes plan exit with code 2 when there are changes, which pipelines can use to detect drift.
  • -replace=ADDRESS forces recreation of one resource; it superseded the older terraform taint.
  • -refresh-only produces a plan that only updates the state to match reality (chapter 9).
⚠ Applying without reading the plan
  • The plan is the safety net, and it only works if a human reads it. A plan that says "1 to destroy" on a resource you did not touch means something else changed (a rename, a provider upgrade, drift). Understand it before approving.
03
Chapter Three - The Language

HCL: Blocks, Arguments, Expressions, Variables, Locals, Outputs and Data

HCL is a small configuration language. A file is a collection of blocks; a block has a type, zero or more labels and a body of arguments (name = value) and nested blocks. Terraform reads every *.tf file in a directory as one module, so how you split the files is only for humans (the convention is main.tf, variables.tf, outputs.tf, versions.tf).

BlockPurposeExample
terraformSettings: required Terraform version, required providers, backendrequired_version = ">= 1.10"
providerConfigures a provider (region, credentials, default tags)provider "aws" { region = var.region }
resourceA thing Terraform creates and managesresource "aws_vpc" "main" { ... }
dataReads something that already exists (read-only)data "aws_caller_identity" "current" {}
variableAn input to the modulevariable "environment" { type = string }
localsNamed intermediate values (private to the module)locals { name = "${var.project}-${var.environment}" }
outputA value the module exposes to its caller or to the CLIoutput "vpc_id" { value = aws_vpc.main.id }
moduleCalls another module (chapter 6)module "network" { source = "..." }

A resource address such as aws_s3_bucket.artifacts is TYPE.NAME; it identifies the resource in the configuration and in the state. Referencing another resource's attribute (aws_vpc.main.id) is how you wire things together, and it also tells Terraform the order (chapter 7).

Variables, validation and where values come from
HCL - variables.tf (validated on Terraform v1.17.1)
variable "project" { type = string default = "orders" } variable "environment" { type = string description = "Deployment environment." validation { condition = contains(["dev", "stg", "prod"], var.environment) error_message = "environment must be one of: dev, stg, prod." } } variable "region" { type = string default = "eu-west-1" } variable "vpc_cidr" { type = string default = "10.20.0.0/16" validation { condition = can(cidrhost(var.vpc_cidr, 0)) error_message = "vpc_cidr must be a valid CIDR block, for example 10.20.0.0/16." } } variable "az_count" { type = number default = 2 }
  • type constrains the value (string, number, bool, list(string), map(string), object({...})).
  • validation rejects bad input at plan time with your own message. Since Terraform 1.9 a validation can refer to other variables and data sources, not just the variable itself.
  • sensitive = true hides the value in CLI output. It does not keep it out of the state (chapter 5).
  • default makes a variable optional; without it Terraform asks for a value.

Values arrive from several sources. If the same variable is set in several places, the last of these wins (lowest to highest precedence): the default, environment variables named TF_VAR_name, terraform.tfvars, terraform.tfvars.json, *.auto.tfvars and *.auto.tfvars.json in lexical order, and finally -var / -var-file on the command line (in the order given). Order checked against the Terraform variables documentation.

Shell - supplying values
$ export TF_VAR_environment=dev $ terraform plan -var="vpc_cidr=10.30.0.0/16" $ terraform plan -var-file=envs/prod.tfvars
Locals, conditionals and merge

locals give names to expressions so you do not repeat them. The conditional expression is condition ? a : b, and merge() combines maps. A common idiom is count = condition ? 1 : 0 to create a resource only in some environments.

HCL - locals, a conditional, merge and a conditional resource
locals { is_prod = var.environment == "prod" instance_size = local.is_prod ? "m6i.large" : "t3.small" common_tags = { project = var.project environment = var.environment } bucket_tags = merge(local.common_tags, { purpose = "artifacts" }) } resource "aws_cloudwatch_log_group" "app" { count = local.is_prod ? 1 : 0 name = "/orders/${var.environment}" retention_in_days = 30 }
Expressions and functions you will use all the time
FeatureExampleNotes
String interpolation"${var.project}-${var.environment}"Plain references need no interpolation: bucket = aws_s3_bucket.x.id
for expression[for s in aws_subnet.private : s.id]Builds a list; { for k, v in m : k => v } builds a map
Splataws_subnet.list[*].idShorthand for a simple for over a list
cidrsubnet(prefix, newbits, num)cidrsubnet("10.20.0.0/16", 8, 1) is 10.20.1.0/24Carve subnets out of a VPC range
slice, length, contains, toset, merge, lookup, try, cancontains(["dev","stg"], var.env)Collection helpers; can() is handy in validations
jsonencode / file / templatefilepolicy = jsonencode({...})Build JSON (IAM policies) from HCL objects rather than hand-written strings
Try expressions interactively

terraform console opens a prompt where you can type cidrsubnet("10.20.0.0/16", 8, 1) or [for n in ["a","b"] : upper(n)] and see the result. It is the quickest way to learn the functions.

Data sources

A data block reads something that exists outside this configuration: an AMI, the current account id, the list of availability zones, a VPC created by another team. Nothing is created or destroyed. A data source whose arguments depend on resources that do not exist yet is read during apply, not plan.

HCL - two data sources used throughout the example in chapter 8
data "aws_availability_zones" "available" { state = "available" } data "aws_caller_identity" "current" {}
04
Chapter Four - Repetition

count vs for_each, dynamic Blocks, Preconditions and Checks

Infrastructure repeats: three queues, one subnet per availability zone, a rule per port. Terraform has two meta-arguments to create several instances of a resource, and the choice between them matters a lot.

HCL - the same three queues, two ways (validated on Terraform v1.17.1)
# count: instances identified by position. Removing "b" renumbers "c". resource "aws_sqs_queue" "by_count" { count = length(var.queue_names) name = var.queue_names[count.index] } # for_each: instances identified by key. Removing "b" touches only "b". resource "aws_sqs_queue" "by_key" { for_each = toset(var.queue_names) name = each.key }
countfor_each
TakesA numberA map or a set of strings
Instance identityPosition: aws_sqs_queue.by_count[0]Key: aws_sqs_queue.by_key["a"]
Remove the middle itemEverything after it shifts index, so Terraform wants to destroy and recreate or rename thoseOnly that key is removed
Instances differ in settingsAwkward (index lookups)Natural: each.key, each.value
Good forOn/off switches (count = var.enabled ? 1 : 0) and truly identical copiesAlmost everything else
Limitation-Keys must be known at plan time (not derived from a resource that is not created yet)
⚠ Using count with a list that gets edited
  • With count, instance 2 is "the third item of the list". Delete the second item and the third becomes instance 1, so Terraform plans to change or destroy a resource you never touched. Prefer for_each keyed on a stable name. Keep count for the 0-or-1 switch.
dynamic blocks

Some resources have repeatable nested blocks (security-group ingress, IAM statement, an S3 lifecycle rule). You cannot put for_each on a nested block, so use a dynamic block: it generates one nested block per element.

HCL - a security group whose ingress rules come from a list
variable "ingress_ports" { type = list(number) default = [80, 443] } resource "aws_security_group" "web" { name = "web" vpc_id = aws_vpc.main.id dynamic "ingress" { for_each = var.ingress_ports content { from_port = ingress.value to_port = ingress.value protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } } }

Use dynamic sparingly. It is harder to read than plain repeated blocks. If you have two fixed ports, write two ingress blocks.

Preconditions and checks

validation guards inputs. For assumptions about resources, a lifecycle precondition or postcondition stops the run with a clear message, and a top-level **check block runs assertions after plan/apply and reports a warning** without blocking (useful for "the bucket still has versioning on"). Both forms were validated for this page.

HCL - a variable marked sensitive, a precondition and a check
variable "db_password" { type = string sensitive = true default = "example-only" } resource "aws_subnet" "checked" { vpc_id = aws_vpc.main.id cidr_block = "10.20.200.0/24" lifecycle { precondition { condition = aws_vpc.main.enable_dns_support error_message = "The VPC must have DNS support enabled." } } } check "bucket_versioning_on" { assert { condition = aws_s3_bucket_versioning.artifacts.versioning_configuration[0].status == "Enabled" error_message = "Artifact bucket versioning must stay enabled." } }
05
Chapter Five - State

Providers, the Lock File, State and Remote Backends

Providers and the lock file

Terraform core knows nothing about AWS. A provider is a plugin that translates resources into API calls. You declare which providers you need (and acceptable versions) in required_providers; terraform init downloads them from a registry.

  • version = "~> 6.0" means "6.x, but not 7". It allows minor and patch updates and blocks a major release that may break your code.
  • terraform init records the exact version selected, and checksums of the packages, in **.terraform.lock.hcl. Commit this file.** It makes every machine and pipeline use the same provider build, and changes to it show up in code review.
  • terraform init -upgrade deliberately moves to the newest version allowed by your constraints and rewrites the lock file.
  • Provider upgrades can change plans. Upgrade in its own pull request, read the plan, and watch the provider's changelog.
Shell - lock the provider hashes for the platforms your team and CI use
$ terraform providers lock -platform=linux_amd64 -platform=darwin_arm64
What state is

Terraform needs to know which real objects belong to which resource blocks. That mapping is the state: a JSON document recording, for every managed resource, its address, its provider and all its attributes as last seen. When you rename a resource in code, Terraform cannot tell it is "the same" unless you tell it (chapter 9); when you delete a block, state tells Terraform what to delete.

  • State is how Terraform maps aws_s3_bucket.artifacts to the actual bucket, caches attributes, and records dependencies.
  • By default state is a local file terraform.tfstate. That works for one person and breaks for teams: no sharing, no locking, easy to lose.
  • State contains secrets. Terraform documentation is explicit: state is stored as plaintext and includes any secret values you defined in the configuration (database passwords, generated keys, tokens). sensitive = true only hides values in CLI output. Treat the state, and saved plan files, as sensitive data: keep them out of Git, encrypt them, restrict access.
  • Do not edit state by hand. Use terraform state list, show, mv, rm and the config-driven blocks moved and import instead.
Remote backend on S3 with locking

A backend decides where state is stored. For AWS teams the common choice is the s3 backend. It stores one object per state, and supports state locking so two runs cannot modify the same state concurrently.

Remote state in S3: shared, versioned, locked
Engineer terraform apply CI pipeline terraform apply Second person or second job S3 bucket (versioning on, encrypted, private) orders/dev/terraform.tfstate the state object (every version kept) orders/dev/terraform.tfstate.tflock lock object, exists only while a run holds the lock Created with a conditional write: if it already exists, the second writer fails: "Error acquiring the state lock". The old way used a DynamoDB table for the lock. Whoever can read the bucket can read every secret. lock, read, write blocked while locked
HCL - backend.tf (syntax validated with terraform init -backend=false; not connected to a real bucket)
terraform { backend "s3" { bucket = "acme-terraform-state" key = "orders/dev/terraform.tfstate" region = "eu-west-1" encrypt = true use_lockfile = true } }
Locking optionMechanismStatus
**use_lockfile = true**Terraform writes a lock object next to the state (<key>.tflock) in the same S3 bucket, using S3 conditional writes. No extra service neededExperimental in Terraform 1.10, generally available in 1.11 (changelog)
**dynamodb_table**A DynamoDB table with a LockID key holds the lockDeprecated since 1.11. Still works; both can be enabled at once during migration
  • Terraform documentation for the S3 backend recommends enabling bucket versioning, so you can recover a previous state after an accidental deletion or a bad apply.
  • Set encrypt = true (and optionally a KMS key), block public access, and give state access only to the roles that run Terraform. With use_lockfile, the role also needs s3:GetObject, s3:PutObject and s3:DeleteObject on the .tflock object (S3 backend docs).
  • The bucket and its settings are infrastructure too, but chicken-and-egg: create the state bucket once with a small bootstrap configuration (local state, or by hand) and then never manage it from the stacks that use it.
  • If a run crashes and leaves a stale lock, terraform force-unlock LOCK_ID releases it. Do it only after you are sure no run is active.
  • Changing backend settings later: terraform init -migrate-state copies existing state to the new location; -reconfigure ignores the saved configuration without migrating.
Which Terraform version to require

use_lockfile needs Terraform 1.10 or newer, and 1.11 or newer for the generally-available behaviour, so the example sets required_version = ">= 1.10". If you must support older versions, use dynamodb_table. New setups should choose the lockfile approach, because the DynamoDB arguments are deprecated.

⚠ Committing state or a plan file
  • Anything that is in terraform.tfstate can contain passwords and keys, and a committed file is in history forever. Add .terraform/, *.tfstate, *.tfstate.* and *.tfplan to .gitignore, use a remote backend, and prefer designs where secrets are not created by Terraform at all (read them from a secrets manager at runtime). Terraform 1.10 and later also offer ephemeral values and write-only arguments for keeping some values out of state; see the HashiCorp documentation on sensitive data.
06
Chapter Six - Structure

Modules and Environments: Workspaces vs Separate State

Modules

A module is a directory of .tf files. Every configuration is already one (the root module). A child module is one you call from another with a module block, passing inputs and reading outputs. Modules are Terraform's unit of reuse: one "network" module used by every environment, instead of copied code.

PartConventionWhy
variables.tfThe module's inputs, with types, descriptions and validationThe module's public API
outputs.tfValues callers need (ids, ARNs)The only way to read from inside a module
main.tf (and others)ResourcesSplit by concern once it grows
versions.tfrequired_version and required_providers (without provider blocks)Provider configuration belongs to the root module, not to reusable modules
README / examplesHow to call itModules are products with users
HCL - calling a registry module with a version constraint (validated with terraform init and validate)
module "network" { source = "terraform-aws-modules/vpc/aws" version = "~> 6.0" name = "orders-dev" cidr = "10.20.0.0/16" }
  • Sources: the public Terraform Registry (namespace/name/provider), a Git repository (git::https://...?ref=v1.2.0), or a local path (./modules/network).
  • Always version modules from a registry or Git (version = "~> 6.0", or ?ref= a tag). An unpinned module means the next init -upgrade can change your infrastructure.
  • Read third-party modules before you adopt them: a popular community module is convenient, but it is also a large amount of code that you now depend on.
  • Keep modules small and focused (network, database, service), pass what varies as variables, and avoid modules that wrap a single resource without adding anything.
  • Treat moving a resource into or out of a module as a refactor that changes its address: use moved blocks (chapter 9).
Environments: workspaces or separate state?
Environments: separate state per environment vs workspaces
Separate directories / state keys (recommended default) envs/dev module "app" { ... } dev.tfvars state: orders/dev/... envs/stg module "app" { ... } stg.tfvars state: orders/stg/... envs/prod module "app" { ... } prod.tfvars state: orders/prod/... Each environment: own state, own role, own pipeline permissions. A mistake in dev cannot touch prod state. Workspaces: one configuration, several states selected by name one configuration directory default dev prod Same code, same backend and credentials, state chosen by terraform.workspace. Easy to run against the wrong one.

The same configuration usually has to exist as dev, staging and production. Terraform offers workspaces: several named states for one configuration and backend. With the S3 backend the default workspace keeps the state at the configured key, and other workspaces are stored under <workspace_key_prefix>/<workspace>/<key> (the default prefix is env:).

Separate directories / state keys per environmentWorkspaces
IsolationStrong: own state, own backend key, own credentials and rolesWeak: same backend and credentials, only the state name changes
Risk of applying to the wrong environmentLow: you are in a directory called prodHigher: depends on the selected workspace (terraform workspace show)
Different configuration per environmentEasy (different files, modules and variables)Awkward: conditionals on terraform.workspace everywhere
Code duplicationSmall if environments call shared modulesNone
Good forDev / staging / prod with different accounts or approvalsTemporary copies of the same stack: feature branches, test runs
The default most teams settle on

A small root configuration per environment (often one AWS account each) that calls shared modules with environment-specific variables, and its own state key and its own pipeline role. Use workspaces for short-lived copies, not as the boundary between production and everything else. (The HashiCorp documentation likewise says workspaces alone are not a good fit for strong isolation of deployment environments with different credentials.)

07
Chapter Seven - Ordering

The Dependency Graph and the lifecycle Block

You never write "create the VPC first". When a resource refers to another (vpc_id = aws_vpc.main.id), Terraform records an implicit dependency and builds a directed acyclic graph of all resources. It walks the graph, creating independent resources in parallel, and deletes in reverse order.

Dependency graph: references decide order and parallelism
aws_vpc.main aws_subnet.private["a"] aws_subnet.private["b"] aws_security_group.web aws_launch _template.app aws_s3_bucket .artifacts aws_iam_role _policy.app aws_iam_role .app Nodes with no path between them (the two subnets, or the bucket and the VPC) are created in parallel. Destroy walks the same graph in reverse: policy first, then role and bucket, and the VPC last.
  • An implicit dependency comes from a reference. This is almost always what you want.
  • depends_on = [aws_iam_role_policy.x] declares an explicit dependency when there is no reference but there is a real ordering need (for example a permission that must exist before a service starts). Use sparingly; it also makes plans more conservative.
  • terraform graph prints the graph in DOT format for Graphviz.
  • Parallelism is limited by -parallelism (default 10 concurrent operations); lower it if an API rate-limits you.
  • Cycles (A needs B, B needs A) are an error. Break them by splitting a resource (for example a security group and separate rule resources).
The lifecycle block

lifecycle changes how Terraform treats a resource when it plans changes. It is a nested block valid on every resource.

ArgumentEffectTypical useRisk
prevent_destroy = trueAny plan that would destroy this resource fails with an errorDatabases, state buckets, anything that holds dataIt blocks terraform destroy for the whole stack too. It does not protect against someone removing the block, or against destruction outside Terraform
create_before_destroy = trueWhen replacement is needed, create the new one first and delete the old one afterwardsThings that must not have downtime when replaced (launch templates, certificates, some IAM)Names must not collide (use name_prefix or random suffixes); some resources cannot coexist
ignore_changes = [attr]Ignore changes to the listed attributes made outside TerraformAttributes owned by something else (an autoscaler changing desired capacity, a pipeline changing an AMI id)Hides real drift for that attribute; document why
replace_triggered_by = [...]Force replacement when another resource or attribute changesRoll a resource when a dependency changesReplacement can cause downtime
precondition / postconditionAssertions that stop the run with your message (chapter 4)Document and enforce assumptions-
HCL - guard a data-holding bucket; replace a launch template safely (validated)
resource "aws_s3_bucket" "ledger" { bucket = "orders-ledger-example" lifecycle { prevent_destroy = true } } resource "aws_launch_template" "app" { name_prefix = "app-" image_id = "ami-0123456789abcdef0" instance_type = "t3.micro" lifecycle { create_before_destroy = true ignore_changes = [image_id] } }

The bucket cannot be destroyed by a plan while prevent_destroy is set. The launch template is created before the old one is removed, and Terraform ignores a changed image_id, because in this scenario another process rotates AMIs.

⚠ prevent_destroy as the only protection
  • prevent_destroy is a guard rail in the plan, not a lock on the resource. Combine it with IAM permissions that do not allow deletion, backups, deletion protection on the resource itself (for RDS, deletion_protection), and review of plans.
08
Chapter Eight - Hands-On

A Real AWS Example: VPC, S3 Bucket and IAM Role

This example creates a VPC with one private subnet per availability zone, a versioned private S3 bucket and an IAM role that an ECS task could use to read and write that bucket. It is deliberately small and was validated for this page: terraform init, terraform validate and terraform fmt -check all succeeded with Terraform v1.17.1 and AWS provider 6.71.0 (the version init selected for the ~> 6.0 constraint on 2026-10-31). It was not applied to a real AWS account, so no plan output is shown as real.

HCL - versions.tf
terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } provider "aws" { region = var.region default_tags { tags = { project = var.project environment = var.environment managed_by = "terraform" } } }

default_tags in the provider block puts the same tags on every taggable resource, which keeps cost allocation and ownership consistent without repeating tags everywhere. variables.tf is the file shown in chapter 3.

HCL - main.tf
data "aws_availability_zones" "available" { state = "available" } data "aws_caller_identity" "current" {} locals { name = "${var.project}-${var.environment}" azs = slice(data.aws_availability_zones.available.names, 0, var.az_count) } # ---------- network ---------- resource "aws_vpc" "main" { cidr_block = var.vpc_cidr enable_dns_support = true enable_dns_hostnames = true tags = { Name = local.name } } resource "aws_subnet" "private" { for_each = { for i, az in local.azs : az => i } vpc_id = aws_vpc.main.id availability_zone = each.key cidr_block = cidrsubnet(var.vpc_cidr, 8, each.value) tags = { Name = "${local.name}-private-${each.key}" } } # ---------- storage ---------- resource "aws_s3_bucket" "artifacts" { bucket = "${local.name}-artifacts-${data.aws_caller_identity.current.account_id}" } resource "aws_s3_bucket_versioning" "artifacts" { bucket = aws_s3_bucket.artifacts.id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_public_access_block" "artifacts" { bucket = aws_s3_bucket.artifacts.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } # ---------- identity ---------- data "aws_iam_policy_document" "assume_ecs_task" { statement { actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["ecs-tasks.amazonaws.com"] } } } resource "aws_iam_role" "app" { name = "${local.name}-app" assume_role_policy = data.aws_iam_policy_document.assume_ecs_task.json } data "aws_iam_policy_document" "app_bucket_access" { statement { actions = ["s3:GetObject", "s3:PutObject"] resources = ["${aws_s3_bucket.artifacts.arn}/*"] } statement { actions = ["s3:ListBucket"] resources = [aws_s3_bucket.artifacts.arn] } } resource "aws_iam_role_policy" "app_bucket_access" { name = "bucket-access" role = aws_iam_role.app.id policy = data.aws_iam_policy_document.app_bucket_access.json }
  • **for_each over a map built from a for expression** gives each subnet a stable key (the availability zone name). cidrsubnet(var.vpc_cidr, 8, index) carves a /24 per zone out of the /16.
  • Account id in the bucket name makes the globally-unique S3 name predictable and avoids collisions.
  • S3 settings are separate resources (aws_s3_bucket_versioning, aws_s3_bucket_public_access_block): the provider moved them out of the bucket resource, so each setting has its own address and lifecycle.
  • **aws_iam_policy_document data sources** build IAM JSON from HCL, which gives syntax checking and references (the bucket ARN) instead of hand-assembled strings.
  • The role has a least-privilege inline policy on exactly one bucket. Wildcard * actions or resources are what IaC security scanners flag first.
HCL - outputs.tf
output "vpc_id" { value = aws_vpc.main.id } output "private_subnet_ids" { value = [for s in aws_subnet.private : s.id] } output "artifact_bucket" { value = aws_s3_bucket.artifacts.bucket } output "app_role_arn" { value = aws_iam_role.app.arn }
Shell - run it (needs AWS credentials; plan output is not reproduced here)
$ terraform init $ terraform validate Success! The configuration is valid. $ terraform plan -var="environment=dev" -out=tfplan $ terraform apply tfplan $ terraform output $ terraform destroy -var="environment=dev"

The Success! line is real output from the validation run described above; the commands that need AWS are shown without output on purpose.

Testing it: terraform test

Terraform 1.6 made terraform test generally available: test files end in .tftest.hcl, live next to the configuration or in a tests/ directory, and contain run blocks that execute plan or apply and then check assert conditions. Terraform 1.7 added mock providers, so a test can run with no cloud account at all. The test below mocks the AWS provider and checks two things.

HCL - tests/naming.tftest.hcl
mock_provider "aws" { mock_data "aws_availability_zones" { defaults = { names = ["eu-west-1a", "eu-west-1b", "eu-west-1c"] } } mock_data "aws_iam_policy_document" { defaults = { json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" } } } variables { environment = "dev" } run "creates_one_subnet_per_az" { command = plan assert { condition = length(aws_subnet.private) == 2 error_message = "expected two private subnets" } } run "rejects_bad_environment" { command = plan variables { environment = "qa" } expect_failures = [var.environment] }
Shell - real output of the test run (Terraform v1.17.1, abridged to the result lines)
$ terraform test tests/naming.tftest.hcl... in progress run "creates_one_subnet_per_az"... pass run "rejects_bad_environment"... pass tests/naming.tftest.hcl... tearing down tests/naming.tftest.hcl... pass Success! 2 passed, 0 failed.
  • command = plan evaluates the plan only, so the tests are fast and need no credentials when the provider is mocked.
  • expect_failures = [var.environment] asserts that the validation rule rejects a bad value. A test that proves a guard rail works is valuable.
  • command = apply (the default) would create real resources and destroy them at the end: use it for integration-style tests in a sandbox account.
  • Static checks complement tests: **terraform validate (syntax and types), TFLint (a pluggable Terraform linter: provider-specific mistakes such as invalid instance types), and Checkov** (static analysis of IaC for security misconfiguration, such as public buckets or unencrypted storage). Chapter 10 puts them in CI.
⚠ Hard-coding what should be a variable or data source
  • Account ids, AMI ids, availability zone names and ARNs pasted into the code break the moment you create a second environment or region. Use variables, data sources (aws_caller_identity, aws_availability_zones) and references.
09
Chapter Nine - Change

Drift, Import, moved Blocks and Refactoring Safely

Drift

Drift is any difference between what Terraform believes exists and what actually exists, caused by changes made outside Terraform: someone edits a security group in the console, an autoscaler changes a count, another tool tags resources. Every terraform plan first refreshes (reads current values through the provider) and then compares, so ordinary plans already reveal drift as proposed changes.

Drift: configuration, state and reality can disagree
Configuration (what you want) State (what Terraform recorded) Reality (what exists in AWS) plan: config vs state refresh reads reality apply changes reality Drift = reality differs from state because someone changed it outside Terraform (console, CLI, another tool). plan notices it during refresh and proposes to revert it, or (with -refresh-only) to accept it into the state.
SituationWhat you wantHow
Console change that should be undoneReality back to the codeterraform apply (the plan reverts it)
Console change that should be keptCode and state updatedEdit the configuration to match, then plan until clean
See drift without proposing configuration changesJust detectterraform plan -refresh-only shows only differences between state and reality; apply -refresh-only accepts them into the state
Scheduled detectionAlert when the environment driftsA nightly CI job running terraform plan -detailed-exitcode (exit code 2 means changes are pending)
Attribute owned by something elseStop fighting itlifecycle { ignore_changes = [...] } (chapter 7)

The cultural fix matters more than the tooling: if anyone can change production in the console, drift is a certainty. Give people read access, give pipelines write access, and keep a break-glass role that triggers a follow-up to reconcile the code.

Adopting existing resources: import

Resources created by hand before IaC can be imported: you write the matching resource block and tell Terraform which real object it represents. Since Terraform 1.5 you can do this declaratively with an import block, which goes through the normal plan and review flow; the older terraform import command still exists but changes state immediately and is not reviewable.

HCL - an import block with the resource it adopts (validated)
# Refactor: the resource address was renamed in code from artifacts_old to artifacts. moved { from = aws_s3_bucket.artifacts_old to = aws_s3_bucket.artifacts } # Adopt a bucket that already exists in AWS (Terraform 1.5+). import { to = aws_s3_bucket.legacy id = "legacy-reports-bucket" } resource "aws_s3_bucket" "legacy" { bucket = "legacy-reports-bucket" }
  • Run terraform plan: it shows "1 to import". Differences between your block and the real object show as changes; edit the block until the plan shows no changes (or only intended ones), then apply.
  • terraform plan -generate-config-out=generated.tf (experimental in Terraform 1.17.1's plan -help) can draft the resource block for you. Review and clean the result.
  • Import brings one object into state. It does not discover dependencies or write a perfect configuration; large estates need patience and tools.
Refactoring safely with moved blocks

Terraform identifies a resource by its address. If you rename aws_s3_bucket.artifacts_old to aws_s3_bucket.artifacts in the code, Terraform sees "delete the old one, create a new one", which for a bucket or database is a catastrophe. A **moved block (Terraform 1.1+) records the rename in code, so the plan shows a move** instead of a destroy and create. The moved block in the file above does exactly that.

RefactorHow to keep the real object
Rename a resourcemoved { from = aws_x.old to = aws_x.new }
Move a resource into a modulemoved { from = aws_x.a to = module.app.aws_x.a }
Change count to for_eachmoved from aws_x.a[0] to aws_x.a["name"]
Stop managing a resource without deleting itA removed block with destroy = false (Terraform 1.7+), or terraform state rm (immediate, not reviewable)
Split one big state in twoterraform state mv / import into the new state, in a planned, reviewed migration
How to refactor with confidence

Make the refactor in its own pull request with no functional changes. A good plan says "Plan: 0 to add, 0 to change, 0 to destroy" plus the moves. If it shows any destroy, stop and fix the moved blocks before applying. Keep moved blocks in the code for a while so that every environment migrates, not only the first one that applied.

⚠ Renaming a resource without a moved block
  • The plan will propose destroying the old resource and creating a new one with the new name. For stateless resources that is noise; for a database or bucket it is data loss. Always check that a rename produces a "has moved" line and no destroy.
10
Chapter Ten - Automation

Running Terraform in CI: Plan on Pull Request, Apply on Merge

Applying from laptops means the state of production depends on who ran what last, with whose credentials. The standard answer is a pipeline (page 05) that holds the credentials and is the only thing allowed to apply.

EventPipeline doesWhy
Pull request touching infra/fmt -check, validate, lint and security scan, plan, and post the plan to the pull requestReviewers see exactly what will change before merging
Merge to maininit, then apply (ideally the saved plan, or a re-plan with a required approval for production)The reviewed change is applied by an audited system
Schedule (nightly)plan -detailed-exitcode and alert on exit code 2Detect drift
YAML - GitHub Actions: plan on pull request, apply on merge, credentials via OIDC
name: terraform on: pull_request: paths: ['infra/**'] push: branches: [main] paths: ['infra/**'] permissions: contents: read id-token: write jobs: plan: if: github.event_name == 'pull_request' runs-on: ubuntu-latest defaults: run: working-directory: infra steps: - uses: actions/checkout@v6 - uses: hashicorp/setup-terraform@v4 - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::111122223333:role/terraform-plan aws-region: eu-west-1 - run: terraform init -input=false - run: terraform fmt -check - run: terraform validate - run: terraform plan -input=false -out=tfplan apply: if: github.event_name == 'push' runs-on: ubuntu-latest environment: production defaults: run: working-directory: infra steps: - uses: actions/checkout@v6 - uses: hashicorp/setup-terraform@v4 - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::111122223333:role/terraform-apply aws-region: eu-west-1 - run: terraform init -input=false - run: terraform apply -input=false -auto-approve

The file parses as YAML and the action versions exist (hashicorp/setup-terraform@v4, aws-actions/configure-aws-credentials@v6, actions/checkout@v6, checked via the GitHub API on 2026-10-31), but the workflow was not run against a real repository. In a real pipeline pin the actions to commit SHAs (page 05).

  • OIDC, not stored keys. permissions: id-token: write lets the job request a token, and configure-aws-credentials exchanges it for short-lived credentials (page 05, chapter 06). Use two roles: a read-mostly role for pull-request plans (trusting pull request subjects) and a write role for apply that trusts only main or a protected GitHub environment.
  • **The apply job uses an environment**, so a required reviewer can gate production, as in any delivery pipeline.
  • Plan and apply must use the same state and the same code. Saving the plan (-out) and applying that file, or re-planning inside the apply job with a human approval, avoids "the plan I reviewed is not what ran".
  • One run per state at a time: state locking protects the file, and a CI concurrency group stops two pipelines racing and failing on the lock. Use -lock-timeout=5m so a run waits briefly instead of failing instantly.
  • Non-interactive flags: -input=false makes a missing variable an error instead of a hang.
  • Plan files are sensitive. A saved plan can contain secret values; store it as a short-lived, restricted artifact, never publicly.
Static checks in the same pipeline
ToolWhat it checksNotes
terraform fmt -checkFormattingZero-config, fast
terraform validateSyntax, references, typesNeeds init; no cloud access
TFLintA pluggable Terraform linter: provider-aware mistakes, deprecated syntax, naming rulesRule sets (for example for AWS) are plugins you enable
CheckovStatic analysis of Terraform (and other IaC) for security and compliance misconfiguration, also able to scan plan outputMaintained by Prisma Cloud
terraform testYour own assertions about the configurationChapter 8

Run these before plan, because they fail in seconds and need no credentials, and let security findings block the merge request the same way a failing unit test does.

Policy and cost control
  • Policy as code (for example Open Policy Agent / Conftest checks against terraform show -json tfplan, or Sentinel in HashiCorp's commercial offerings) can reject plans that violate rules, such as public buckets or disallowed regions.
  • Cost estimation tools can read the plan and show the monthly cost change on the pull request.
  • Protect production state: restrict who can read it, enable bucket versioning and keep cross-account access to the minimum.
11
Chapter Eleven - Choosing

Terraform vs OpenTofu vs CloudFormation vs CDK, and Common Mistakes

Terraform, OpenTofu, CloudFormation and the AWS CDK
TerraformOpenTofuCloudFormationAWS CDK
What it isHashiCorp's IaC tool; HCL; many providersA fork of Terraform; same HCL and provider modelAWS's native IaC service; JSON/YAML templatesWrite infrastructure in a programming language (TypeScript, Java, Python, ...)
CloudsAny with a provider (AWS, Azure, GCP, SaaS)Same, using the provider ecosystemAWS onlyAWS (it generates CloudFormation)
StateYou manage it (remote backend)You manage it (remote backend)AWS manages it (stack state)CloudFormation stack state
PreviewplanplanChange setscdk diff (then CloudFormation change set)
Rollback on failureNo automatic rollback: a failed apply leaves a partial result, fix and re-applySameStacks roll back automatically on a failed create or updateSame as CloudFormation
Drift detectionplan / -refresh-onlySameBuilt-in drift detection featureVia CloudFormation
StrengthMulti-cloud, huge ecosystem, readableOpen-source governance, Terraform-compatible workflowNo state to host, deep AWS integrationLoops, types and tests in a real language
Cost to watchLicense terms (below)Different feature roadmap over timeVerbose templates, AWS-onlyTwo layers (code and generated template)

License facts, as verified on 2026-10-31. Terraform versions up to 1.5.x are under the Mozilla Public License 2.0 (the LICENSE file of the v1.5 tag says so). The Terraform repository's current LICENSE is the Business Source License, with the Licensed Work stated as "Terraform Version 1.6.0 or later", and with IBM named as licensor. Terraform 1.6.0 was released in October 2023. OpenTofu began as a fork in response to the license change and is described in its own FAQ as given to the Linux Foundation; its repository carries the MPL 2.0 license. The BSL is a source-available license that restricts some commercial uses (read the license text, and ask your legal team, before assuming it affects or does not affect your use). OpenTofu's current release as of this check was 1.13.2.

Practical advice

For a team using Terraform to manage its own infrastructure the day-to-day experience is the same either way, because OpenTofu keeps the HCL language and the plan/apply workflow. Decide on license policy, tool support (CI integrations, scanners) and roadmap, then pick one per repository and do not mix the two against the same state without checking compatibility for your versions.

Common mistakes
⚠ Terraform mistakes that cause outages and leaks
  • **Committing state or .tfvars with secrets** to Git. State is plaintext and holds secret values.
  • Manual console changes next to Terraform: drift, surprise reverts, and "who changed this?".
  • One giant state for everything. Slow plans, large blast radius, and a lock that blocks every team. Split by lifecycle and ownership (network, data, services) and by environment.
  • **terraform apply without reading the plan,** or approving a plan that differs from the reviewed one.
  • Unpinned providers and modules (no lock file committed, no version), so a routine run upgrades something.
  • **count over an edited list,** causing reshuffled instances. Use for_each.
  • **Renaming or moving resources without moved blocks.**
  • Local state in a team, with no backend, no lock and no backup.
  • Using workspaces as the only prod/dev boundary, then applying to the wrong one.
  • Giving the pipeline admin rights everywhere. Scope roles per environment and per purpose (plan vs apply).
  • **Treating -target as a normal tool.** It bypasses the graph and leaves the rest out of sync; it is for emergencies.
12
Chapter Twelve - Interview

Questions You Should Be Able to Answer

Crisp answers for the questions that come up in interviews on this topic.

  • Q: What is Infrastructure as Code and why use it?
    Describing infrastructure in versioned files applied by a tool. It gives repeatable environments, review through pull requests, history, fast rebuilds and less drift than console changes.
  • Q: Declarative vs imperative?
    Declarative describes the desired end state and the tool computes the steps (idempotent, has a plan); imperative lists the steps and you handle ordering and repeat runs. Terraform and CloudFormation are declarative.
  • Q: Explain the Terraform workflow.
    init downloads providers and configures the backend; validate checks the configuration; plan compares configuration, state and real resources and shows the actions; apply executes (preferably a saved plan) and updates state; destroy removes everything managed.
  • Q: What is Terraform state and why does it matter?
    A mapping between resource addresses in the code and real objects, plus their attributes. It enables diffs, deletions and dependency tracking. It must be shared (remote backend), locked, protected and treated as sensitive because it contains secret values in plaintext.
  • Q: How do you configure remote state on AWS with locking?
    The s3 backend with a versioned, encrypted, private bucket, and use_lockfile = true (Terraform 1.10 experimental, 1.11 GA) so a lock object is written in S3 with a conditional write. The older approach is a DynamoDB table via dynamodb_table, now deprecated.
  • Q: count vs for_each?
    count identifies instances by index, so removing an element in the middle shifts the others; for_each identifies them by key, so only the removed key changes. Use for_each for collections and count for 0-or-1 switches.
  • Q: What is a module and how do you version one?
    A directory of Terraform files called with a module block, with inputs (variables) and outputs. Version registry modules with version constraints and Git modules with a ?ref= tag, so upgrades are deliberate.
  • Q: Workspaces or separate state per environment?
    Separate state (and ideally separate credentials) per environment gives strong isolation and easier per-environment differences; workspaces share backend and credentials and suit temporary copies of one stack.
  • Q: What is drift and how do you handle it?
    Reality differing from state because of changes outside Terraform. plan shows it after refresh; fix by applying (revert) or by updating the code; plan -refresh-only inspects it, and a scheduled plan -detailed-exitcode detects it. Reduce it by restricting console write access.
  • Q: How do you bring an existing resource under Terraform?
    Write the resource block and an import block (Terraform 1.5+) with the resource id, run plan to review the diff, adjust until clean, apply. The older terraform import command changes state immediately without review.
  • Q: How do you rename a resource without destroying it?
    Add a moved block from the old address to the new one (Terraform 1.1+). The plan then shows a move, not a destroy and create. terraform state mv does the same immediately but is not reviewable.
  • Q: What do prevent_destroy, create_before_destroy and ignore_changes do?
    Fail any plan that would destroy the resource; create the replacement before deleting the old one; and ignore changes to listed attributes made outside Terraform. Each has trade-offs: prevent_destroy is only a plan guard, create_before_destroy needs non-colliding names, ignore_changes hides drift.
  • Q: How does Terraform decide the order of operations?
    From references between resources, which form a dependency graph. Independent resources run in parallel, deletion runs in reverse order, and depends_on adds explicit edges where a reference does not exist.
  • Q: How do you test Terraform code?
    fmt and validate, linters such as TFLint, security scanners such as Checkov, terraform test (Terraform 1.6+, with mock providers from 1.7) for assertions, and plans reviewed in CI. Real apply tests go in a sandbox account.
  • Q: How would you run Terraform in CI safely?
    Plan on pull request and post it for review; apply only from main by the pipeline with a reviewed or saved plan; OIDC to AWS with separate roles for plan and apply; state locking and a concurrency group; plan files and state treated as sensitive.
  • Q: Terraform vs CloudFormation?
    Terraform is multi-cloud with providers, a plan step, and externally managed state. CloudFormation is AWS-only, AWS manages the state, has change sets, automatic rollback and built-in drift detection. Terraform failures leave partial changes you must re-apply.
  • Q: What happened with the Terraform license, and what is OpenTofu?
    Terraform 1.6.0 and later are under the Business Source License (earlier versions are MPL 2.0). OpenTofu is a fork created in response, now under the Linux Foundation, which keeps HCL and the plan/apply workflow under MPL 2.0.
13
Chapter Thirteen - Summary

Cheat Sheet and Recap

Cheat sheet
TopicRememberDetail
Workflowinit, validate, plan, apply, destroyplan -out=tfplan then apply tfplan
DeclarativeDescribe the end state, tool computes stepsIdempotent; plan shows the diff
Variablestype, validation, sensitivePrecedence: default, env, tfvars, auto.tfvars, -var (last wins)
Repetitionfor_each over countcount only for 0-or-1
Provider versions~> constraint + committed .terraform.lock.hclinit -upgrade to move deliberately
StatePlaintext, contains secrets, never commitRemote backend, encrypted, versioned, locked
S3 lockinguse_lockfile = trueGA in 1.11; dynamodb_table deprecated
ModulesSmall, versioned, provider config in rootRegistry version or Git ?ref=
EnvironmentsSeparate state per environmentWorkspaces for temporary copies
GraphReferences create order; independent = paralleldepends_on only when needed
lifecycleprevent_destroy, create_before_destroy, ignore_changesEach has caveats
Refactormoved blocks (1.1), import blocks (1.5)Plan must show moves, not destroys
Testingvalidate, TFLint, Checkov, terraform testMock providers since 1.7
CIPlan on PR, apply on merge, OIDC, two rolesSaved plan or approved re-plan
LicenseTerraform 1.6.0+ is BSL; OpenTofu is MPL 2.0 forkCheck legal needs yourself
∑ Infrastructure as Code - what to remember
  • IaC replaces console clicks with reviewed, versioned, repeatable code. Declarative tools compute the steps from the desired end state.
  • The plan is the safety net. Read it, save it, and apply exactly what was reviewed.
  • State is the mapping and the secret store. Remote, locked, encrypted, versioned and never committed.
  • Choose stable identities: for_each keys and moved blocks protect real resources during refactors.
  • Pin everything: provider versions with a committed lock file, module versions, Terraform version.
  • Isolate environments with separate state and credentials; use workspaces only for temporary copies.
  • Automate with a pipeline: plan on pull request, apply on merge, OIDC instead of stored keys, scanners and tests before the plan.
  • Detect and prevent drift by restricting console writes and running scheduled plans.
Version note

Checked on 2026-10-31. Executed: terraform init, validate, fmt -check and terraform test (with a mocked AWS provider) on the example in chapter 8 and the snippets of chapters 3, 4, 6, 7 and 9, with Terraform v1.17.1 (the hashicorp/terraform:latest image) and hashicorp/aws 6.71.0; the module snippet was initialised against the registry (terraform-aws-modules/vpc/aws, 6.x). Nothing was applied to AWS. From documentation and changelogs: S3 backend use_lockfile (experimental in 1.10, generally available in 1.11, DynamoDB arguments deprecated), state plaintext and sensitive-data guidance, variable precedence, dependency lock file behaviour, moved (1.1), import blocks (1.5), terraform test GA (1.6) with mocks (1.7), Terraform repository licenses (MPL 2.0 through 1.5, BSL for 1.6.0 and later) and the OpenTofu FAQ. Not verified here: the exact date HashiCorp announced the license change, HashiCorp's statements about third-party use under the BSL, and any CloudFormation or CDK behaviour beyond the general characteristics listed in chapter 11. Re-check against the docs for your Terraform version.

Next in this section
  • 05 CI/CD Pipelines - the pipeline concepts that chapter 10 reuses: OIDC, approvals, environments, supply-chain security.
  • 03 Kubernetes Core - what Terraform often provisions (a cluster) and what GitOps then runs on it.
  • AWS - the services these examples create.
  • DevOps Map - the whole section.