Skip to content

Portal | Level: L1: Foundations | Topics: Terraform | Domain: DevOps & Tooling

Infrastructure as Code with Terraform - Primer

Why This Matters

Terraform lets you define infrastructure in code, version it in Git, review it in PRs, and apply it reproducibly. Instead of clicking through a cloud console, you write declarative configuration that describes the desired state, and Terraform figures out what to create, modify, or destroy to reach that state. This is the foundation of modern infrastructure management.

Core Concepts

How Terraform Works

Write config (.tf files)
     |
     v
terraform init      # Download providers, initialize backend
     |
     v
terraform plan      # Preview what will change (dry run)
     |
     v
terraform apply     # Execute the changes
     |
     v
State file updated  # Terraform records what it manages

Terraform is declarative: you describe what you want, not the steps to get there. If you define 3 EC2 instances and run apply, Terraform creates 3. If you change the config to 2 and apply again, Terraform destroys one.

Providers

Providers are plugins that talk to APIs (AWS, GCP, Azure, Kubernetes, GitHub, etc.):

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

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

Version constraints: ~> 5.0 means >= 5.0 and < 6.0. Pin provider versions to avoid surprises.

Resources

Resources are the infrastructure objects Terraform manages:

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.main.id

  tags = {
    Name        = "web-server"
    Environment = "production"
  }
}

resource "aws_security_group" "web" {
  name   = "web-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Resource references: aws_subnet.main.id references an attribute from another resource. Terraform builds a dependency graph and creates resources in the correct order.

State

State is Terraform's record of what it manages. It maps your config to real-world resources.

# Backend configuration (where state is stored)
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/network/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"  # State locking
    encrypt        = true
  }
}
  • Local state (terraform.tfstate): fine for learning, dangerous for teams
  • Remote state (S3, GCS, Terraform Cloud): required for collaboration
  • State locking (DynamoDB, GCS): prevents two people from applying simultaneously

Variables and Outputs

# variables.tf
variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "staging"
  validation {
    condition     = contains(["staging", "production"], var.environment)
    error_message = "Environment must be staging or production."
  }
}

variable "instance_count" {
  description = "Number of instances"
  type        = number
  default     = 2
}

# Using variables
resource "aws_instance" "web" {
  count         = var.instance_count
  instance_type = var.environment == "production" ? "t3.large" : "t3.micro"
  # ...
}

# outputs.tf
output "instance_ips" {
  description = "Public IPs of web instances"
  value       = aws_instance.web[*].public_ip
}

Setting variables (in order of precedence): 1. Command line: terraform apply -var="environment=production" 2. Variable file: terraform apply -var-file="prod.tfvars" 3. Environment variables: TF_VAR_environment=production 4. Default values in variable definition

Modules

Modules are reusable packages of Terraform configuration:

# Using a module
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.0"

  name = "my-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"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]

  enable_nat_gateway = true
}

# Referencing module outputs
resource "aws_instance" "web" {
  subnet_id = module.vpc.private_subnets[0]
}

Module sources: Terraform Registry, Git repos, local paths. Always pin module versions.

Plan and Apply

# Preview changes (ALWAYS review this)
terraform plan -out=tfplan

# Apply the reviewed plan
terraform apply tfplan

# Destroy all resources
terraform destroy

# Target specific resources
terraform plan -target=aws_instance.web
terraform apply -target=aws_instance.web

The plan output shows: - + create - - destroy - ~ update in-place - -/+ destroy and recreate (replacement)

Data Sources

Read information from existing infrastructure (not managed by this config):

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]  # Canonical
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-*"]
  }
}

resource "aws_instance" "web" {
  ami = data.aws_ami.ubuntu.id
}

What Experienced People Know

  • Always run terraform plan before terraform apply. Read the plan output completely. A missed destroy in the plan can take down production.
  • State is sacred. Lose it and Terraform doesn't know what it manages. Back up remote state with versioning enabled on the S3 bucket.
  • Never store secrets in .tf files or .tfvars committed to Git. Use environment variables, vault references, or sensitive = true on variables.
  • -target is for emergencies, not workflow. If you're routinely targeting, your configuration is too coupled.
  • Terraform doesn't detect manual changes (drift) unless you run plan. If someone modifies infrastructure outside of Terraform, the next apply may revert their changes.
  • count vs for_each: use for_each when possible. With count, removing an item from the middle of a list forces recreation of subsequent resources. for_each uses map keys, so removals are surgical.

See Also


Wiki Navigation

Prerequisites

Next Steps