contentintech
Learn/devops/Terraform
Intermediate~20 min read

Terraform

Infrastructure as Code with HCL — providers, resources, variables, outputs, state and remote backends, the init/plan/apply/destroy workflow, modules, data sources, workspaces, count/for_each, and lifecycle rules.

IaCHCLStateModules

Infrastructure as Code

Terraform describes cloud infrastructure in declarative configuration files and provisions it through provider APIs. You state the desired end state; Terraform computes the diff against reality and makes only the changes needed. This is declarative IaC, in contrast to imperative scripts that spell out each step.

Why IaC

Infrastructure becomes version-controlled, reviewable, and reproducible. The same config yields the same environment every time, eliminating snowflake servers and manual console drift.

HCL Syntax and Blocks

Configuration is written in HCL (HashiCorp Configuration Language). Everything is a block: a type, optional labels, and a body of arguments.

hcl
terraform {
  required_version = ">= 1.9"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
  }
}

provider "aws" {
  region = var.region
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"

  tags = {
    Name = "web-${var.environment}"
  }
}
BlockPurpose
providerConfigures a plugin that talks to a platform API (AWS, GCP, Azure)
resourceDeclares an infrastructure object Terraform manages
dataReads existing infrastructure it does not manage
variableInput parameter for the configuration
outputExported value shown after apply / consumed by other modules
moduleReusable, parameterized bundle of resources

Variables and Outputs

Variables parameterize a config; outputs surface computed values. Reference a variable with var.name and a resource attribute with aws_instance.web.public_ip.

hcl
variable "environment" {
  type        = string
  description = "Deployment environment"
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging, or prod."
  }
}

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

output "web_ip" {
  value       = aws_instance.web.public_ip
  description = "Public IP of the web server"
}

Set values via terraform.tfvars, -var flags, or TF_VAR_* environment variables.

The Core Workflow

Four commands drive Terraform. Always review the plan before applying.

bash
# Download providers and initialize the backend
terraform init

# Preview changes without touching anything
terraform plan -out=tfplan

# Apply the reviewed plan
terraform apply tfplan

# Tear everything down
terraform destroy
CommandWhat it does
initInstalls providers/modules, configures backend
planShows the diff between config and real state
applyMakes reality match the config
destroyRemoves all managed resources
fmt / validateCanonically formats / syntactically checks config

State

Terraform records what it manages in a state file (terraform.tfstate). State maps config resources to real-world IDs and caches attributes. For teams, store state in a remote backend so it is shared and locked.

hcl
terraform {
  backend "s3" {
    bucket       = "acme-tf-state"
    key          = "prod/network/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true   # native S3 state locking (Terraform 1.10+)
  }
}

State locking

Remote state locking prevents two people from running apply at once and corrupting state. S3 backends now support native lockfiles, so a separate DynamoDB table is no longer required.

Never edit state by hand or commit it to Git — it can contain secrets. Use CLI commands like terraform state list, state mv, and import for surgical changes.

Data Sources

A data source reads information from a provider without managing it — for example, looking up the latest AMI or an existing VPC.

hcl
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"] # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-noble-24.04-amd64-server-*"]
  }
}

count and for_each

Both create multiple instances of a resource. Use count for identical copies indexed by number; use for_each when instances are keyed by a stable identifier — this avoids destructive re-indexing when the list changes.

hcl
# count — indexed 0..N
resource "aws_instance" "worker" {
  count         = var.instance_count
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  tags = { Name = "worker-${count.index}" }
}

# for_each — keyed by a set/map, stable across changes
resource "aws_s3_bucket" "bucket" {
  for_each = toset(["logs", "assets", "backups"])
  bucket   = "acme-${each.key}"
}

Modules

A module is a directory of .tf files you call with inputs and read outputs from. Modules make infrastructure reusable and composable.

hcl
module "network" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "prod-vpc"
  cidr = "10.0.0.0/16"
  azs             = ["us-east-1a", "us-east-1b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
}

# consume a module output
resource "aws_instance" "app" {
  subnet_id = module.network.private_subnets[0]
}

Workspaces

Workspaces let one configuration hold multiple independent states — handy for quick per-branch or per-tester environments. For long-lived prod/staging separation, most teams prefer separate directories or backends over workspaces.

bash
terraform workspace new staging
terraform workspace select staging
terraform workspace list

# reference in config
resource "aws_instance" "web" {
  instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
}

Lifecycle Meta-Arguments

The lifecycle block fine-tunes how Terraform treats a resource during changes.

hcl
resource "aws_db_instance" "main" {
  # ... config ...

  lifecycle {
    create_before_destroy = true          # avoid downtime on replacement
    prevent_destroy       = true          # guard critical resources
    ignore_changes        = [tags["LastScanned"]]
  }
}

Practice Exercises

  1. Write a config that provisions a single instance, run fmt, validate, plan, then apply.
  2. Add an input variable with a validation rule and an output that exposes the instance IP.
  3. Configure a remote S3 backend with native state locking and migrate your local state to it.
  4. Rewrite a count-based resource to use for_each and observe the plan diff.
  5. Use a data source to look up the latest Ubuntu AMI and reference it in your instance.
  6. Extract your instance and networking into a local module and call it twice with different inputs.

Section navigation