Terraform Infrastructure as Code Guide
TOP 5 Dec. 18, 2025, 11:30 p.m.

Terraform Infrastructure as Code Guide

Terraform has become the de‑facto tool for provisioning cloud resources with code, and for good reason. It abstracts the quirks of each provider behind a clean, declarative language, letting you focus on *what* you need rather than *how* to spin it up. In this guide, we’ll walk through the fundamentals, explore real‑world patterns, and sprinkle in pro tips that can shave hours off your workflow.

Why Infrastructure as Code (IaC) Matters

Traditional manual provisioning is error‑prone, hard to audit, and impossible to scale reliably. By treating infrastructure the same way you treat application code—stored in version control, peer‑reviewed, and automatically applied—you gain repeatability and traceability. This shift also enables rapid experimentation: spin up a sandbox, test a change, then destroy it without leaving orphaned resources.

IaC also encourages collaboration across teams. Developers can request resources via pull requests, while ops can enforce policies through guardrails like Sentinel or Open Policy Agent. The result is a shared, single source of truth that evolves alongside your product.

Getting Started with Terraform

First, install Terraform from the official website or your package manager. Verify the installation with terraform version. Next, create a working directory and initialize it; this step downloads the required provider plugins and sets up the backend for state storage.

# Initialize a new Terraform project
$ mkdir my‑infra && cd my‑infra
$ terraform init
Initializing the backend...
Initializing provider plugins...
Terraform has been successfully initialized!

All Terraform commands are run from this directory, and the .tf files you create define the desired state of your infrastructure. The next sections break down the key building blocks you’ll use every day.

Core Concepts: Providers, Resources, and State

A provider is a plugin that knows how to interact with a specific API—AWS, Azure, GCP, and even SaaS platforms like Datadog. You declare a provider block once, and Terraform uses it for all subsequent resources.

A resource represents a single piece of infrastructure, such as an EC2 instance or an S3 bucket. Resources are declared declaratively: you describe the desired attributes, and Terraform figures out the actions needed to reach that state.

The state file (terraform.tfstate) is Terraform’s memory of what actually exists in the cloud. It maps your configuration to real resources, enabling diff calculations and safe updates. Because state contains sensitive IDs and sometimes secrets, store it securely—remote backends like S3 with DynamoDB locking or Terraform Cloud are recommended.

Example: A Minimal AWS Provider and S3 Bucket

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

resource "aws_s3_bucket" "logs" {
  bucket = "my-app-logs-2025"
  acl    = "private"

  versioning {
    enabled = true
  }

  tags = {
    Environment = "dev"
    Owner       = "team-analytics"
  }
}

Running terraform plan will show a preview of actions, and terraform apply will create the bucket. Notice how the bucket name is hard‑coded; in production we’ll replace that with a variable for flexibility.

Managing State Effectively

Local state works for quick experiments, but it doesn’t scale. When multiple engineers run apply against the same environment, they can overwrite each other’s changes, leading to race conditions and corrupt state.

Remote backends solve this by storing state in a central location and providing locking mechanisms. For AWS, the typical setup is an S3 bucket with a DynamoDB table for lock acquisition.

terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/network/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

After adding this block, run terraform init again to migrate existing state. From now on, any apply will acquire a lock, preventing concurrent modifications.

Pro tip: Enable versioning on the state bucket. If something goes wrong, you can roll back to a previous state file with a simple S3 version restore.

Variables, Outputs, and Secrets

Hard‑coding values makes your configurations brittle. Terraform’s variable blocks let you inject values at runtime, while output blocks expose useful information after a run—like an ELB DNS name that downstream services need.

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

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

output "bucket_name" {
  description = "Name of the S3 bucket created"
  value       = aws_s3_bucket.logs.id
}

When you run terraform apply -var="environment=prod", Terraform substitutes the variable throughout the configuration. For secrets, avoid plain‑text variables; instead, use terraform.tfvars excluded from VCS, or integrate with secret managers like AWS Secrets Manager via data sources.

Modules: Reusability at Scale

Modules are the building blocks of large Terraform codebases. A module groups related resources—say, a VPC with subnets, route tables, and NAT gateways—into a reusable package. You can source modules from local paths, Git repositories, or the public Terraform Registry.

Let’s create a simple VPC module. Inside modules/vpc, place the following files:

# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block = var.cidr_block
  tags = {
    Name = "${var.name}-vpc"
  }
}

resource "aws_subnet" "public" {
  count             = length(var.public_subnets)
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.public_subnets[count.index]
  map_public_ip_on_launch = true
  tags = {
    Name = "${var.name}-public-${count.index}"
  }
}
# modules/vpc/variables.tf
variable "name" {
  type = string
}

variable "cidr_block" {
  type = string
}

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

Now consume the module in your root configuration:

module "vpc" {
  source         = "./modules/vpc"
  name           = "myapp"
  cidr_block     = "10.0.0.0/16"
  public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
}

Modules keep your code DRY, simplify testing, and make it easy to spin up identical environments across regions or accounts.

Pro tip: Pin module versions using the ref argument when pulling from Git (e.g., source = "git::https://github.com/org/network-modules.git?ref=v1.2.3"). This prevents accidental breakages from upstream changes.

Provisioners and Remote Execution

While Terraform’s philosophy is to avoid imperative steps, sometimes you need to run a script on a newly created instance—like installing a monitoring agent. Provisioners let you do that, but they should be a last resort because they break idempotence.

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type

  provisioner "remote-exec" {
    inline = [
      "sudo apt-get update",
      "sudo apt-get install -y nginx",
      "sudo systemctl start nginx"
    ]

    connection {
      type        = "ssh"
      user        = "ubuntu"
      private_key = file(var.ssh_private_key_path)
      host        = self.public_ip
    }
  }

  tags = {
    Name = "app-server-${var.environment}"
  }
}

