Back to Blog
InfrastructureTerraformDevOpsCloud

Infrastructure as Code Best Practices

Manage infrastructure with code. From Terraform basics to modular design to state management.

B
Bootspring Team
Engineering
February 12, 2023
6 min read

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.example

CI/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-approve

Security 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.

Share this article

Help spread the word about Bootspring