Provisioning AWS Infrastructure with Terraform: A Practical Guide
Managing AWS resources through the console works for a proof of concept. It doesn't work when you have ten engineers, three environments, and infrastructure that needs to be reproduced reliably. Terraform gives you repeatable, version-controlled infrastructure that can be reviewed in a pull request, tested, and redeployed from scratch in minutes.
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
- Pin provider and module versions —
~> 5.0allows minor bumps but not major breaking changes. - Use
terraform fmtandterraform validatein pre-commit hooks. - Never store secrets in state — use AWS Secrets Manager or SSM Parameter Store and reference ARNs.
- Tag every resource with
Environment,Owner, andManagedBy = "terraform"for cost allocation. - Use workspaces or separate state files per environment — never share state between prod and staging.
- Run
terraform planin CI on every pull request; block merge if plan fails.
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