Infrastructure as Code
Best practices for managing cloud infrastructure declaratively with Terraform, Pulumi, CloudFormation, and OpenTofu. Covers module composition, state management, security scanning, and drift prevention.
IaC Tool Comparison
Choose the right tool based on team skills, cloud strategy, and operational requirements.
| Tool | Language | State Management | Multi-Cloud | Learning Curve | Ecosystem | |------|----------|-----------------|-------------|----------------|-----------| | Terraform | HCL | Remote backend (S3, GCS, etc.) | Excellent | Medium | Largest provider ecosystem | | OpenTofu | HCL | Same as Terraform | Excellent | Medium | Fork-compatible with Terraform | | Pulumi | TypeScript, Python, Go, C# | Pulumi Cloud or self-managed | Excellent | Low for developers | Growing, SDK-based | | CloudFormation | YAML/JSON | AWS-managed | AWS only | Medium | Native AWS integration | | CDK | TypeScript, Python, Java, Go | AWS-managed (synths to CFN) | AWS only | Low for developers | Leverages CFN resources |
| Decision Factor | Recommendation | |-----------------|---------------| | Multi-cloud required | Terraform or Pulumi | | AWS-only shop | CloudFormation or CDK | | Team knows TypeScript | Pulumi or CDK | | Need open-source license | OpenTofu | | Existing Terraform codebase | Stay Terraform or migrate to OpenTofu | | Complex logic and loops | Pulumi (general-purpose language) |
State Management Patterns
State is the source of truth for what IaC has provisioned. Mismanaging state causes orphaned resources, duplicate deployments, and data loss.
+------------------+ +------------------+ +------------------+
| Developer CLI | ----> | Remote Backend | <---- | CI/CD Pipeline |
+------------------+ +------------------+ +------------------+
| - S3 + DynamoDB |
| - GCS + Lock |
| - Terraform Cloud|
+------------------+
| Pattern | When to Use | Implementation | |---------|------------|----------------| | Single state file | Small projects, <20 resources | One backend config | | State per environment | Separate dev/staging/prod | Workspace or directory per env | | State per component | Large infra, team boundaries | Separate root modules with data sources | | Hierarchical state | Complex multi-team orgs | Layers: network -> compute -> app |
Terraform Remote State Configuration
# backend.tf -- Remote state with locking
terraform {
backend "s3" {
bucket = "myorg-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
# State locking table -- provision FIRST with a bootstrap module
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
# Cross-stack references via remote state data source
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "myorg-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
}
Module Composition and Versioning
Modules are the unit of reuse in Terraform and OpenTofu. Good module design follows the single-responsibility principle.
modules/
vpc/
main.tf # Resources
variables.tf # Input variables with validation
outputs.tf # Output values for consumers
versions.tf # Required providers and versions
environments/
dev/
main.tf # Calls modules with dev parameters
backend.tf # Dev state backend
terraform.tfvars # Dev variable values
prod/
main.tf
backend.tf
terraform.tfvars
Terraform Module Example
# modules/vpc/variables.tf
variable "name" {
description = "Name prefix for all VPC resources"
type = string
validation {
condition = length(var.name) <= 24
error_message = "Name must be 24 characters or fewer."
}
}
variable "cidr_block" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "availability_zones" {
type = list(string)
}
variable "tags" {
type = map(string)
default = {}
}
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, { Name = "${var.name}-vpc" })
}
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index)
availability_zone = var.availability_zones[count.index]
tags = merge(var.tags, {
Name = "${var.name}-public-${var.availability_zones[count.index]}"
Tier = "public"
})
}
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index + length(var.availability_zones))
availability_zone = var.availability_zones[count.index]
tags = merge(var.tags, {
Name = "${var.name}-private-${var.availability_zones[count.index]}"
Tier = "private"
})
}
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
# environments/prod/main.tf -- Consuming the module
module "vpc" {
source = "git::https://github.com/myorg/terraform-modules.git//vpc?ref=v2.1.0"
name = "prod"
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
tags = { Environment = "production", Team = "platform" }
}
| Versioning Strategy | Source Format | When to Use | |---------------------|-------------|-------------| | Git tag | git::https://...?ref=v2.1.0 | Internal modules, full control | | Terraform Registry | source = "hashicorp/consul/aws" version = "0.1.0" | Public modules | | Local path | source = "../../modules/vpc" | Monorepo, development | | S3/GCS archive | source = "s3::https://..." | Air-gapped environments |
Pulumi TypeScript Example
Pulumi uses general-purpose languages, giving full IDE support, type checking, and testing capabilities.
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
const config = new pulumi.Config();
const env = pulumi.getStack();
const vpcCidr = config.get("vpcCidr") || "10.0.0.0/16";
class VpcComponent extends pulumi.ComponentResource {
public readonly vpcId: pulumi.Output<string>;
public readonly publicSubnetIds: pulumi.Output<string>[];
public readonly privateSubnetIds: pulumi.Output<string>[];
constructor(name: string, args: {
cidr: string; azCount: number; enableNat: boolean;
}, opts?: pulumi.ComponentResourceOptions) {
super("custom:network:Vpc", name, {}, opts);
const vpc = new aws.ec2.Vpc(`${name}-vpc`, {
cidrBlock: args.cidr,
enableDnsHostnames: true,
enableDnsSupport: true,
tags: { Name: `${name}-vpc`, Environment: env },
}, { parent: this });
this.vpcId = vpc.id;
this.publicSubnetIds = [];
this.privateSubnetIds = [];
const azs = aws.getAvailabilityZones({ state: "available" });
for (let i = 0; i < args.azCount; i++) {
const pub = new aws.ec2.Subnet(`${name}-public-${i}`, {
vpcId: vpc.id,
cidrBlock: `10.0.${i}.0/24`,
availabilityZone: azs.then(az => az.names[i]),
mapPublic