Education › DevOps › Stage 3: Run at scale

Infrastructure as Code with Terraform

Providers, state, modules, plan/apply discipline, and remote backends.

Intermediate ~35 min read Module 11 of 17

Infrastructure created by clicking in a cloud console cannot be reviewed, repeated or rolled back, and six months later nobody remembers why it looks the way it does. Infrastructure as Code fixes that by describing networks, clusters and databases in files that go through the same pull requests and pipelines as application code. Terraform is the most widely used tool for it. This module covers the workflow, the state file that makes it work, and the discipline that keeps it safe.

After this module you can
  • Explain the Terraform workflow of write, init, plan, apply, and what each step does
  • Write configuration using providers, resources, data sources, variables, locals and outputs
  • Describe what state is, why it must be remote and locked, and what belongs in it
  • Structure reusable modules and separate environments safely
  • Read a plan critically, and handle drift, imports and destructive changes without surprises

Declarative infrastructure

Terraform configuration, written in HCL, declares the infrastructure you want. Terraform compares that with what it believes exists, works out the difference, and makes the API calls to close it. This is the same desired-state idea as Kubernetes, with one important difference: Terraform reconciles only when you run it, not continuously.

A provider is a plugin that translates HCL into calls to one API: AWS, Azure, Google Cloud, Cloudflare, GitHub, Kubernetes and thousands more. A resource is one thing the provider manages. Terraform builds a dependency graph from the references between resources, so it creates things in the right order and in parallel where it can.

main.tf
hcl
terraform {
  required_version = ">= 1.6"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.region
}

resource "aws_s3_bucket" "artifacts" {
  bucket = "acme-${var.environment}-artifacts"
  tags   = local.common_tags
}

resource "aws_s3_bucket_versioning" "artifacts" {
  bucket = aws_s3_bucket.artifacts.id      # a reference: creates the dependency
  versioning_configuration {
    status = "Enabled"
  }
}

A resource address has the form TYPE.NAME, here aws_s3_bucket.artifacts. The name is local to your configuration and is how other resources refer to it. ~> 5.0 allows any 5.x provider release but not 6.0, and terraform init records the exact version chosen in .terraform.lock.hcl, which you commit, for the same reproducibility reasons as any other lockfile.

The core workflow

bash
terraform init              # download providers and modules, configure the backend
terraform fmt -recursive     # canonical formatting
terraform validate           # syntax and internal consistency
terraform plan -out=tfplan   # compute the changes and save them
terraform apply tfplan       # execute exactly the saved plan
terraform destroy            # remove everything this configuration manages

The plan is the heart of Terraform's safety. It lists every change before anything happens, using symbols you must learn to read.

SymbolMeaningRisk
+CreateLow
~Update in placeUsually low; check what is changing
-DestroyHigh: data may be lost
-/+Destroy, then create a replacementHigh: downtime and data loss; look for "forces replacement"

Some attributes cannot be changed on a live resource, so changing them makes Terraform replace it. Renaming a database instance can therefore mean deleting the database. Read the summary line, Plan: 2 to add, 1 to change, 0 to destroy, every single time, and treat any unexpected destroy as a stop sign.

Tip

Saving the plan with -out and applying that file guarantees that what was reviewed is what runs. Without it, apply computes a fresh plan, and the world may have changed since you looked.

State: Terraform's memory

Terraform records every resource it manages in a state file, mapping addresses such as aws_s3_bucket.artifacts to real IDs in the provider. Without state it could not know that a bucket already exists, which resources to delete when you remove a block, or what depends on what. By default state is a local terraform.tfstate file, which fails the moment a second person or a pipeline is involved.

Teams use a remote backend: shared storage for the state, with locking so two applies cannot run at once and corrupt it.

desiredreviewed change setAPI callscreate, change, destroywrites the new IDsknown mappingrefresh: real attributesConfiguration*.tf + tfvarsterraform plan+ ~ - -/+terraform applythe saved planProviderhashicorp/awsRemote statelocked, encryptedCloud resourcesVPC, buckets, DBs
Why state matters: `plan` compares your configuration with the state file and with what the provider reports, then `apply` makes the API calls and writes the new mapping back to the locked remote state.
backend.tf
hcl
terraform {
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "shop/prod/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true
  }
}
  • State contains secrets. Generated passwords, private keys and connection strings are stored in it in plain text. Encrypt the backend, restrict who can read it, and never commit state to Git.
  • Never edit state by hand. Use the terraform state subcommands, or better, the declarative moved, import and removed blocks.
  • Turn on versioning for the storage that holds state, so a bad write can be recovered.
  • Keep states small. One state for the whole company means every plan is slow and every mistake has an enormous blast radius. Split by environment and by layer, for example network, cluster and application.
