Skip to main content

🌍 Terraform Basics & Setup

In the last lesson you learned why we describe infrastructure in code. Now you'll learn the tool that most of the industry reaches for to do it: Terraform. It's provider-agnostic, declarative, and built around one calm, repeatable cycle — write, init, plan, apply.

Week 13 · Wednesday: Infrastructure as Code · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Install Terraform and verify it from the command line
  • Explain what makes Terraform provider-agnostic and how providers plug in
  • Write HCL resource blocks and parameterize them with variables and outputs
  • Run the full initplanapplydestroy cycle and describe what each does
  • Explain the state file, why it matters, and how remote state with locking protects a team
  • Package resources into reusable modules

Estimated Time: 70 minutes

Practice: Author a small configuration, preview its plan, and reason about state.

In This Lesson

What Is Terraform?

Terraform is an open-source IaC tool from HashiCorp that lets you provision infrastructure across hundreds of platforms — AWS, Azure, Google Cloud, Cloudflare, GitHub, Datadog, and many more — using one consistent, declarative language called HCL (HashiCorp Configuration Language). You describe the resources you want in .tf files, and Terraform makes the API calls to create them.

Its superpower is being provider-agnostic: the same workflow and mental model apply whether you're spinning up an AWS server or a DNS record at Cloudflare. Learn Terraform once, and you can automate almost any cloud.

📖 Terraform in one breath

You write desired state in HCL. Terraform compares it to the recorded state, computes a plan (the diff), and on apply calls each provider's API to make reality match. Simple loop, enormous power.

🛒 The shopping-list analogy

Terraform is grocery shopping with a well-organized list:

  • Config files are your shopping list — what you want, not how to get it.
  • Plan is checking the list against your pantry: what do I still need to buy, and what's already here?
  • Apply is the shopping trip that actually acquires the items.
  • State is your pantry inventory — the record of what you currently have.
  • Providers are the different stores you shop at.
  • Modules are pre-made lists for recurring occasions you reuse.

Installing & Verifying

Terraform ships as a single binary. Pick the line for your platform:

# macOS (Homebrew)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

# Windows (Chocolatey)
choco install terraform

# Ubuntu / Debian
wget -O- https://apt.releases.hashicorp.com/gpg | \
  sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
  https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform

Confirm the install by checking the version:

terraform version

Expected output

Terraform v1.9.5
on linux_amd64

💡 Set up your editor

Install the official HashiCorp Terraform extension in VS Code for syntax highlighting, autocompletion, and validation. Enable "format on save" so terraform fmt keeps your files tidy, and use 2-space indentation — the HCL convention.

HCL: Providers & Resources

HCL is built from blocks. Each block has a type, optional labels, and a body of arguments. The two you'll write most are provider and resource.

Providers — the plugins that talk to a platform

A provider is what teaches Terraform how to speak a given platform's API. You declare which providers you need and configure them:

# Declare required providers and pin their versions for reproducibility.
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"   # allow 5.x, but not 6.0 — predictable upgrades
    }
  }
  required_version = ">= 1.5.0"
}

# Configure the AWS provider (credentials come from your environment/CLI).
provider "aws" {
  region = "us-west-2"
}

Resources — the things you actually create

A resource block describes one piece of infrastructure. The two labels are the resource type and a local name you use to reference it elsewhere.

# resource "<TYPE>" "<LOCAL NAME>" { ...arguments... }
resource "aws_instance" "web_server" {
  ami           = "ami-0c55b159cbfafe1f0"  # machine image
  instance_type = "t3.micro"               # VM size

  tags = {
    Name        = "WebServer"
    Environment = "Production"
  }
}

# Reference one resource from another to build a dependency.
resource "aws_eip" "web_ip" {
  instance = aws_instance.web_server.id   # Terraform infers the order
}

✅ Terraform builds a dependency graph for you

Because aws_eip.web_ip references aws_instance.web_server.id, Terraform knows the instance must exist first. You never write ordering logic — you just reference, and the graph sorts it out.

Data sources — read without creating

When you need to look up something that already exists rather than create it, use a data block:

# Look up the latest Amazon Linux AMI instead of hardcoding an ID.
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id   # use the looked-up value
  instance_type = "t3.micro"
}

Variables & Outputs

Hardcoding values makes configs rigid. Variables are the inputs that parameterize your code; outputs are the values you expose after an apply.

