← All articles
June 18, 2026 · EkamOps Team

Infrastructure as Code with Terraform: A Beginner's Guide

TL;DR: Terraform lets you define AWS/Azure/GCP infrastructure as version-controlled code instead of manual console clicks. Write resources in HCL, store state remotely so your team shares one source of truth, group reusable pieces into modules, and run terraform plan in CI on every pull request so nobody applies an unreviewed change to production.

Infrastructure as Code (IaC) is the practice of managing servers, databases, networks, and cloud resources through code rather than manual configuration. Terraform, by HashiCorp, is the most widely used IaC tool — and for good reason.

Why Terraform?

Terraform works with every major cloud provider (AWS, Azure, GCP) using the same syntax. Your team can review infrastructure changes in pull requests, roll back bad changes with git, and spin up identical environments for dev, staging and production with a single command.

Documenting Modules Automatically

A module with no README of its inputs and outputs gets used incorrectly the first time someone other than its author touches it. Rather than maintaining documentation by hand, generate it directly from the HCL with terraform-docs:

terraform-docs markdown table --output-file README.md ./modules/vpc

Run it as a pre-commit hook so the README updates automatically whenever a variable or output changes — the documentation can never drift out of sync with the actual module because it's generated from the same source.

Your First Terraform Config

Here is a minimal example to create an AWS EC2 instance:

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

provider "aws" {
  region = "ap-south-1"
}

resource "aws_instance" "web" {
  ami           = "ami-0f5ee92e2d63afc18"
  instance_type = "t3.micro"

  tags = {
    Name = "ekamops-web"
    Env  = "production"
  }
}

Run terraform init, terraform plan (preview changes), then terraform apply to create the resource.

Variables and Multiple Environments

Hard-coding values like instance_type or region means copy-pasting the whole file for staging and production. Instead, declare variables:

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

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

resource "aws_instance" "web" {
  ami           = "ami-0f5ee92e2d63afc18"
  instance_type = var.instance_type

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

Then keep one .tfvars file per environment:

# prod.tfvars
environment   = "production"
instance_type = "t3.medium"

# staging.tfvars
environment   = "staging"
instance_type = "t3.micro"

Apply with terraform apply -var-file=prod.tfvars — same code, different environment, zero copy-pasting.

State Management

Terraform tracks what it has created in a state file. Store this remotely (S3 + DynamoDB locking for AWS) so your whole team shares the same view of infrastructure — never locally on someone's laptop:

terraform {
  backend "s3" {
    bucket         = "ekamops-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "ap-south-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

The DynamoDB table prevents two people from running terraform apply at the same moment and corrupting the state file.

Modules: Reusable Infrastructure

Group related resources into modules. A VPC module, an EKS module, an RDS module — each tested, versioned, and reusable across projects. This is where teams unlock the real productivity of IaC:

module "vpc" {
  source      = "./modules/vpc"
  cidr_block  = "10.0.0.0/16"
  environment = var.environment
}

module "database" {
  source      = "./modules/rds"
  vpc_id      = module.vpc.vpc_id
  subnet_ids  = module.vpc.private_subnet_ids
  environment = var.environment
}

Running Terraform in CI

Manual terraform apply from a laptop is how untracked drift creeps in. Run plan automatically on every pull request, and only apply after merge:

name: Terraform
on:
  pull_request:
  push:
    branches: [main]

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform plan -var-file=prod.tfvars
        if: github.event_name == 'pull_request'
      - run: terraform apply -auto-approve -var-file=prod.tfvars
        if: github.ref == 'refs/heads/main'

The plan output posts to the pull request so reviewers see exactly what will change — new resources, modified resources, and anything marked for destruction — before it ever merges.

Testing Terraform Changes Before Apply

A syntactically valid terraform plan can still describe an insecure or broken change. Run these checks in CI before anyone reviews the plan output:

- run: terraform fmt -check
- run: terraform validate
- run: tflint
- run: checkov -d . --compact

terraform fmt -check catches formatting drift, validate catches syntax and reference errors, tflint catches provider-specific mistakes like an invalid instance type, and Checkov flags security misconfigurations — a public S3 bucket, an open security group, an unencrypted volume — before they ever reach a human reviewer.

Importing Existing Infrastructure

Most teams don't start from zero — there's usually already a hand-built VPC or database sitting in the console. Bring it under Terraform's management without recreating it:

terraform import aws_instance.web i-0123456789abcdef0

Then write the matching resource block and run terraform plan. If the plan shows no changes, your code now accurately describes what's already running. If it shows changes, that's real drift between the console and reality that existed before you ever touched Terraform.

Tagging Everything for Cost and Ownership

Untagged infrastructure is invisible infrastructure — nobody can tell who owns it, what it costs, or whether it's safe to delete six months later. Enforce tags at the provider level so every resource gets them automatically, instead of relying on each engineer to remember:

provider "aws" {
  region = "ap-south-1"
  default_tags {
    tags = {
      ManagedBy   = "terraform"
      Team        = var.team
      Environment = var.environment
    }
  }
}

Six months from now, when someone asks "can we delete this?", a consistent tagging convention is the difference between a five-minute answer and a week of archaeology through CloudTrail logs.

Protecting Production from Accidental Destroy

A terraform destroy run against the wrong workspace is one of the most common self-inflicted outages in infrastructure teams. Mark critical resources so Terraform refuses to remove them even if a destroy is triggered:

resource "aws_db_instance" "prod" {
  # ...
  lifecycle {
    prevent_destroy = true
  }
}

Combine this with separate state files per environment (from the backend config above) so a mistaken terraform destroy in staging can never touch the production state file at all — the blast radius is limited by design, not by hoping nobody makes a mistake.

Terraform vs. the Alternatives

CloudFormation and ARM templates work well if you're fully committed to a single cloud and want first-party tooling with no extra dependency. Pulumi lets you write infrastructure in TypeScript, Python or Go instead of HCL, which appeals to teams who want full programming-language control — loops, conditionals, real functions — rather than HCL's more declarative, limited expressions. Terraform's advantage is breadth: one tool, one state model, and one syntax across every provider, which matters most for teams running multi-cloud today or expecting to add a second provider later.

Common Mistakes to Avoid

  • Editing resources by hand in the cloud console — this creates drift between real infrastructure and your Terraform state, and the next apply will try to "fix" changes you made intentionally.
  • Committing state files to git — state files can contain sensitive values in plaintext. Always use a remote backend, never commit terraform.tfstate.
  • One giant root module — a single 2,000-line main.tf is slow to plan and risky to change. Split by environment and by service.
  • No locking — without DynamoDB (or equivalent) locking, two simultaneous applies can corrupt your state and your infrastructure.

Need help getting your cloud infrastructure into code? EkamOps can help — we specialise in Terraform migrations for AWS and Azure.

Want help applying this to your stack?

Book a free consultation →
EkamOps Assistant Online
👋 Hi! I'm EkamOps AI. Ask me anything about DevOps, Cloud, AI — or how we can help your team. What's on your mind?