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 therefargument 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!