Input variables

# variables.tf — declare inputs with types, defaults, and validation.
variable "region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-west-2"
}

variable "instance_count" {
  description = "How many web servers to create"
  type        = number
  default     = 2
}

variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "development"

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

Reference variables with var.<name>:

# main.tf — use the variables.
provider "aws" {
  region = var.region
}

resource "aws_instance" "server" {
  count         = var.instance_count                # make N copies
  ami           = data.aws_ami.amazon_linux.id
  # A ternary picks a bigger box in production:
  instance_type = var.environment == "production" ? "t3.medium" : "t3.micro"

  tags = {
    Name        = "server-${count.index + 1}"
    Environment = var.environment
  }
}

Outputs

Outputs surface useful values — an IP, a URL, an ARN — after Terraform runs:

# outputs.tf
output "instance_ips" {
  description = "Public IPs of the created servers"
  value       = aws_instance.server[*].public_ip
}

output "db_connection" {
  description = "Database connection string"
  value       = aws_db_instance.main.endpoint
  sensitive   = true   # hides the value in CLI output
}

💡 Provide variable values three ways

Via a terraform.tfvars file, with -var="instance_count=3" on the command line, or through TF_VAR_instance_count environment variables. Keep any *.tfvars holding secrets out of Git.

The init / plan / apply Cycle

Terraform's entire day-to-day boils down to four commands. Master this rhythm and you've mastered the tool.

sequenceDiagram participant D as Developer participant T as Terraform participant C as Cloud Provider D->>T: terraform init T->>T: Download providers and modules T->>D: Working directory ready D->>T: terraform plan T->>C: Read current infrastructure C->>T: Return the real resources T->>D: Show the diff to add change or destroy D->>T: terraform apply T->>C: Create update or delete resources C->>T: Confirm the changes T->>T: Record the new state T->>D: Show the results D->>T: terraform destroy when finished T->>C: Delete all managed resources T->>D: Tear-down complete

terraform init — prepare the directory

Downloads the providers and modules your config needs and sets up the backend. Run it once per project and again whenever you add a provider or module.

terraform init            # download providers, set up backend
terraform init -upgrade   # pull newer allowed provider versions

terraform plan — preview the diff

The safety step. Terraform compares your code to recorded state and shows exactly what it would change — nothing is touched yet.

terraform plan                  # print the diff
terraform plan -out=tfplan      # save the plan to apply later, verbatim

A plan reads like a diff

Terraform will perform the following actions:

  # aws_instance.web_server will be created
  + resource "aws_instance" "web_server" {
      + ami           = "ami-0c55b159cbfafe1f0"
      + instance_type = "t3.micro"
      + id            = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

terraform apply — make it real

Executes the plan. By default it shows the diff again and waits for you to type yes.

terraform apply             # prompts for confirmation
terraform apply tfplan      # apply a saved plan with no prompt
terraform apply -auto-approve   # skip the prompt (use only in CI)

terraform destroy — tear it all down

Removes every resource this configuration manages. Priceless for cleaning up practice environments so you don't get billed.

terraform destroy   # prompts before deleting everything managed here

⚠️ Always read the plan before you apply

A plan that says Plan: 0 to add, 0 to change, 1 to destroy against a production database is your last chance to stop a disaster. Never -auto-approve production changes without a reviewed plan.

State: The Heart of Terraform

Terraform keeps a state file (terraform.tfstate) that maps your code to the real resources it created. State is how Terraform knows that aws_instance.web in your code corresponds to instance i-0abc123 in AWS — and therefore what to change on the next apply.

Terraform state maps code to real cloud resources Your code aws_instance.web (desired state) State file web ⇄ i-0abc123 (the mapping) Real cloud EC2 i-0abc123 (actual resource)
State is the bridge between what your code declares and what actually exists in the cloud.

Local vs. remote state

By default state is a local file — fine for solo experiments, dangerous for teams. Two people running apply against separate local state files will clobber each other. The fix is remote state stored in a shared, encrypted backend, with state locking so only one apply runs at a time.

# backend.tf — store state in S3, lock with DynamoDB.
terraform {
  backend "s3" {
    bucket         = "company-terraform-state"
    key            = "production/network/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true               # encrypt sensitive values at rest
    dynamodb_table = "terraform-locks"  # prevents concurrent applies
  }
}

⚠️ State holds secrets — protect it

State files can contain plaintext values captured during an apply, including passwords. Never commit *.tfstate to Git. Use an encrypted remote backend with tight access controls, and enable versioning so you can recover a corrupted state.

Handy state commands

terraform state list                 # list resources Terraform tracks
terraform state show aws_instance.web # inspect one resource in detail
terraform output                      # print output values

Modules

A module is a reusable package of resources with its own inputs and outputs — the IaC equivalent of a function. Instead of copy-pasting the same VPC definition into every project, you write it once and call it wherever you need it.

# Call a module: pass inputs, receive outputs.
module "vpc" {
  source = "./modules/vpc"    # local path, Git URL, or the public Registry

  name               = "main-vpc"
  cidr_block         = "10.0.0.0/16"
  availability_zones = ["us-west-2a", "us-west-2b"]
}

# Use a module output just like any other reference.
resource "aws_instance" "app" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
  subnet_id     = module.vpc.private_subnet_ids[0]
}