Note

Locking options depend on the backend and the Terraform version. Older S3 setups use a DynamoDB table for locks; newer versions can use a lock file in the bucket. Check the backend documentation for the version you run.

Variables, locals, outputs and data sources

hcl
variable "environment" {
  type        = string
  description = "Deployment environment"
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging or prod."
  }
}

variable "instance_count" {
  type    = number
  default = 2
}

variable "db_password" {
  type      = string
  sensitive = true          # redacted in plan output; still stored in state
}

locals {
  common_tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    Team        = "shop"
  }
}

data "aws_caller_identity" "current" {}     # read-only lookup of something that exists

output "bucket_name" {
  value = aws_s3_bucket.artifacts.bucket
}

Variables are a configuration's inputs, outputs are its results, and locals are named expressions that save you repeating yourself. A data source reads information about something Terraform does not manage, such as the current account, the newest machine image or an existing network.

Variable values are resolved in a fixed order, with later sources winning: the default, then TF_VAR_name environment variables, then terraform.tfvars, then *.auto.tfvars, then -var-file and -var on the command line. Pipelines typically pass secrets as TF_VAR_ environment variables and everything else in a per-environment .tfvars file.

hcl
# one resource per item, addressed by key: aws_iam_user.team["alice"]
resource "aws_iam_user" "team" {
  for_each = toset(["alice", "bob", "carol"])
  name     = each.key
}

Prefer for_each to count for collections. With count, resources are addressed by position, so removing the first item shifts every index and Terraform plans to destroy and recreate the rest. With for_each they are addressed by key, and removing one touches only that one.

Modules and environments

A module is a directory of .tf files with variables as inputs and outputs as results, the unit of reuse. The directory where you run Terraform is the root module; it calls child modules. Write a module when you need the same group of resources more than once, or want to enforce a standard such as "every bucket is encrypted and versioned".

hcl
module "network" {
  source = "./modules/network"

  environment = var.environment
  cidr_block  = "10.20.0.0/16"
}

module "cluster" {
  source  = "terraform-aws-modules/eks/aws"   # from the public registry
  version = "~> 20.0"                         # always pin registry modules

  cluster_name = "shop-${var.environment}"
  vpc_id       = module.network.vpc_id         # a module output
  subnet_ids   = module.network.private_subnet_ids
}

For environments, the robust pattern is one directory per environment, each with its own backend and therefore its own state, all calling the same modules with different inputs.

text
infra/
  modules/
    network/   cluster/   database/
  envs/
    dev/       main.tf  backend.tf  terraform.tfvars
    staging/   main.tf  backend.tf  terraform.tfvars
    prod/      main.tf  backend.tf  terraform.tfvars

Separate states mean that a mistake in dev cannot touch prod, and you can grant different people access to each. Terraform workspaces offer several states for one configuration, but every workspace shares the same backend and code, which makes it easy to apply to the wrong one. They suit short-lived copies better than the split between production and everything else.

Drift, imports and guard rails

Drift is when real infrastructure no longer matches the configuration because someone changed it by hand. terraform plan reveals it as an unexpected change. You then either revert the manual change by applying, or accept it by updating the code. Running a scheduled plan in CI and alerting on any difference catches drift early.

hcl
# adopt an existing resource into state without recreating it
import {
  to = aws_s3_bucket.legacy
  id = "acme-legacy-uploads"
}

# rename or move a resource in code without destroying it
moved {
  from = aws_s3_bucket.artifacts
  to   = aws_s3_bucket.build_artifacts
}

resource "aws_db_instance" "main" {
  # ...arguments...
  lifecycle {
    prevent_destroy = true        # any plan that would destroy this fails
    ignore_changes  = [tags["LastPatched"]]
  }
}

Renaming a resource block without a moved block looks to Terraform like "delete the old one, create a new one", which for a database is catastrophic. Put prevent_destroy on anything stateful.

In a team, nobody applies from a laptop. A pull request runs fmt -check, validate, a linter such as tflint, a security scanner, and plan, posting the plan for review. After merge, the pipeline applies that plan using short-lived cloud credentials obtained through OIDC. OpenTofu, the open-source fork of Terraform, uses the same language and workflow, so everything here applies to it too.

Hands-on practice

