Skip to main content
Terraform is my go-to tool for provisioning any cloud infrastructure that needs to be reproducible, reviewed, and version-controlled. These notes cover the workflow I use daily — from first init through module composition and remote state management. Code examples use AWS but the patterns apply equally to GCP and Azure by swapping the provider block.

Core Workflow

1

terraform init — initialize the working directory

Downloads provider plugins and sets up the backend. Run this once per checkout, and again whenever you add providers or change the backend config.
terraform init

# Upgrade providers to the latest allowed by version constraints
terraform init -upgrade

# Reconfigure the backend (e.g., changing S3 bucket or key)
terraform init -reconfigure
2

terraform fmt — format code

Standardize HCL formatting before committing. Add this to your pre-commit hooks.
# Format all .tf files in the current directory recursively
terraform fmt -recursive

# Check formatting without modifying files (use in CI)
terraform fmt -check -recursive
3

terraform validate — check configuration syntax

Validates the configuration without accessing any remote services or state. Fast and safe to run locally.
terraform validate
4

terraform plan — preview changes

Shows exactly what Terraform will create, modify, or destroy. Always review this before applying.
terraform plan

# Save the plan to a file (use this in CI/CD for consistent apply)
terraform plan -out=tfplan

# Plan with variable overrides
terraform plan -var="instance_type=t3.small" -var="env=staging"

# Plan with a tfvars file
terraform plan -var-file=staging.tfvars
5

terraform apply — provision resources

# Interactive apply with confirmation prompt
terraform apply

# Apply a saved plan (no prompt, safe for CI/CD)
terraform apply tfplan

# Auto-approve without prompt (use carefully)
terraform apply -auto-approve
6

terraform destroy — tear down resources

# Destroy all resources managed by this state
terraform destroy

# Destroy a specific resource (target flag)
terraform destroy -target=aws_instance.web_server

# Preview what would be destroyed
terraform plan -destroy
Never run terraform apply or terraform destroy directly in production without a saved plan file reviewed in a pull request. Even terraform apply -auto-approve in CI should only run after a plan output has been reviewed and approved.

Provider Configuration

# versions.tf
terraform {
  required_version = ">= 1.6.0"

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

provider "aws" {
  region = var.aws_region

  # Tag every resource managed by this configuration
  default_tags {
    tags = {
      ManagedBy   = "terraform"
      Environment = var.environment
      Project     = var.project_name
    }
  }
}

# Second provider block for a different region (e.g., us-west-2 for DR)
provider "aws" {
  alias  = "dr"
  region = "us-west-2"
}
Use ~> (pessimistic constraint) for provider versions: ~> 5.0 allows 5.x but not 6.x. This protects against breaking changes in major versions while still getting patch updates automatically on init -upgrade.

Variables, Outputs, and Locals

variable "aws_region" {
  description = "AWS region to deploy resources into"
  type        = string
  default     = "us-east-1"
}

variable "environment" {
  description = "Deployment environment (dev, staging, prod)"
  type        = string

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

variable "project_name" {
  description = "Project identifier used in resource naming and tags"
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}

variable "allowed_cidr_blocks" {
  description = "List of CIDR blocks allowed SSH access"
  type        = list(string)
  default     = []
}

variable "tags" {
  description = "Additional tags to apply to all resources"
  type        = map(string)
  default     = {}
}

Remote State with S3 Backend

Storing state locally is fine for learning, but any team environment needs remote state. S3 + DynamoDB is the standard AWS pattern.
1

Create the S3 bucket and DynamoDB table

Bootstrap these resources once, manually or with a separate Terraform root. Do not put the state bucket itself in the state it manages.
# Create versioned S3 bucket for state
aws s3api create-bucket \
  --bucket my-tf-state-bucket \
  --region us-east-1

aws s3api put-bucket-versioning \
  --bucket my-tf-state-bucket \
  --versioning-configuration Status=Enabled

aws s3api put-public-access-block \
  --bucket my-tf-state-bucket \
  --public-access-block-configuration \
    "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

# Enable default encryption
aws s3api put-bucket-encryption \
  --bucket my-tf-state-bucket \
  --server-side-encryption-configuration '{
    "Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]
  }'

# Create DynamoDB table for state locking
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
2

Configure the backend block

# backend.tf
terraform {
  backend "s3" {
    bucket         = "my-tf-state-bucket"
    key            = "environments/prod/myapp/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}
3

Reference outputs from another state (remote state data source)

# Read outputs from the networking layer state
data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "my-tf-state-bucket"
    key    = "environments/prod/networking/terraform.tfstate"
    region = "us-east-1"
  }
}

# Use VPC and subnet IDs provisioned by the networking stack
resource "aws_instance" "app" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = var.instance_type
  subnet_id     = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
}

Resource and Data Source Patterns

# data sources — read existing infrastructure, don't manage it
data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "public" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
  filter {
    name   = "tag:Tier"
    values = ["public"]
  }
}

# Fetch the latest Amazon Linux 2023 AMI dynamically
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

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

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

# Resource — Terraform manages this
resource "aws_security_group" "web" {
  name_prefix = "${local.name_prefix}-web-"
  vpc_id      = data.aws_vpc.default.id
  description = "Web server security group"

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

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

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }

  tags = local.common_tags
}

Modules

Modules let you package reusable infrastructure patterns. The standard layout for a module:
modules/
  ec2-web-server/
    main.tf
    variables.tf
    outputs.tf
    README.md
resource "aws_instance" "this" {
  ami                    = var.ami_id
  instance_type          = var.instance_type
  subnet_id              = var.subnet_id
  vpc_security_group_ids = var.security_group_ids
  iam_instance_profile   = var.instance_profile_name
  user_data              = var.user_data

  root_block_device {
    volume_type           = "gp3"
    volume_size           = var.root_volume_size
    delete_on_termination = true
    encrypted             = true
  }

  tags = merge(var.tags, {
    Name = var.name
  })
}

Complete Example: AWS EC2 Instance

A self-contained, working Terraform configuration for a single EC2 instance with a security group, S3 bucket, and IAM role.
terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" {
  region = var.aws_region
  default_tags {
    tags = {
      ManagedBy   = "terraform"
      Environment = var.environment
      Project     = var.project_name
    }
  }
}

# --- Data sources ---
data "aws_vpc" "selected" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.selected.id]
  }
}

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

# --- IAM role for EC2 ---
resource "aws_iam_role" "ec2_role" {
  name = "${var.project_name}-${var.environment}-ec2-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ec2" {
  name = "${var.project_name}-${var.environment}-ec2-profile"
  role = aws_iam_role.ec2_role.name
}

# --- Security group ---
resource "aws_security_group" "web" {
  name_prefix = "${var.project_name}-${var.environment}-web-"
  vpc_id      = data.aws_vpc.selected.id
  description = "Web server - HTTP/HTTPS only"

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "HTTP"
  }

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

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle { create_before_destroy = true }
}

# --- EC2 instance ---
resource "aws_instance" "web_server" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = var.instance_type
  subnet_id              = data.aws_subnets.default.ids[0]
  vpc_security_group_ids = [aws_security_group.web.id]
  iam_instance_profile   = aws_iam_instance_profile.ec2.name

  user_data = <<-EOF
    #!/bin/bash
    dnf update -y
    dnf install -y nginx
    systemctl enable --now nginx
    echo "<h1>Hello from ${var.project_name} (${var.environment})</h1>" \
      > /usr/share/nginx/html/index.html
  EOF

  root_block_device {
    volume_type           = "gp3"
    volume_size           = 20
    encrypted             = true
    delete_on_termination = true
  }

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

# --- S3 bucket for app assets ---
resource "aws_s3_bucket" "app_assets" {
  bucket = "${var.project_name}-${var.environment}-assets-${data.aws_caller_identity.current.account_id}"
}

resource "aws_s3_bucket_versioning" "app_assets" {
  bucket = aws_s3_bucket.app_assets.id
  versioning_configuration { status = "Enabled" }
}

resource "aws_s3_bucket_public_access_block" "app_assets" {
  bucket                  = aws_s3_bucket.app_assets.id
  block_public_acls       = true
  ignore_public_acls      = true
  block_public_policy     = true
  restrict_public_buckets = true
}

data "aws_caller_identity" "current" {}

Useful Commands and Tips

# Show the current state (list all managed resources)
terraform state list

# Inspect a specific resource in state
terraform state show aws_instance.web_server

# Move a resource to a new address (after rename/refactor)
terraform state mv \
  aws_instance.web_server \
  module.web_server.aws_instance.this

# Remove a resource from state without destroying it
# (useful when importing existing infra management)
terraform state rm aws_s3_bucket.legacy

# Import an existing AWS resource into state
terraform import aws_s3_bucket.existing my-existing-bucket-name

# Refresh state to match real infrastructure
terraform apply -refresh-only

# Graph resource dependencies (requires graphviz)
terraform graph | dot -Tsvg > graph.svg

# Show what the workspace thinks is deployed
terraform show

# Taint a resource to force recreation on next apply
terraform taint aws_instance.web_server   # Terraform < 1.0
terraform apply -replace=aws_instance.web_server  # Terraform ≥ 1.0

# Output a specific value
terraform output instance_public_ip
terraform output -json  # all outputs as JSON
Use workspaces (terraform workspace new staging) for managing multiple environments from a single config, but be aware they share the same backend bucket. Many teams prefer separate state files per environment with different key paths in the S3 backend — it’s more explicit and easier to reason about access controls.

AWS Reference

AWS CLI commands and service details that pair with Terraform-provisioned infrastructure.

GCP & Azure

Apply the same Terraform patterns to GCP and Azure by swapping the provider block.

GitLab CI/CD

Automate Terraform plan and apply in CI/CD pipelines with GitLab.

FinOps & Cost Management

Tag strategy and cost allocation practices to bake into Terraform from day one.
Last modified on June 9, 2026