learnterraform.day concepts cli gotchas examples

Infrastructure as code · a working reference

Learn Terraform in an afternoon.

A dense, working reference for engineers who know some cloud but haven't written much HCL — and for anyone drilling for an interview or the Associate cert. Every concept, the syntax, and configs you can copy.

10 sections ~40 min read Terraform 1.x / OpenTofu

01 — Foundations

Core concepts

Terraform is a declarative infrastructure-as-code tool. You describe the desired state of your infrastructure in configuration files; Terraform builds an execution plan to reach that state and applies it through provider plugins. It reconciles configuration against recorded state — not against imperative steps.

Provider
A plugin for a platform (aws, google, azurerm). Configured once, exposes resources and data sources.
Resource
A managed object — a VM, bucket, DNS record. Terraform creates, updates, and destroys it to match config.
Data source
A read-only lookup of something that already exists (an AMI id, an existing VPC). It never changes infrastructure.
State
A JSON file mapping your config to real-world objects and caching their attributes. Terraform's source of truth for what it manages.
Plan / apply
The core loop: init to set up, plan to preview the diff, apply to enact it, destroy to tear down.

A minimal configuration declares a provider and one resource:

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

provider "aws" {
  region = "us-east-1"
}

resource "aws_s3_bucket" "assets" {
  bucket = "acme-assets-prod"
}
+Cert check — declarative vs imperative

Terraform is declarative: you state the desired end state, not the steps. Given the same config and state, repeated applies are idempotent — no change if reality already matches. This is why the order of resource blocks in a file does not matter; dependencies are derived from references.

02 — Language

HCL syntax

Configuration is written in HCL — HashiCorp Configuration Language. Everything is either a block (a labelled body in braces) or an argument (name = expression). Expressions reference other objects as type.name.attribute.

Types

Primitives string, number, bool; collections list(), set(), map(); structural object({}) and tuple([]). null omits an argument.

Meta-arguments

Available on most resource and module blocks: count and for_each (multiple instances), depends_on (explicit ordering), lifecycle (create_before_destroy, prevent_destroy, ignore_changes), and provider (which aliased provider to use).

Prefer for_each over count — it keys instances by a stable string, so removing one item doesn't re-index and recreate the rest:

for_each & dynamic
resource "aws_iam_user" "team" {
  for_each = toset(["ada", "linus", "grace"])
  name     = each.value
}

resource "aws_security_group" "web" {
  name = "web"

  # dynamic block: one ingress per port in the list
  dynamic "ingress" {
    for_each = [80, 443]
    content {
      from_port = ingress.value
      to_port   = ingress.value
      protocol  = "tcp"
    }
  }
}

Useful expression forms: conditional cond ? a : b, for comprehensions [for x in list : upper(x)], splat aws_instance.web[*].id, and interpolation "${var.env}-app".

03 — Interface

Variables & outputs

variable blocks are a module's inputs; output blocks are its return values; locals are named intermediate expressions. Give variables a type and — where it helps — a default, a validation, and sensitive = true.

variables.tf
variable "instance_count" {
  type    = number
  default = 2

  validation {
    condition     = var.instance_count > 0
    error_message = "Must run at least one instance."
  }
}

locals {
  name_prefix = "${var.env}-web"
}

output "public_ip" {
  value       = aws_instance.web.public_ip
  description = "Reachable address of the web host"
}

Where values come from

In increasing precedence: environment variables (TF_VAR_name), the auto-loaded terraform.tfvars and *.auto.tfvars, then -var-file, then -var on the command line. Later sources win.

+Cert check — does sensitive hide the value everywhere?

No. sensitive = true redacts a value from CLI plan/apply output, but it is still written in plaintext to state. Protect state itself (encrypted remote backend, restricted access) — sensitivity is not encryption.

04 — Composition

Modules

A module is any directory of .tf files. The directory you run Terraform in is the root module; a module block calls a child module. Its variables are the inputs you pass, its outputs are what you read back — that is the entire interface. Always pin a version for registry and Git sources.

calling a module
# from the Terraform Registry
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.8.1"

  name = "prod"
  cidr = "10.0.0.0/16"
}

# a local child module
module "web" {
  source     = "./modules/web"
  subnet_ids = module.vpc.private_subnets
}

