Why Infrastructure as Code?

Before Terraform, the typical approach was a mix of console clicks, shell scripts, and tribal knowledge. Someone knew which security group rules mattered. Replicating a production environment for staging took days. A misconfigured IAM role brought down a deployment.

IaC solves this by making infrastructure a first-class engineering artifact: code that lives in Git, gets reviewed, and can be applied or torn down deterministically. Terraform specifically uses a declarative model — you describe the desired end state, and it figures out what to create, update, or destroy.

Installing Terraform and Configuring the AWS Provider

Install Terraform via the official HashiCorp package repository, or use tfenv to manage multiple versions across projects:

# Install tfenv
git clone https://github.com/tfutils/tfenv.git ~/.tfenv
export PATH="$HOME/.tfenv/bin:$PATH"

# Install and pin a specific version
tfenv install 1.8.5
tfenv use 1.8.5

Every Terraform project needs a provider configuration. For AWS, create a providers.tf file:

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

provider "aws" {
  region = var.aws_region
}

Terraform authenticates to AWS using standard credential chains — environment variables (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), a named profile (~/.aws/credentials), or an IAM role attached to the compute running the plan. In CI, use short-lived credentials via OIDC rather than long-lived access keys.

Variables and Outputs

Hard-coding values makes configuration brittle. Define variables in variables.tf and supply values via terraform.tfvars or environment variables prefixed with TF_VAR_:

# variables.tf
variable "aws_region" {
  type    = string
  default = "ap-south-1"
}

variable "environment" {
  type        = string
  description = "Deployment environment: dev, staging, or prod"
}

variable "vpc_cidr" {
  type    = string
  default = "10.0.0.0/16"
}
# terraform.tfvars  (never commit secrets to this file)
aws_region  = "ap-south-1"
environment = "prod"
vpc_cidr    = "10.10.0.0/16"

Outputs expose values to other Terraform configurations or scripts:

# outputs.tf
output "vpc_id" {
  value       = aws_vpc.main.id
  description = "ID of the primary VPC"
}

Remote State: S3 + DynamoDB Locking

By default Terraform stores state in a local terraform.tfstate file. That breaks the moment two engineers run apply simultaneously — or when the file gets deleted. Store state remotely on S3 with DynamoDB for locking:

# backend.tf
terraform {
  backend "s3" {
    bucket         = "my-company-terraform-state"
    key            = "prod/vpc/terraform.tfstate"
    region         = "ap-south-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

Create the S3 bucket and DynamoDB table once, manually or with a bootstrap Terraform configuration. The DynamoDB table needs a partition key named LockID (type String). After configuring the backend, run terraform init to migrate any existing local state to S3.

Use a separate AWS account or at minimum a separate S3 bucket per environment. State files contain sensitive data — treat them like secrets.

A Working VPC Example

Here's a production-ready VPC with public and private subnets across two availability zones:

# vpc.tf
locals {
  azs             = ["${var.aws_region}a", "${var.aws_region}b"]
  public_subnets  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnets = ["10.0.11.0/24", "10.0.12.0/24"]
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

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

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "${var.environment}-igw" }
}

resource "aws_subnet" "public" {
  count             = length(local.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = local.public_subnets[count.index]
  availability_zone = local.azs[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.environment}-public-${count.index + 1}"
    "kubernetes.io/role/elb" = "1"
  }
}

resource "aws_subnet" "private" {
  count             = length(local.azs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = local.private_subnets[count.index]
  availability_zone = local.azs[count.index]

  tags = {
    Name = "${var.environment}-private-${count.index + 1}"
    "kubernetes.io/role/internal-elb" = "1"
  }
}

resource "aws_eip" "nat" {
  count  = length(local.azs)
  domain = "vpc"
}

resource "aws_nat_gateway" "main" {
  count         = length(local.azs)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
  depends_on    = [aws_internet_gateway.main]

  tags = { Name = "${var.environment}-nat-${count.index + 1}" }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
  tags = { Name = "${var.environment}-public-rt" }
}

resource "aws_route_table_association" "public" {
  count          = length(local.azs)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  count  = length(local.azs)
  vpc_id = aws_vpc.main.id
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main[count.index].id
  }
  tags = { Name = "${var.environment}-private-rt-${count.index + 1}" }
}

resource "aws_route_table_association" "private" {
  count          = length(local.azs)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index].id
}

Modules: Reusable Building Blocks

Once your VPC configuration is stable, wrap it in a module so other teams can consume it without copying code. A module is just a directory with Terraform files:

modules/
  vpc/
    main.tf
    variables.tf
    outputs.tf

Call the module from a root configuration:

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

For shared modules across repositories, publish to a private Terraform registry or reference a Git source with a pinned tag: source = "git::https://github.com/org/terraform-modules.git//vpc?ref=v2.1.0".

Running Terraform Safely

The standard workflow is always: init → plan → review → apply.

# Initialise — downloads providers and configures backend
terraform init

# Show what will change — review this carefully
terraform plan -out=tfplan

# Apply the saved plan (no surprises)
terraform apply tfplan

# Tear down (use with extreme care in production)
terraform destroy

In CI pipelines, run plan on pull requests and post the output as a comment. Only run apply on merge to main, using a restricted IAM role with least-privilege permissions for each Terraform workspace.

Key Best Practices

Want Terraform set up properly for your team?

We design and implement modular Terraform configurations for AWS and Azure, including CI/CD integration, remote state, and team workflows.

Talk to Us