Modules can come from a local folder, a Git repository, or the public Terraform Registry, which hosts thousands of community-maintained modules for common patterns.

✅ Pin module versions

When sourcing from the Registry or Git, always pin a version (version = "5.1.2" or ?ref=v5.1.2). An unpinned module can change under you and break a deploy without a single edit to your code.

Practice & Quiz

🏋️ Exercise 1: Read a plan

Goal: A teammate runs terraform plan and the summary line reads Plan: 2 to add, 1 to change, 1 to destroy. In one sentence, what is Terraform about to do — and what should you check before approving?

💡 Hint

Each verb maps to a real action against live infrastructure. Which one is irreversible?

✅ Solution

Terraform will create 2 new resources, modify 1 in place, and delete 1 existing resource. Before approving, scroll up and confirm which resource is being destroyed — a "destroy" against a database or stateful resource can mean data loss, so verify it's intentional.

🏋️ Exercise 2: Parameterize a resource

Goal: Rewrite this hardcoded resource to use a variable named bucket_name with a default of "app-assets".

resource "aws_s3_bucket" "assets" {
  bucket = "app-assets"
}
✅ Solution
# variables.tf
variable "bucket_name" {
  description = "Name of the assets bucket"
  type        = string
  default     = "app-assets"
}

# main.tf
resource "aws_s3_bucket" "assets" {
  bucket = var.bucket_name
}

Now the same code can create app-assets, staging-assets, or anything else by passing a different value — no edits to the resource block.

🎯 Quick Quiz

Question 1: Which command previews changes without touching real infrastructure?

Question 2: Why should team state be stored remotely with locking?

Question 3: What is a Terraform provider?

Best Practices & Pitfalls

✅ Do

  • Run terraform fmt and terraform validate before committing
  • Always plan and read the diff before apply
  • Use remote state with locking for anything a team touches
  • Pin provider and module versions for reproducible builds
  • Split configs into main.tf, variables.tf, outputs.tf for readability

❌ Don't

  • Commit *.tfstate or *.tfvars containing secrets to Git
  • Hardcode credentials in .tf files — use environment variables or a secrets manager
  • Use -auto-approve on production without a reviewed plan
  • Edit resources by hand in the console — that reintroduces drift
  • Overuse -target; it can skip dependencies and leave state inconsistent

⚠️ Add a .gitignore first

# .gitignore for Terraform projects
**/.terraform/*
*.tfstate
*.tfstate.*
*.tfvars      # often holds secrets
crash.log

Create this before your first commit. Leaking a state file or a secrets-laden tfvars is one of the most common — and most costly — beginner mistakes.

Summary

🎉 Key Takeaways

  • Terraform is a provider-agnostic, declarative IaC tool driven by HCL
  • Providers plug into platforms; resources describe what to create; data sources read what exists
  • Variables parameterize your code and outputs expose results
  • The core loop is initplanapplydestroy — and you always read the plan first
  • The state file maps code to reality; store it remotely with locking and never commit it
  • Modules package resources for reuse — pin their versions

📚 Additional Resources

🚀 What's Next?

Terraform is provider-agnostic, but AWS also has its own native IaC service that's deeply integrated with its ecosystem. Next up: AWS CloudFormation — YAML/JSON templates, stacks, change sets, and drift detection, and how it compares to what you just learned.

🎉 You can drive Terraform now!

Write, init, plan, apply — that calm loop is the same whether you manage one server or a thousand.