Infrastructure as Code — Terraform and Pulumi
A practical guide to managing infrastructure as code: benefits of declarative vs imperative approaches, state management, modules, and testing infrastructure changes.
Infrastructure as Code — Terraform and Pulumi
Introduction
Infrastructure as Code (IaC) is the practice of managing and provisioning infrastructure through machine-readable definition files rather than manual configuration. It turns infrastructure changes into repeatable, reviewable, and versioned operations. This guide compares declarative and imperative approaches, covers Terraform and Pulumi, and provides best practices for production use.
Declarative vs Imperative
| Approach | You Say | Tool Handles | Examples |
|---|---|---|---|
| Declarative | ”I want this state” | How to get there | Terraform, CloudFormation, Pulumi |
| Imperative | ”Do these steps” | Execution order | Ansible, Shell scripts, SDK calls |
Declarative is preferred for infrastructure because it handles drift detection, dependency ordering, and idempotency automatically.
Terraform
HashiCorp’s declarative tool. Define resources in HCL (HashiCorp Configuration Language) and Terraform plans and applies the changes.
# main.tf — define a VPC and subnet
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "production-vpc" }
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
tags = { Name = "public-subnet" }
}
Terraform Workflow
terraform init # Download providers and modules
terraform plan # Preview changes
terraform apply # Execute changes
terraform destroy # Tear down (use with caution)
State Management
Terraform stores the mapping between your configuration and real resources in a state file.
| State Storage | Best For |
|---|---|
| Local file | Solo development only |
| S3 + DynamoDB | Team workflows with locking |
| Terraform Cloud | Collaboration, remote execution, policy checks |
# Remote state backend
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/vpc.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Critical rule: Never edit state files manually. Use terraform state commands or fix the configuration and re-apply.
Modules
Modules are reusable, parameterized infrastructure components.
# modules/vpc/main.tf
variable "cidr_block" { type = string }
variable "azs" { type = list(string) }
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
tags = { Name = "vpc" }
}
# root module usage
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
}
Best practice: Publish internal modules to a private registry. Version them like software.
Pulumi
Pulumi uses general-purpose programming languages (TypeScript, Python, Go, C#) instead of HCL.
// TypeScript: define a VPC with Pulumi
import * as aws from "@pulumi/aws";
const vpc = new aws.ec2.Vpc("main", {
cidrBlock: "10.0.0.0/16",
enableDnsHostnames: true,
tags: { Name: "production-vpc" },
});
const subnet = new aws.ec2.Subnet("public", {
vpcId: vpc.id,
cidrBlock: "10.0.1.0/24",
tags: { Name: "public-subnet" },
});
export const vpcId = vpc.id;
When to Choose Pulumi Over Terraform
| Use Pulumi When | Use Terraform When |
|---|---|
| Your team prefers programming languages over HCL | Your team already knows HCL |
| You need complex logic (loops, conditionals, abstraction) | Your infrastructure is mostly static resource declarations |
| You want to test infrastructure with unit tests | You want the largest ecosystem of modules and community support |
| You are building a platform or internal developer tooling | You need maturity and broad provider support |
Testing Infrastructure
Static Analysis
Validate before applying.
# Terraform: format and validate
terraform fmt -check
terraform validate
# TFLint: catch provider-specific mistakes
tflint --deep
# Checkov / Terraform-compliance: security policies
checkov -d .
Plan Review
Always review the plan in CI before applying.
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
# CI parses plan.json and checks for destructive changes
Integration Testing
Test that the infrastructure actually works.
| Tool | Approach |
|---|---|
| Terratest (Go) | Apply infrastructure, run assertions, destroy |
| Kitchen-Terraform | Ruby-based testing with Inspec |
| Pulumi unit tests | Mock providers and assert resource properties |
// Terratest example
func TestVpc(t *testing.T) {
terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
TerraformDir: "../examples/vpc",
})
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcId)
}
Best Practices
- Store code in version control — every infrastructure change is a PR, reviewed, and logged
- Use remote state with locking — prevents concurrent modifications corrupting state
- Separate environments — use workspaces or separate state files per environment
- Use modules for reusability — but avoid over-abstraction; simple is better
- Never commit secrets — use secret managers (AWS Secrets Manager, Vault) and reference by ARN
- Document your modules — README with inputs, outputs, and usage examples
- Pin provider versions — prevent breaking changes from automatic provider updates
Common Mistakes
- Running
terraform applylocally instead of through CI/CD - Storing state files in Git (contains sensitive resource IDs and sometimes secrets)
- Not using workspaces or separate directories for environments
- Writing giant monolithic Terraform configs instead of modular components
- Ignoring plan output — the plan tells you what will be destroyed; read it
- Using
countorfor_eachwith resources that cannot be recreated without downtime
Frequently Asked Questions
Can I use Terraform and Pulumi together?
Yes, via Terraform state references or Pulumi’s Terraform bridge. Migrate gradually: import existing Terraform state into Pulumi, or manage new resources with Pulumi while Terraform handles legacy.
How do I handle secrets in IaC?
Never hardcode secrets. Use:
- Environment variables for non-sensitive config
- Secret managers (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault) for sensitive values
- Terraform
sensitive = trueon outputs to prevent logging
Should I apply Terraform from my laptop or CI/CD?
Always from CI/CD. Local applies are untraceable, unreviewed, and bypass approval workflows. Use Terraform Cloud, Atlantis, or a GitOps pipeline for all production changes.