Remember to set connection details correctly, and consider using a configuration management tool (Ansible, Chef) for complex setups.

Real‑World Use Case: Multi‑Region VPC with Transit Gateway

Enterprises often need a resilient network spanning multiple AWS regions. Terraform can model this as a single root module that creates a Transit Gateway, attaches regional VPCs, and configures routing.

Below is a high‑level snippet showing how you might orchestrate the Transit Gateway attachment. The full implementation would be split into modules for each region, but this gives a taste of the pattern.

# Root module - transit-gateway.tf
resource "aws_ec2_transit_gateway" "core" {
  description = "Central TGW for prod traffic"
  tags = {
    Name = "prod-tgw"
  }
}

module "vpc_us_east_1" {
  source = "./modules/vpc"
  name   = "prod-us-east-1"
  cidr_block = "10.10.0.0/16"
  public_subnets = ["10.10.1.0/24", "10.10.2.0/24"]
}

module "vpc_us_west_2" {
  source = "./modules/vpc"
  name   = "prod-us-west-2"
  cidr_block = "10.20.0.0/16"
  public_subnets = ["10.20.1.0/24", "10.20.2.0/24"]
}

resource "aws_ec2_transit_gateway_vpc_attachment" "us_east_1" {
  transit_gateway_id = aws_ec2_transit_gateway.core.id
  vpc_id             = module.vpc_us_east_1.vpc_id
  subnet_ids         = module.vpc_us_east_1.public_subnet_ids
}

resource "aws_ec2_transit_gateway_vpc_attachment" "us_west_2" {
  transit_gateway_id = aws_ec2_transit_gateway.core.id
  vpc_id             = module.vpc_us_west_2.vpc_id
  subnet_ids         = module.vpc_us_west_2.public_subnet_ids
}

After applying, you have a single gateway routing traffic between two isolated VPCs, with all routing tables managed as code. Adding a new region becomes a matter of adding another module instance and attachment.

Testing, Validation, and Linting

Before you push changes, run terraform fmt to enforce canonical formatting, and terraform validate to catch syntactic errors. For deeper analysis, tools like tflint and checkov scan for best‑practice violations and security issues.

# Format code
$ terraform fmt -recursive

# Validate configuration
$ terraform validate

# Lint with tflint
$ tflint --init
$ tflint

# Security scan with checkov
$ checkov -d .

Integrate these checks into your CI pipeline so that every pull request is automatically vetted. This “fail fast” approach catches misconfigurations early, reducing costly cloud spend.

CI/CD Integration: Automated Deployments

Most teams use GitHub Actions, GitLab CI, or Azure Pipelines to automate Terraform runs. A typical workflow includes three stages: plan, review, and apply. The plan is generated as an artifact, posted as a comment, and only merged after approval.

# .github/workflows/terraform.yml
name: Terraform

on:
  pull_request:
    paths:
      - '**/*.tf'
  push:
    branches:
      - main

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: 1.9.0

      - name: Terraform Init
        run: terraform init -backend-config="bucket=my-terraform-state"

      - name: Terraform Plan
        id: plan
        run: terraform plan -out=tfplan

      - name: Upload Plan
        uses: actions/upload-artifact@v3
        with:
          name: tfplan
          path: tfplan

      - name: Comment PR with Plan
        uses: thollander/actions-comment-pull-request@v2
        with:
          message: |
                        ${{ steps.plan.outputs.stdout }}
                      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Terraform Apply (on merge)
        if: github.ref == 'refs/heads/main'
        run: terraform apply -auto-approve tfplan

This pipeline ensures that any change to infrastructure is visible, reviewed, and only applied to the target environment after a successful merge.

Pro tip: Store Terraform Cloud or remote backend credentials as encrypted secrets, never hard‑code them. Use the TF_VAR_ prefix to inject variables securely.

Advanced Patterns: Workspaces and Environments

Terraform workspaces let you manage multiple logical instances of the same configuration—perfect for dev, staging, and prod. Each workspace has its own state file, but shares the same code base.

$ terraform workspace new dev
Created and switched to workspace "dev"!

$ terraform workspace list
* dev
  default
  prod

When combined with variable files (e.g., dev.tfvars, prod.tfvars), you can keep environment‑specific settings separate while reusing the core modules.

Cost Management and Drift Detection

Untracked changes—known as drift—can happen when resources are modified outside Terraform (e.g., via console). Regularly run terraform plan against the remote state; any differences indicate drift.

For cost awareness, integrate with tools like infracost. It reads your Terraform plan and produces a detailed cost estimate, which you can embed in PR comments.

# Generate cost estimate
$ infracost breakdown --path . --format json > infracost.json

# Post to PR (example using GitHub Action)
- name: Infracost
  uses: infracost/actions@v2
  with:
    path: .
    api-key: ${{ secrets.INFRACOST_API_KEY }}

By surfacing cost impact early, teams can make informed trade‑offs before resources hit production.

Conclusion

Terraform empowers you to treat infrastructure as a first‑class citizen in your software development lifecycle. By mastering providers, state management, modules, and CI/CD integration, you can build resilient, auditable, and cost‑effective environments at scale. Remember to keep state secure, enforce linting, and leverage modules for reuse—these habits will pay dividends as your infrastructure grows. Happy coding, and may your plans always be green!

Share this article