Skip to content
← IaC · intermediate · 13 min · 03 / 05

Terraform Fundamentals

Resources, state, providers, modules, and the plan/apply workflow that makes cloud provisioning reproducible.

TerraformHCLstateprovidersmodulesplanapply

Real-World Analogy

An architect’s blueprint approved before construction: first you draw the plan (terraform plan), someone reviews it, then the crew builds it (terraform apply). You don’t start pouring concrete and figure it out as you go. The blueprint is the source of truth — the building is the artifact.

Core Concepts

Provider: Plugin that talks to an API (AWS, GCP, Hetzner, Cloudflare). Translates HCL into API calls.

Resource: A single infrastructure object managed by Terraform (an EC2 instance, a DNS record, a database).

State: Terraform tracks what it has created in a state file. Required to know what exists, what changed, what to destroy.

Plan: A preview of what Terraform will do — create, modify, or destroy — before it does it.

Apply: Execute the plan, making real changes to infrastructure.

Basic Structure

project/
  main.tf          # resources
  variables.tf     # input variable declarations
  outputs.tf       # output value declarations
  terraform.tf     # required providers, terraform settings
  terraform.tfvars # variable values (gitignored for secrets)
# terraform.tf
terraform {
  required_version = ">= 1.6"

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

  # Remote state — required for team use
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

provider "aws" {
  region = var.aws_region
}

Resources

# main.tf

# VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = {
    Name        = "main"
    Environment = var.environment
  }
}

# Subnet
resource "aws_subnet" "public" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id    # reference to VPC above
  cidr_block        = cidrsubnet("10.0.0.0/16", 8, count.index)
  availability_zone = var.availability_zones[count.index]

  tags = {
    Name = "public-${var.availability_zones[count.index]}"
  }
}

# EC2 instance
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  subnet_id              = aws_subnet.public[0].id
  vpc_security_group_ids = [aws_security_group.web.id]
  key_name               = aws_key_pair.deploy.key_name

  user_data = file("scripts/user-data.sh")

  tags = {
    Name        = "web-${var.environment}"
    Environment = var.environment
  }

  lifecycle {
    create_before_destroy = true   # create new, then destroy old (zero-downtime replace)
  }
}

# Data source: look up an existing resource (not managed by Terraform)
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]  # Canonical

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

Variables and Outputs

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

variable "instance_type" {
  type    = string
  default = "t3.medium"
}

variable "availability_zones" {
  type    = list(string)
  default = ["us-east-1a", "us-east-1b"]
}

variable "db_password" {
  type      = string
  sensitive = true   # masked in plan output and logs
}
# outputs.tf
output "web_public_ip" {
  value       = aws_instance.web.public_ip
  description = "Public IP of the web server"
}

output "db_endpoint" {
  value     = aws_db_instance.main.endpoint
  sensitive = true
}
# terraform.tfvars (gitignored — contains environment-specific values)
environment  = "production"
instance_type = "t3.xlarge"
db_password  = "secret"   # better: use SSM or environment variable
# Or pass sensitive values via environment variables
export TF_VAR_db_password="secret"

The Workflow

# 1. Initialize — download providers, configure backend
terraform init

# 2. Validate syntax
terraform validate

# 3. Format code
terraform fmt -recursive

# 4. Plan — preview changes
terraform plan -out=tfplan
# Output:
# Plan: 3 to add, 1 to change, 0 to destroy.

# 5. Review the plan (especially for production)
# Post plan output to PR for team review

# 6. Apply the plan
terraform apply tfplan

# 7. Check state
terraform state list
terraform show

Never skip the plan in production. A plan is cheap; an accidental destroy is not.

State Management

State is Terraform’s record of what it has created. It maps configuration to real resources.

Remote state is required for teams:

# Configure S3 backend (in terraform.tf)
# Create the bucket first (bootstrap problem — create manually or with a separate config)
aws s3 mb s3://my-terraform-state
aws s3api put-bucket-versioning \
  --bucket my-terraform-state \
  --versioning-configuration Status=Enabled
aws s3api put-bucket-encryption \
  --bucket my-terraform-state \
  --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms"}}]}'

# State locking (prevents simultaneous applies)
aws dynamodb create-table \
  --table-name terraform-state-lock \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

State commands:

# List resources in state
terraform state list

# Show details of a specific resource
terraform state show aws_instance.web

# Move resource (rename without recreating)
terraform state mv aws_instance.web aws_instance.web_server

# Remove from state (stop managing without destroying)
terraform state rm aws_instance.web

# Import existing resource into state
terraform import aws_instance.web i-1234567890abcdef0

Modules

Modules are reusable packages of Terraform configuration:

# modules/web-server/main.tf
resource "aws_instance" "this" {
  ami           = var.ami
  instance_type = var.instance_type
  subnet_id     = var.subnet_id
  # ...
}

resource "aws_security_group" "this" {
  name   = "${var.name}-sg"
  vpc_id = var.vpc_id
  # ...
}

# modules/web-server/variables.tf
variable "name" {}
variable "ami" {}
variable "instance_type" { default = "t3.medium" }
variable "vpc_id" {}
variable "subnet_id" {}

# modules/web-server/outputs.tf
output "instance_id" { value = aws_instance.this.id }
output "public_ip"   { value = aws_instance.this.public_ip }
# Use the module
module "web" {
  source = "./modules/web-server"  # local path

  name          = "web-prod"
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.xlarge"
  vpc_id        = aws_vpc.main.id
  subnet_id     = aws_subnet.public[0].id
}

output "web_ip" {
  value = module.web.public_ip
}

Public registry modules (use carefully — audit before trusting):

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.2"  # always pin version

  name = "main"
  cidr = "10.0.0.0/16"
  azs  = ["us-east-1a", "us-east-1b"]
}

Workspaces

Workspaces let you manage multiple environments from the same configuration:

# Create workspace per environment
terraform workspace new staging
terraform workspace new production

# Switch workspace
terraform workspace select production

# Use workspace name in config
resource "aws_instance" "web" {
  instance_type = terraform.workspace == "production" ? "t3.xlarge" : "t3.small"

  tags = {
    Environment = terraform.workspace
  }
}

Alternative: separate state files per environment with different tfvars. Both approaches work — workspaces are simpler, separate configs give stronger isolation.

CI/CD Integration

# GitHub Actions: Terraform CI
- name: Terraform plan
  run: |
    terraform init
    terraform plan -out=tfplan -no-color 2>&1 | tee plan.txt

- name: Post plan to PR
  uses: actions/github-script@v7
  with:
    script: |
      const plan = require('fs').readFileSync('plan.txt', 'utf8');
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: `<details><summary>Terraform Plan</summary>\n\n\`\`\`\n${plan}\n\`\`\`\n</details>`,
      });

# Apply only on merge to main
- name: Terraform apply
  if: github.ref == 'refs/heads/main'
  run: terraform apply tfplan

Never auto-apply to production without a human reviewing the plan. Auto-apply to staging is fine for fast feedback.