Sources include local paths (./modules/web), the public/private registry (namespace/name/provider), and Git (git::https://...). Reference a module's output as module.NAME.OUTPUT.

05 — State

State & backends

State lives locally in terraform.tfstate by default. For any shared or production work, use a remote backend so the team reads one state and Terraform can lock it against concurrent applies.

backend.tf — S3 + DynamoDB lock
terraform {
  backend "s3" {
    bucket         = "acme-tfstate"
    key            = "prod/network.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks"
    encrypt        = true
  }
}

Common backends: s3 (with DynamoDB locking), gcs, azurerm, and Terraform Cloud / HCP. Manage state with the terraform state subcommands — list, show, mv (rename/move an address), rm (forget without destroying), pull / push. Bring existing infrastructure under management with terraform import ADDRESS ID.

+Cert check — what does locking prevent?

State locking stops two applies from writing state at the same time and corrupting it. It is acquired for the duration of a write operation and released after. Not all backends support it; S3 uses a DynamoDB table as the lock.

06 — Environments

Workspaces

CLI workspaces give one configuration multiple independent state files under the same backend. Switch with terraform workspace new staging / select staging, and branch on terraform.workspace inside config.

terraform.workspace
locals {
  instance_size = terraform.workspace == "prod" ? "m5.large" : "t3.micro"
}

Caveat. Workspaces share the same backend, config, and credentials, so they are weak isolation. For real environment separation (prod vs staging), most teams prefer separate directories or separate backend keys, not workspaces alone.

07 — Escape hatch

Provisioners

Provisioners run scripts as part of resource creation or destruction — local-exec (on the machine running Terraform), remote-exec and file (on the target, over a connection). HashiCorp calls them a last resort: they break the declarative model and aren't tracked in state.

provisioner
resource "aws_instance" "web" {
  # ...
  provisioner "local-exec" {
    command = "echo ${self.public_ip} >> hosts.txt"
  }
}

Prefer the cloud-native path — pass a startup script through user_data / cloud-init, or bake an image with Packer — before reaching for a provisioner.

08 — Workflow

CLI commands

The commands you'll type every day, in roughly the order you meet them.

CommandWhat it does
initDownloads providers and configures the backend. Run first, and after backend or provider changes.
fmtRewrites files to canonical style. Run in CI with -check.
validateChecks syntax and internal consistency without touching any provider.
planShows the diff between config and state. Save with -out=tfplan.
applyEnacts the plan. apply tfplan applies a saved plan with no re-prompt.
destroyDestroys everything in state. Equivalent to apply -destroy.
outputPrints output values; -json for machine use.
statelist, show, mv, rm — inspect and surgically edit state.
importBrings an existing object under Terraform management.
-replace=Forces one resource to be recreated (replaces the deprecated taint).
consoleAn interactive REPL for evaluating expressions against state.

09 — Craft

Best practices & gotchas

  • Use a remote backend with locking from day one; never commit .tfstate or .tfvars holding secrets.
  • Pin provider versions (required_providers) and module versions. Commit the .terraform.lock.hcl lock file.
  • Keep modules small with a clear input/output interface. Compose, don't nest deeply.
  • Always plan and read the diff before apply. Wire fmt -check and validate into CI.
  • Favour for_each over count for anything that will change; avoid provisioners.

Gotchas that bite

+count re-indexes on removal

With count, instances are addressed by position ([0], [1]…). Delete a middle item and every later one shifts index — Terraform destroys and recreates them. for_each keys by a string and is stable.

+Secrets live in state in plaintext

Passwords, keys, and any sensitive value are stored unencrypted inside state. Encrypt the backend and lock down who can read it. Prefer generating secrets outside Terraform where you can.

+Drift: reality changed out of band

If someone edits a resource in the console, the next plan shows a diff to pull it back. Use lifecycle { ignore_changes = [...] } for fields you intentionally let others manage.

10 — Patterns

Example configs

Complete, copy-ready starting points. Switch between them:

A web host in a new subnet
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public.id
  tags = { Name = "web" }
}
Bootstrapping remote state
# 1. create the bucket + lock table once (local state)
resource "aws_s3_bucket" "state" {
  bucket = "acme-tfstate"
}

resource "aws_dynamodb_table" "locks" {
  name         = "tf-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"
  attribute {
    name = "LockID"
    type = "S"
  }
}

# 2. then add the backend block and re-run: terraform init -migrate-state
modules/bucket/main.tf
variable "name" { type = string }
variable "versioned" {
  type    = bool
  default = false
}

resource "aws_s3_bucket" "this" {
  bucket = var.name
}

resource "aws_s3_bucket_versioning" "this" {
  count  = var.versioned ? 1 : 0
  bucket = aws_s3_bucket.this.id
  versioning_configuration { status = "Enabled" }
}

output "arn" {
  value = aws_s3_bucket.this.arn
}