Introduction
Infrastructure as Code (IaC) has transformed how teams provision and manage cloud infrastructure. Instead of manually clicking through cloud consoles, IaC enables declarative definitions of infrastructure that are version-controlled, tested, and automated. This shift brings dramatic improvements in consistency, velocity, and reliability.
Understanding IaC tools and practices is essential for modern cloud engineering. Whether managing a few resources or complex multi-environment architectures, IaC provides the foundation for reproducible, manageable infrastructure.
This comprehensive guide examines IaC across major tools. We explore Terraform, CloudFormation, and Pulumiโunderstanding their strengths, tradeoffs, and appropriate use cases. Whether establishing IaC practices or improving existing implementations, this guide provides the knowledge necessary for success.
Understanding Infrastructure as Code
IaC defines infrastructure through code rather than manual processes.
Benefits of IaC
- Consistency: Same infrastructure every time
- Version Control: Track changes, enable code review
- Automation: Reduce manual effort, improve velocity
- Testing: Validate infrastructure before deployment
- Documentation: Code serves as documentation
IaC Approaches
| Approach | Description | Tools |
|---|---|---|
| Declarative | Define desired state | Terraform, Pulumi, CloudFormation |
| Imperative | Define steps to achieve state | AWS CLI, scripts |
Terraform
Terraform uses a declarative approach with a domain-specific language (HCL).
Basic Configuration
# main.tf
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "terraform-state-bucket"
key = "prod/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "main-vpc"
Environment = "production"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
tags = {
Name = "public-subnet"
}
}
Variables and Outputs
# variables.tf
variable "environment" {
description = "Environment name"
type = string
default = "production"
}
variable "vpc_cidr" {
description = "VPC CIDR block"
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
description = "Availability zones"
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
# outputs.tf
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.main.id
}
output "subnet_ids" {
description = "Subnet IDs"
value = aws_subnet.public[*].id
}
Modules
# modules/vpc/main.tf
module "vpc" {
source = "./modules/vpc"
environment = var.environment
cidr_block = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
tags = {
ManagedBy = "Terraform"
}
}
# modules/vpc/variables.tf
variable "environment" {
type = string
}
variable "cidr_block" {
type = string
}
variable "availability_zones" {
type = list(string)
}
variable "tags" {
type = map(string)
default = {}
}
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
tags = merge(var.tags, {
Environment = var.environment
})
}
State Management
# Terraform workflow
terraform init # Initialize working directory
terraform plan # Generate execution plan
terraform apply # Apply changes
terraform destroy # Destroy resources
terraform show # Show current state
terraform state list # List resources
Workspaces
# Create workspace
terraform workspace new production
# Switch workspace
terraform workspace select production
# Use workspace in config
resource "aws_instance" "app" {
instance_type = terraform.workspace == "production" ? "t3.large" : "t3.micro"
tags = {
Environment = terraform.workspace
}
}
AWS CloudFormation
CloudFormation provides AWS-native infrastructure as code.
Template Structure
AWSTemplateFormatVersion: '2010-09-09'
Description: 'VPC Network Template'
Parameters:
Environment:
Type: String
Default: production
AllowedValues:
- production
- staging
- development
VPCCidr:
Type: String
Default: 10.0.0.0/16
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Ref VPCCidr
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: !Sub ${Environment}-vpc
PublicSubnet:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
CidrBlock: 10.0.1.0/24
AvailabilityZone: !Select [0, !Ref AWS::NoValue]
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub ${Environment}-public-subnet
Outputs:
VPCId:
Description: VPC ID
Value: !Ref VPC
Export:
Name: !Sub ${AWS::StackName}-VPC-ID
Nested Stacks
# root-stack.yaml
Resources:
NetworkStack:
Type: AWS::CloudFormation::Stack
Properties:
TemplateURL: https://s3.amazonaws.com/templates-bucket/network.yaml
Parameters:
Environment: !Ref Environment
VPCCidr: !Ref VPCCidr
DatabaseStack:
Type: AWS::CloudFormation::Stack
Properties:
TemplateURL: https://s3.amazonaws.com/templates-bucket/database.yaml
Parameters:
VPCId: !GetAtt NetworkStack.Outputs.VPCId
SubnetIds: !GetAtt NetworkStack.Outputs.SubnetIds
Change Sets
# Create change set
aws cloudformation create-change-set \
--stack-name my-stack \
--template-body file://template.yaml \
--change-set-type UPDATE \
--change-set-name my-changes
# Execute change set
aws cloudformation execute-change-set \
--stack-name my-stack \
--change-set-name $(aws cloudformation list-change-sets --stack-name my-stack --query 'Summaries[0].ChangeSetName' --output text)
Pulumi
Pulumi uses general-purpose programming languages for infrastructure.
Program Structure
import pulumi
import pulumi_aws as aws
# Create VPC
vpc = aws.ec2.Vpc(
"main",
cidr_block="10.0.0.0/16",
enable_dns_hostnames=True,
enable_dns_support=True,
tags={
"Name": "main-vpc",
"Environment": "production"
}
)
# Create subnet
subnet = aws.ec2.Subnet(
"public",
vpc_id=vpc.id,
cidr_block="10.0.1.0/24",
availability_zone="us-east-1a",
map_public_ip_on_launch=True,
tags={
"Name": "public-subnet"
}
)
# Export values
pulumi.export("vpc_id", vpc.id)
pulumi.export("subnet_id", subnet.id)
Configuration and Secrets
import pulumi
from pulumi import Config
config = Config()
# String config
environment = config.require("environment")
# Secret config
api_key = config.require_secret("apiKey")
# Secret with default
db_password = config.get_secret("dbPassword") or "default-password"
# Require object
database_config = require_object("database")
Component Resources
class NetworkComponent(pulumi.ComponentResource):
def __init__(self, name, opts=None):
super().__init__("custom:network:Network", name, opts=opts)
# VPC
self.vpc = aws.ec2.Vpc(
f"{name}-vpc",
cidr_block="10.0.0.0/16",
opts=pulumi.ResourceOptions(parent=self)
)
# Subnet
self.subnet = aws.ec2.Subnet(
f"{name}-subnet",
vpc_id=self.vpc.id,
cidr_block="10.0.1.0/24",
opts=pulumi.ResourceOptions(parent=self)
)
self.register_outputs({
"vpc_id": self.vpc.id,
"subnet_id": self.subnet.id
})
# Usage
network = NetworkComponent("main")
Best Practices
State Management
# Terraform with remote state
terraform {
backend "s3" {
bucket = "terraform-state-prod"
key = "network/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Testing Infrastructure
# Terratest example
import pytest
import terraform
import aws
def test_vpc_created():
# Run terraform
options = terraform.Options(
terraform_dir=".",
vars={
"environment": "test"
}
)
# Apply and get outputs
output = terraform.apply(options, return_output=True)
# Verify VPC exists
vpc_id = output["vpc_id"]
vpc = aws.ec2.Vpc(vpc_id)
assert vpc.cidr_block == "10.0.0.0/16"
# Cleanup
terraform.destroy(options)
CI/CD Integration
# GitHub Actions workflow
name: Terraform CI
on:
pull_request:
paths:
- '**.tf'
push:
paths:
- '**.tf'
branches:
- main
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.0
- name: Terraform Init
run: terraform init
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Terraform Plan
run: terraform plan -no-color
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Terraform Apply
if: github.ref == 'refs/heads/main'
run: terraform apply -auto-approve
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Tool Comparison
| Feature | Terraform | CloudFormation | Pulumi |
|---|---|---|---|
| Language | HCL | YAML/JSON | Python, TS, Go |
| State | File/Remote | Managed | File/Remote |
| Providers | Many | AWS only | Many |
| Testing | Terratest | CFN Nitro | Native testing |
| Learning Curve | Medium | Low | Medium |
Conclusion
Infrastructure as Code is essential for modern cloud engineering. Understanding Terraform, CloudFormation, and Pulumi enables informed decisions about which tools to use and how to implement IaC practices effectively.
Key practices include starting with remote state and state locking, organizing code into modules for reuse, implementing testing as part of the workflow, and integrating with CI/CD for automated deployments. The investment in IaC pays dividends through improved consistency, velocity, and reliability.
As infrastructure grows in complexity, IaC becomes increasingly critical. Choose the tool that fits your team’s skills and requirements, then invest in building robust IaC practices.
Comments