Infrastructure as Code (IaC) treats infrastructure configuration like application code—version controlled, tested, and automated. Here's how to do it well.
Why IaC?#
Benefits:
- Reproducible environments
- Version controlled changes
- Automated deployments
- Self-documenting infrastructure
- Disaster recovery
- Consistency across environments
Tools:
- Terraform: Multi-cloud, declarative
- Pulumi: Real programming languages
- CloudFormation: AWS native
- CDK: AWS with TypeScript/Python
Terraform Basics#
1# main.tf - Define resources
2terraform {
3 required_version = ">= 1.0"
4
5 required_providers {
6 aws = {
7 source = "hashicorp/aws"
8 version = "~> 5.0"
9 }
10 }
11
12 backend "s3" {
13 bucket = "my-terraform-state"
14 key = "production/terraform.tfstate"
15 region = "us-east-1"
16 encrypt = true
17 dynamodb_table = "terraform-locks"
18 }
19}
20
21provider "aws" {
22 region = var.aws_region
23
24 default_tags {
25 tags = {
26 Environment = var.environment
27 ManagedBy = "terraform"
28 Project = var.project_name
29 }
30 }
31}
32
33# VPC
34resource "aws_vpc" "main" {
35 cidr_block = var.vpc_cidr
36 enable_dns_hostnames = true
37 enable_dns_support = true
38
39 tags = {
40 Name = "${var.project_name}-vpc"
41 }
42}
43
44# Subnets
45resource "aws_subnet" "public" {
46 count = length(var.availability_zones)
47 vpc_id = aws_vpc.main.id
48 cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
49 availability_zone = var.availability_zones[count.index]
50 map_public_ip_on_launch = true
51
52 tags = {
53 Name = "${var.project_name}-public-${count.index + 1}"
54 Type = "public"
55 }
56}
57
58resource "aws_subnet" "private" {
59 count = length(var.availability_zones)
60 vpc_id = aws_vpc.main.id
61 cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + length(var.availability_zones))
62 availability_zone = var.availability_zones[count.index]
63
64 tags = {
65 Name = "${var.project_name}-private-${count.index + 1}"
66 Type = "private"
67 }
68}Variables and Outputs#
1# variables.tf
2variable "aws_region" {
3 description = "AWS region"
4 type = string
5 default = "us-east-1"
6}
7
8variable "environment" {
9 description = "Environment name"
10 type = string
11
12 validation {
13 condition = contains(["development", "staging", "production"], var.environment)
14 error_message = "Environment must be development, staging, or production."
15 }
16}
17
18variable "vpc_cidr" {
19 description = "CIDR block for VPC"
20 type = string
21 default = "10.0.0.0/16"
22}
23
24variable "availability_zones" {
25 description = "Availability zones"
26 type = list(string)
27 default = ["us-east-1a", "us-east-1b", "us-east-1c"]
28}
29
30variable "database_password" {
31 description = "Database password"
32 type = string
33 sensitive = true
34}
35
36# outputs.tf
37output "vpc_id" {
38 description = "VPC ID"
39 value = aws_vpc.main.id
40}
41
42output "public_subnet_ids" {
43 description = "Public subnet IDs"
44 value = aws_subnet.public[*].id
45}
46
47output "database_endpoint" {
48 description = "Database endpoint"
49 value = aws_db_instance.main.endpoint
50 sensitive = true
51}Modular Design#
1# modules/vpc/main.tf
2variable "name" {
3 type = string
4}
5
6variable "cidr" {
7 type = string
8 default = "10.0.0.0/16"
9}
10
11variable "azs" {
12 type = list(string)
13}
14
15resource "aws_vpc" "this" {
16 cidr_block = var.cidr
17 enable_dns_hostnames = true
18 enable_dns_support = true
19
20 tags = {
21 Name = var.name
22 }
23}
24
25resource "aws_subnet" "public" {
26 count = length(var.azs)
27 vpc_id = aws_vpc.this.id
28 cidr_block = cidrsubnet(var.cidr, 4, count.index)
29 availability_zone = var.azs[count.index]
30 map_public_ip_on_launch = true
31
32 tags = {
33 Name = "${var.name}-public-${count.index + 1}"
34 }
35}
36
37output "vpc_id" {
38 value = aws_vpc.this.id
39}
40
41output "public_subnet_ids" {
42 value = aws_subnet.public[*].id
43}
44
45# Using the module
46module "vpc" {
47 source = "./modules/vpc"
48
49 name = "${var.project_name}-${var.environment}"
50 cidr = "10.0.0.0/16"
51 azs = ["us-east-1a", "us-east-1b"]
52}
53
54module "database" {
55 source = "./modules/rds"
56
57 name = "${var.project_name}-db"
58 vpc_id = module.vpc.vpc_id
59 subnet_ids = module.vpc.private_subnet_ids
60 instance_class = var.environment == "production" ? "db.r5.large" : "db.t3.micro"
61 password = var.database_password
62}Environment Separation#
1# environments/production/main.tf
2terraform {
3 backend "s3" {
4 bucket = "terraform-state"
5 key = "production/terraform.tfstate"
6 region = "us-east-1"
7 }
8}
9
10module "infrastructure" {
11 source = "../../modules/infrastructure"
12
13 environment = "production"
14 vpc_cidr = "10.0.0.0/16"
15
16 # Production settings
17 instance_count = 3
18 instance_type = "t3.large"
19 rds_instance = "db.r5.large"
20 multi_az = true
21}
22
23# environments/staging/main.tf
24terraform {
25 backend "s3" {
26 bucket = "terraform-state"
27 key = "staging/terraform.tfstate"
28 region = "us-east-1"
29 }
30}
31
32module "infrastructure" {
33 source = "../../modules/infrastructure"
34
35 environment = "staging"
36 vpc_cidr = "10.1.0.0/16"
37
38 # Staging settings (smaller)
39 instance_count = 1
40 instance_type = "t3.small"
41 rds_instance = "db.t3.micro"
42 multi_az = false
43}State Management#
1# Remote state with locking
2terraform {
3 backend "s3" {
4 bucket = "my-terraform-state"
5 key = "production/terraform.tfstate"
6 region = "us-east-1"
7 encrypt = true
8 dynamodb_table = "terraform-locks"
9 }
10}
11
12# State locking table
13resource "aws_dynamodb_table" "terraform_locks" {
14 name = "terraform-locks"
15 billing_mode = "PAY_PER_REQUEST"
16 hash_key = "LockID"
17
18 attribute {
19 name = "LockID"
20 type = "S"
21 }
22}
23
24# Import existing resources
25# terraform import aws_instance.example i-1234567890abcdef0
26
27# Move resources between states
28# terraform state mv aws_instance.old aws_instance.new
29
30# Remove from state without destroying
31# terraform state rm aws_instance.exampleCI/CD Integration#
1# .github/workflows/terraform.yml
2name: Terraform
3
4on:
5 push:
6 branches: [main]
7 pull_request:
8 branches: [main]
9
10env:
11 TF_VERSION: 1.5.0
12
13jobs:
14 validate:
15 runs-on: ubuntu-latest
16 steps:
17 - uses: actions/checkout@v4
18
19 - uses: hashicorp/setup-terraform@v3
20 with:
21 terraform_version: ${{ env.TF_VERSION }}
22
23 - name: Terraform Format
24 run: terraform fmt -check -recursive
25
26 - name: Terraform Init
27 run: terraform init -backend=false
28
29 - name: Terraform Validate
30 run: terraform validate
31
32 plan:
33 needs: validate
34 runs-on: ubuntu-latest
35 if: github.event_name == 'pull_request'
36 steps:
37 - uses: actions/checkout@v4
38
39 - uses: hashicorp/setup-terraform@v3
40 with:
41 terraform_version: ${{ env.TF_VERSION }}
42
43 - name: Terraform Init
44 run: terraform init
45 env:
46 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
47 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
48
49 - name: Terraform Plan
50 run: terraform plan -out=plan.tfplan
51
52 - name: Comment Plan
53 uses: actions/github-script@v6
54 with:
55 script: |
56 // Post plan output as PR comment
57
58 apply:
59 needs: validate
60 runs-on: ubuntu-latest
61 if: github.ref == 'refs/heads/main' && github.event_name == 'push'
62 environment: production
63 steps:
64 - uses: actions/checkout@v4
65
66 - uses: hashicorp/setup-terraform@v3
67 with:
68 terraform_version: ${{ env.TF_VERSION }}
69
70 - name: Terraform Init
71 run: terraform init
72
73 - name: Terraform Apply
74 run: terraform apply -auto-approveSecurity Best Practices#
1# Use data sources for sensitive values
2data "aws_secretsmanager_secret_version" "db_password" {
3 secret_id = "production/database/password"
4}
5
6resource "aws_db_instance" "main" {
7 # ... other config
8 password = data.aws_secretsmanager_secret_version.db_password.secret_string
9}
10
11# Don't hardcode credentials
12# ❌ Bad
13provider "aws" {
14 access_key = "AKIAIOSFODNN7EXAMPLE"
15 secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
16}
17
18# ✅ Good - use environment variables or IAM roles
19provider "aws" {
20 region = "us-east-1"
21}
22
23# Mark sensitive outputs
24output "database_password" {
25 value = aws_db_instance.main.password
26 sensitive = true
27}Best Practices#
Organization:
✓ Use modules for reusability
✓ Separate environments
✓ Consistent naming conventions
✓ Pin provider versions
State Management:
✓ Remote state with locking
✓ Encrypt state at rest
✓ Separate state per environment
✓ Regular state backups
Security:
✓ No secrets in code
✓ Use IAM roles over keys
✓ Encrypt sensitive outputs
✓ Review plans before apply
Operations:
✓ Always run plan first
✓ Use CI/CD for changes
✓ Tag all resources
✓ Document modules
Conclusion#
Infrastructure as Code enables reliable, repeatable infrastructure management. Use modules for reusability, remote state for collaboration, and CI/CD for safe deployments. Treat your infrastructure code with the same care as application code.