Build, change and protect real infrastructure

  1. Install Terraform or OpenTofu. Use a free-tier cloud account, or avoid cloud cost entirely with the hashicorp/local and hashicorp/random providers, or the kreuzwerker/docker provider against your local Docker.
  2. Write a configuration with a pinned provider, two resources where one references the other, a variable with validation, and an output. Run init, fmt, validate, plan -out=tfplan and apply tfplan.
  3. Open terraform.tfstate and read it. Find the resource IDs, and find any sensitive value stored in plain text. Then run terraform state list and terraform state show on one resource.
  4. Change an attribute that updates in place, then one that forces replacement. Compare the ~ and -/+ plans and find the "forces replacement" note.
  5. Convert a count-based list of resources to for_each, remove the first item, and compare the plan with what count would have done.
  6. Create drift: change or delete a resource outside Terraform, run plan, and resolve it both ways, once by re-applying and once by updating the code.
  7. Extract your resources into modules/ and call the module from envs/dev and envs/prod with separate state. Add prevent_destroy to one resource and confirm terraform destroy refuses.
Cheat sheet

Infrastructure as Code with Terraform — at a glance

Main things to focus on

  • Workflow: init, fmt, validate, plan -out, review, apply the saved plan.
  • Read every plan. -/+ and - mean destruction; find out why before approving.
  • State maps your code to real resources. It must be remote, locked, encrypted and versioned, and it contains secrets.
  • One state per environment and layer keeps the blast radius small.
  • Prefer for_each to count, so removing one item does not reshuffle the rest.
  • Rename with moved, adopt with import, protect stateful resources with prevent_destroy.
  • Pin provider and module versions and commit .terraform.lock.hcl.
  • Apply from a pipeline, not a laptop.

Core commands

terraform initInstall providers and modules; set up the backend
terraform init -upgradeMove to newer provider versions within constraints
terraform fmt -recursiveFormat all files (-check in CI)
terraform validateCheck syntax and references
terraform plan -out=tfplanCompute and save the change set
terraform apply tfplanExecute exactly the saved plan
terraform plan -var-file=prod.tfvarsSupply variable values from a file
terraform destroyRemove everything in this state

Inspect and repair state

terraform state listEvery resource address in state
terraform state show ADDRESSAll recorded attributes of one resource
terraform outputPrint output values (-json for scripts)
terraform plan -refresh-onlyShow drift without proposing config changes
terraform state mv OLD NEWRename in state (prefer a moved block)
terraform state rm ADDRESSStop managing a resource without deleting it
terraform force-unlock LOCK_IDRelease a stale lock; be certain nothing is running

HCL building blocks

resource "TYPE" "NAME" { ... }Something Terraform creates and manages
data "TYPE" "NAME" { ... }Read-only lookup of existing infrastructure
variable "NAME" { type = string }Input; read as var.NAME
locals { NAME = expression }Named expression; read as local.NAME
output "NAME" { value = ... }Result exposed to callers and the CLI
module "NAME" { source = "..." }Call a module; read results as module.NAME.OUTPUT
TYPE.NAME.ATTRIBUTEReference, which also creates a dependency
"prefix-${var.environment}"String interpolation

Meta-arguments and lifecycle

for_each = toset([...]) / each.keyOne instance per item, addressed by key
count = N / count.indexN instances, addressed by position
depends_on = [RESOURCE]Explicit dependency when no reference exists
lifecycle { prevent_destroy = true }Fail any plan that destroys this resource
lifecycle { create_before_destroy = true }Build the replacement first to avoid downtime
lifecycle { ignore_changes = [ATTR] }Do not revert external changes to an attribute
import { to = ADDRESS id = "ID" }Adopt an existing resource
moved { from = OLD to = NEW }Rename without destroy and recreate

Variable precedence (lowest to highest)

default in the variable blockUsed when nothing else sets it
TF_VAR_name environment variableCommon for secrets in pipelines
terraform.tfvars, then *.auto.tfvarsLoaded automatically
-var-file=FILE, -var 'name=value'Command line; wins over everything

Common pitfalls

  • Approving a plan without noticing that a rename produced -/+ on a database.
  • Keeping state locally or in Git, where it conflicts, leaks secrets and gets lost.
  • Putting every environment in one state, so a dev experiment can break prod.
  • Using count for a list, then removing an early element and recreating everything after it.
  • Fixing things in the cloud console "just this once", creating drift that the next apply reverts.
  • Leaving provider and module versions unpinned, so the same code behaves differently next month.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →