Introduction

Someone clicks the wrong button in the AWS console and a production database disappears. The team spends four hours reconstructing it from memory. That's Tuesday without infrastructure as code.

Terraform is HashiCorp's tool for defining cloud infrastructure in configuration files. You describe what you want, Terraform figures out what to create and in what order, and it tracks everything it builds. Tear it all down? One command. But the real value isn't the automation — it's the state file. Terraform knows what exists. It knows what you asked for. It computes the difference. That's a fundamentally different relationship with infrastructure than clicking around in a console.

The provider model is what made Terraform win. Same language, same workflow for AWS, Google Cloud, Azure, Datadog, GitHub repos. Hundreds of services. Not locked to one cloud. That flexibility matters more than people think it does early on, and by the time they realize it, they're glad they picked something portable.

Infrastructure as code is not about replacing the cloud console. It is about making your infrastructure reviewable, repeatable, and recoverable.

HCL Quick Reference

HCL is the configuration language. More readable than JSON, less ambiguous than YAML. A Terraform project is one or more .tf files in a directory — Terraform merges all of them together, so split them however makes sense. Most teams: main.tf, variables.tf, outputs.tf, providers.tf.

Basic EC2 instance:

main.tf
# Configure the AWS provider
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  # 1.10+ is needed for S3 native state locking (use_lockfile)
  required_version = ">= 1.10.0"
}

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

# Create an EC2 instance
resource "aws_instance" "web_server" {
  # Placeholder: AMI IDs are region-specific and get retired. The aws_ami
  # data source in the modules section looks one up instead.
  ami           = "ami-0c02fb55956c7d316"
  instance_type = "t3.micro"

  tags = {
    Name        = "web-server"
    Environment = "production"
    ManagedBy   = "terraform"
  }
}
Terminal running terraform init: it finds hashicorp/aws versions matching ~> 6.0, installs a 6.x release signed by HashiCorp, creates .terraform.lock.hcl and reports success.
Example output. terraform init resolves the ~> 6.0 constraint to one provider version and records it in the lock file.

The examples target Terraform 1.10 or later (1.17 is current as of October 2026) and version 6 of the AWS provider. Pin provider versions. Pin the Terraform version. Skip this and a new provider release will break your config silently on someone else's machine. Every resource follows resource "TYPE" "NAME" — type from the provider, name is your local label. Reference it elsewhere as aws_instance.web_server.

Workflow: terraform init (downloads providers), terraform plan (previews changes), terraform apply (creates resources), terraform destroy (tears them down). The plan step is the most valuable part. Shows you exactly what will happen before anything happens.

Providers and Resources

Providers are plugins. AWS, Google Cloud, Azure, Kubernetes, Datadog, GitHub — thousands of them in the Terraform Registry. Each exposes resources (things Terraform creates) and data sources (things Terraform reads but doesn't own).

network.tf
# Create a VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "main-vpc"
  }
}

# Create a public subnet
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = "us-east-1a"
  map_public_ip_on_launch = true

  tags = {
    Name = "public-subnet"
  }
}

# Internet gateway for outbound traffic
resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "main-igw"
  }
}

# Route table for public subnet
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.gw.id
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}
Terminal running terraform plan for main.tf and network.tf: six resources will be created, including the VPC 10.0.0.0/16 and subnet 10.0.1.0/24 whose vpc_id is known after apply. Plan: 6 to add.
Example output. The plan lists six resources to create; the subnet's vpc_id is unknown until the VPC exists.

The dependency graph is implicit. The subnet references aws_vpc.main.id, the route table references both the VPC and the internet gateway. Terraform reads these references and creates resources in the right order. VPC before subnet, internet gateway before route table entry. You rarely need to specify ordering manually. Reach for depends_on only for hidden dependencies Terraform cannot see from references, such as an IAM policy that must exist before an instance uses it. If you add it everywhere, your references probably aren't structured correctly.

Variables, Outputs and Locals

Hardcoded values work for experiments. They fall apart the moment you need the same config in a different region or with a different instance size.

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

variable "environment" {
  description = "Deployment environment name"
  type        = string

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

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

variable "allowed_cidr_blocks" {
  description = "CIDR blocks allowed to access the instance"
  type        = list(string)
  # Placeholder: 0.0.0.0/0 means the whole internet. Replace it with your
  # office or VPN ranges before using this for anything like SSH.
  default     = ["0.0.0.0/0"]
}

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

# Local values for computed or combined values
locals {
  common_tags = merge({
    Environment = var.environment
    ManagedBy   = "terraform"
    Project     = "web-platform"
  }, var.extra_tags)

  name_prefix = "${var.environment}-web"
}

# Outputs expose values after apply
output "instance_public_ip" {
  description = "Public IP of the web server"
  value       = aws_instance.web_server.public_ip
}

output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}
Terminal running terraform plan with environment=prod: Error, Invalid value for variable on variables.tf line 7, var.environment is "prod", Environment must be dev, staging, or production.
Example output. The validation block rejects "prod" before Terraform touches AWS.

The validation block on environment — if someone passes "prod" instead of "production", Terraform catches it before creating anything. Cheap safety net.

Locals are computed values. local.common_tags merges standard tags with extra tags from a variable. Every resource references one tag map instead of duplicating the same block everywhere. Outputs expose values after apply: the IP of a new server, the ID of a VPC. They also pass data between modules.

In practice, one .tfvars file per environment. production.tfvars sets instance type to t3.large, dev.tfvars keeps it at t3.micro. Pass the file with terraform apply -var-file="production.tfvars". Simple, but it works.

Modules for Reusable Infrastructure

Every application needs a VPC. Every application needs a load balancer and an auto-scaling group. Copy-pasting those resource blocks across projects is how you end up with five slightly different VPC configurations and no idea which one is correct.

A module is a directory of .tf files with inputs and outputs. Your root config is already a module. Child modules encapsulate reusable infrastructure.

The module structure matters more than most people realize, so here's what a standardized web application stack looks like before you see the code: security group, launch template, and auto-scaling group, all parameterized by app name, VPC, subnets, and sizing. One module, callable with different parameters for different services.

modules/web-app/main.tf
# modules/web-app/main.tf
# A reusable module for web application infrastructure

resource "aws_security_group" "web" {
  name_prefix = "${var.app_name}-web-"
  vpc_id      = var.vpc_id

  # Public web traffic: HTTP and HTTPS from anywhere is intended here
  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"]
  }

  tags = var.tags
}

resource "aws_launch_template" "web" {
  name_prefix            = "${var.app_name}-"
  image_id               = var.ami_id
  instance_type          = var.instance_type
  vpc_security_group_ids = [aws_security_group.web.id]

  tag_specifications {
    resource_type = "instance"
    tags = merge(var.tags, {
      Name = "${var.app_name}-web"
    })
  }
}

resource "aws_autoscaling_group" "web" {
  name                = "${var.app_name}-asg"
  desired_capacity    = var.desired_capacity
  max_size            = var.max_size
  min_size            = var.min_size
  vpc_zone_identifier = var.subnet_ids

  launch_template {
    id      = aws_launch_template.web.id
    version = "$Latest"
  }
}

The module declares its inputs in its own variables.tf:

modules/web-app/variables.tf
# modules/web-app/variables.tf
variable "app_name" {
  type = string
}

variable "vpc_id" {
  type = string
}

variable "subnet_ids" {
  type = list(string)
}

variable "ami_id" {
  type = string
}

variable "instance_type" {
  type = string
}

variable "desired_capacity" {
  type = number
}

variable "min_size" {
  type = number
}

variable "max_size" {
  type = number
}

variable "tags" {
  type    = map(string)
  default = {}
}

Calling it from root config. The aws_ami data source looks up the latest Amazon Linux 2023 image so no AMI ID is hardcoded:

main.tf (root module)
# Latest Amazon Linux 2023 AMI in the provider's region
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

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

module "api_service" {
  source           = "./modules/web-app"
  app_name         = "api"
  vpc_id           = aws_vpc.main.id
  subnet_ids       = aws_subnet.public[*].id
  ami_id           = data.aws_ami.amazon_linux.id
  instance_type    = "t3.small"
  desired_capacity = 2
  min_size         = 1
  max_size         = 4
  tags             = local.common_tags
}

module "frontend_service" {
  source           = "./modules/web-app"
  app_name         = "frontend"
  vpc_id           = aws_vpc.main.id
  subnet_ids       = aws_subnet.public[*].id
  ami_id           = data.aws_ami.amazon_linux.id
  instance_type    = "t3.micro"
  desired_capacity = 3
  min_size         = 2
  max_size         = 6
  tags             = local.common_tags
}

One module. Two separate application stacks. Change how security groups work in the module and every caller picks up the fix.

The Terraform Registry has community modules for common patterns — VPCs, EKS clusters, RDS databases. The official AWS VPC module handles public and private subnets, NAT gateways, and route tables in a single call. Use those for infrastructure primitives. Write custom modules for your application-specific patterns.

Rule of thumb: module when you build the same resources more than twice. But keep each module focused. A VPC module should not also deploy an application. That's not a module, that's a monolith wearing a module's clothes.

State Management and Remote Backends

This is the hardest concept in Terraform. Not the syntax. Not HCL. State.

Terraform's state file is a JSON mapping between what's in your config and what actually exists in the cloud. aws_instance.web_server maps to instance i-0abc123def456. Corrupt that file and Terraform no longer knows what it created. You're untangling things by hand, and "by hand" in this context means reading API responses and cross-referencing them against your config while production is potentially broken.

By default, state lives in a local file called terraform.tfstate. Solo work, fine. On a team, two people running terraform apply with different local state files will overwrite each other and potentially corrupt live infrastructure.

Get this wrong and you'll corrupt your state file. Read it carefully.

backend.tf
terraform {
  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "web-platform/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true

    # S3 native locking (Terraform 1.10+): writes a .tflock object next to the state
    use_lockfile   = true

    # Migrating from DynamoDB locking? Keep the old line alongside use_lockfile
    # for one release cycle, then delete it. dynamodb_table is deprecated.
    # dynamodb_table = "terraform-locks"
  }

  # required_providers lives in main.tf; declaring it twice in one module is an error
}

Every plan or apply now fetches state from S3, takes a lock by creating a .tflock object next to the state file, runs, writes state back, and deletes the lock. Older guides use a DynamoDB table for this. HashiCorp's S3 backend docs now mark dynamodb_table as deprecated and say DynamoDB-based locking will be removed in a future minor version, so new setups should use use_lockfile. Someone else tries to run at the same time? "State locked." They wait.

Never edit the state file by hand. Ever. Use terraform state mv and terraform state rm if you need to move or remove resources. The state file contains sensitive data — database passwords, API keys, connection strings — so encrypt the S3 bucket and restrict access with IAM policies. Enable versioning on the bucket. You will, at some point, need to recover from accidental corruption. When that day comes, you'll be glad the previous state file is still there.

Terraform Cloud handles all of this automatically. For larger teams, probably worth it. For smaller teams, an S3 backend with use_lockfile is enough.

Workspaces for Multiple Environments

Dev, staging, production. Same infrastructure, different sizes. Workspaces are Terraform's answer: same configuration, separate state files, completely independent resources. You reference the current workspace name with terraform.workspace to vary settings per environment.

environments.tf
# Environment-specific configuration using workspaces
locals {
  environment = terraform.workspace

  # Different settings per environment
  env_config = {
    dev = {
      instance_type     = "t3.micro"
      desired_capacity  = 1
      max_size          = 2
      multi_az          = false
      db_instance_class = "db.t3.micro"
    }
    staging = {
      instance_type     = "t3.small"
      desired_capacity  = 2
      max_size          = 4
      multi_az          = false
      db_instance_class = "db.t3.small"
    }
    production = {
      instance_type     = "t3.large"
      desired_capacity  = 3
      max_size          = 10
      multi_az          = true
      db_instance_class = "db.r6g.large"
    }
  }

  # Select config for current workspace
  config = local.env_config[local.environment]
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = local.config.instance_type

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

resource "aws_db_instance" "main" {
  identifier     = "${local.environment}-database"
  engine         = "postgres"
  # Major version only: RDS picks the default minor (exact minors get retired)
  engine_version = "16"
  instance_class = local.config.db_instance_class
  multi_az       = local.config.multi_az
  # ... other configuration
}
Terminal running terraform workspace new staging, then a plan in which the database is staging-database on db.t3.small without multi-AZ and the web instance is a t3.small tagged staging-web-server.
Example output. In the staging workspace, local.config picks the staging sizes and names every resource with a staging prefix.

terraform workspace new staging, terraform workspace select staging, apply. Resources get created with staging-sized instances and staging tags. Switch to production and the same code provisions production-grade resources.

Workspaces have a real problem, though. Nothing stops you from running terraform apply in the wrong workspace. Picture dev-sized database settings applied to production because someone forgot to switch: the database buckles under real traffic and needs an emergency resize. Running terraform workspace show before every apply, or printing the workspace in your shell prompt, guards against it.

Many teams prefer separate directories per environment with shared modules instead. When the directory is literally named environments/production/, ambiguity disappears. But separate directories mean more files to maintain. Pick your tradeoff.

Terraform in CI/CD Pipelines

Running Terraform from laptops is how teams get into trouble. Version mismatches. Forgotten git pull before apply. Zero audit trail. Moving Terraform into a CI/CD pipeline fixes all of these and, honestly, should happen earlier than most teams do it.

The pattern: branch, PR, CI runs terraform plan and posts results as a comment, reviewers approve, CD runs terraform apply on merge to main.

.github/workflows/terraform.yml
name: Terraform CI/CD

on:
  pull_request:
    branches: [main]
    paths: ['infrastructure/**']
  push:
    branches: [main]
    paths: ['infrastructure/**']

permissions:
  contents: read
  pull-requests: write
  id-token: write

jobs:
  plan:
    name: Terraform Plan
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.17.0"
          terraform_wrapper: false

      - name: Terraform Init
        run: terraform init
        working-directory: infrastructure/

      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan
        working-directory: infrastructure/

      - name: Render Plan
        run: terraform show -no-color tfplan > plan.txt
        working-directory: infrastructure/

      - name: Comment Plan on PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('infrastructure/plan.txt', 'utf8');
            // PR comments are capped at 65,536 characters
            const body = '#### Terraform plan\n```\n' + plan.slice(0, 60000) + '\n```';
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body,
            });

  apply:
    name: Terraform Apply
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    # A GitHub environment with required reviewers pauses here for approval
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.17.0"

      - name: Terraform Init
        run: terraform init
        working-directory: infrastructure/

      - name: Terraform Plan
        run: terraform plan -no-color -out=tfplan
        working-directory: infrastructure/

      - name: Terraform Apply
        # Applying the saved plan file applies exactly what the step above planned
        run: terraform apply -no-color tfplan
        working-directory: infrastructure/

OIDC authentication via role-to-assume instead of stored AWS keys. Short-lived credentials, nothing to rotate. Pinned Terraform version for consistency. Plan on PRs, apply only on pushes to main. terraform_wrapper: false keeps the plan output clean when it is redirected to a file.

The apply job saves its plan with -out=tfplan and then runs terraform apply tfplan. A saved plan applies exactly what was planned, with no second diff and no prompt. Note that the PR plan and the apply plan are separate runs: if main or the real infrastructure moved after review, the apply plan can differ from the comment reviewers saw. The production environment with required reviewers gives someone the chance to check the apply job's plan output before it continues. Teams that need the reviewed plan applied byte-for-byte upload tfplan as an artifact and apply that file instead, or use a tool built for this.

For more involved setups, Atlantis (open source, self-hosted) or Spacelift (managed, policy enforcement, drift detection). Both purpose-built for Terraform in PR workflows. But even a basic GitHub Actions setup like the one above is vastly better than running from laptops.

Next Steps

Set up remote state with S3 and use_lockfile before writing your first resource. Not after. Before. Migrating from local state later is doable with terraform init -migrate-state, but it is easier to never need it.

Terraform vs Pulumi vs CDK vs OpenTofu — the tooling is fragmenting. But the practice of codifying infrastructure is only getting more important. Which tool you pick matters less than picking one and using it for everything.

AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.