CI/CD — Infrastructure as Code
Infrastructure as Code (IaC) is the practice of managing and provisioning infrastructure through machine-readable configuration files rather than manual processes. IaC enables version-controlled, repeatable, and auditable infrastructure changes.
This section covers Terraform, Pulumi, and AWS CloudFormation — the three dominant IaC tools — along with state management, module design, testing, and integrating IaC into CI/CD pipelines.
IaC treats infrastructure configuration as software. Instead of clicking through a cloud console or running ad-hoc CLI commands, you define resources in code, commit that code to Git, and apply it through automated pipelines.
| Approach | Description |
|---|---|
| Imperative | Step-by-step commands to create resources (CLI scripts) |
| Declarative | Describe desired end state; the tool figures out the steps (Terraform, CloudFormation) |
| Programmatic | Use a general-purpose language to define infrastructure (Pulumi, CDK) |
info
Terraform by HashiCorp is the most widely adopted IaC tool. It uses HCL (HashiCorp Configuration Language) to define resources and supports all major cloud providers through a plugin-based provider system.
| 1 | # main.tf — Provision an AWS ECS service |
| 2 | terraform { |
| 3 | required_version = ">= 1.6" |
| 4 | required_providers { |
| 5 | aws = { |
| 6 | source = "hashicorp/aws" |
| 7 | version = "~> 5.0" |
| 8 | } |
| 9 | } |
| 10 | |
| 11 | backend "s3" { |
| 12 | bucket = "myorg-terraform-state" |
| 13 | key = "production/vpc/terraform.tfstate" |
| 14 | region = "us-east-1" |
| 15 | dynamodb_table = "terraform-locks" |
| 16 | encrypt = true |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | provider "aws" { |
| 21 | region = var.aws_region |
| 22 | } |
| 23 | |
| 24 | variable "aws_region" { |
| 25 | type = string |
| 26 | default = "us-east-1" |
| 27 | } |
| 28 | |
| 29 | resource "aws_vpc" "main" { |
| 30 | cidr_block = "10.0.0.0/16" |
| 31 | enable_dns_hostnames = true |
| 32 | enable_dns_support = true |
| 33 | |
| 34 | tags = { |
| 35 | Name = "production-vpc" |
| 36 | Environment = "production" |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | resource "aws_subnet" "public" { |
| 41 | count = 3 |
| 42 | vpc_id = aws_vpc.main.id |
| 43 | cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index) |
| 44 | availability_zone = data.aws_availability_zones.available.names[count.index] |
| 45 | map_public_ip_on_launch = true |
| 46 | } |
| 47 | |
| 48 | output "vpc_id" { |
| 49 | value = aws_vpc.main.id |
| 50 | } |
| 1 | # Terraform workflow |
| 2 | terraform init # Download providers and initialize backend |
| 3 | terraform plan # Preview changes (always run before apply) |
| 4 | terraform apply # Apply the planned changes |
| 5 | terraform destroy # Tear down all managed resources |
| 6 | |
| 7 | # Import existing infrastructure |
| 8 | terraform import aws_vpc.main vpc-abc123 |
| 9 | |
| 10 | # Format and validate |
| 11 | terraform fmt -recursive |
| 12 | terraform validate |
warning
Pulumi lets you define infrastructure using general-purpose programming languages (TypeScript, Python, Go, C#). You get the full power of loops, conditionals, functions, and type checking — no DSL required.
| 1 | import * as pulumi from "@pulumi/pulumi"; |
| 2 | import * as aws from "@pulumi/aws"; |
| 3 | |
| 4 | // Create a VPC with subnets |
| 5 | const vpc = new aws.ec2.Vpc("main", { |
| 6 | cidrBlock: "10.0.0.0/16", |
| 7 | enableDnsHostnames: true, |
| 8 | tags: { Name: "production-vpc" }, |
| 9 | }); |
| 10 | |
| 11 | const availabilityZones = aws.getAvailabilityZones(); |
| 12 | |
| 13 | const publicSubnets = availabilityZones.names.slice(0, 3).map( |
| 14 | (az, i) => |
| 15 | new aws.ec2.Subnet(`public-${i}`, { |
| 16 | vpcId: vpc.id, |
| 17 | cidrBlock: `10.0.${i}.0/24`, |
| 18 | availabilityZone: az, |
| 19 | mapPublicIpOnLaunch: true, |
| 20 | tags: { Name: `public-subnet-${i}` }, |
| 21 | }) |
| 22 | ); |
| 23 | |
| 24 | // Export the VPC ID |
| 25 | export const vpcId = vpc.id; |
| 1 | # Pulumi workflow |
| 2 | pulumi login s3://myorg-pulumi-state |
| 3 | pulumi stack select production |
| 4 | pulumi install # Install dependencies |
| 5 | pulumi preview # Preview changes |
| 6 | pulumi up # Apply changes |
| 7 | pulumi destroy # Tear down |
best practice
CloudFormation is AWS's native IaC service. It uses YAML/JSON templates and is tightly integrated with AWS services. The AWS CDK (Cloud Development Kit) lets you define CloudFormation templates using TypeScript, Python, or other languages.
| 1 | AWSTemplateFormatVersion: "2010-09-09" |
| 2 | Description: Production ECS Fargate Service |
| 3 | |
| 4 | Parameters: |
| 5 | Environment: |
| 6 | Type: String |
| 7 | Default: production |
| 8 | ImageUri: |
| 9 | Type: String |
| 10 | |
| 11 | Resources: |
| 12 | Cluster: |
| 13 | Type: AWS::ECS::Cluster |
| 14 | Properties: |
| 15 | ClusterName: !Sub "${Environment}-cluster" |
| 16 | |
| 17 | Service: |
| 18 | Type: AWS::ECS::Service |
| 19 | DependsOn: ALBListener |
| 20 | Properties: |
| 21 | Cluster: !Ref Cluster |
| 22 | DesiredCount: 2 |
| 23 | LaunchType: FARGATE |
| 24 | TaskDefinition: !Ref TaskDefinition |
| 25 | NetworkConfiguration: |
| 26 | AwsvpcConfiguration: |
| 27 | SecurityGroups: [!Ref ServiceSecurityGroup] |
| 28 | Subnets: !Ref PublicSubnets |
| 29 | |
| 30 | Outputs: |
| 31 | ClusterArn: |
| 32 | Value: !GetAtt Cluster.Arn |
info
State management tracks which resources are managed by IaC and their current configuration. Terraform stores state in a state file; losing or corrupting it can orphan resources and cause destructive applies.
Remote State
Store state in S3, GCS, or Azure Blob Storage. Never use local state files in team environments.
State Locking
Use DynamoDB (AWS) or similar to prevent concurrent state modifications that cause corruption.
State Encryption
Enable server-side encryption on the state bucket. State files contain secrets (database passwords, API keys).
| 1 | # Remote state with S3 backend |
| 2 | terraform { |
| 3 | backend "s3" { |
| 4 | bucket = "myorg-terraform-state" |
| 5 | key = "production/vpc/terraform.tfstate" |
| 6 | region = "us-east-1" |
| 7 | dynamodb_table = "terraform-locks" |
| 8 | encrypt = true |
| 9 | } |
| 10 | } |
| 11 | |
| 12 | # Reference outputs from another state file |
| 13 | data "terraform_remote_state" "vpc" { |
| 14 | backend = "s3" |
| 15 | config = { |
| 16 | bucket = "myorg-terraform-state" |
| 17 | key = "production/vpc/terraform.tfstate" |
| 18 | region = "us-east-1" |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | # Use the VPC ID from the remote state |
| 23 | resource "aws_ecs_service" "app" { |
| 24 | network_configuration { |
| 25 | subnets = data.terraform_remote_state.vpc.outputs.public_subnet_ids |
| 26 | } |
| 27 | } |
warning
Terraform modules are reusable, composable packages of infrastructure code. A well-designed module encapsulates a set of related resources with a clean input/output interface.
| 1 | # modules/ecs-service/main.tf |
| 2 | variable "service_name" { |
| 3 | type = string |
| 4 | } |
| 5 | |
| 6 | variable "image_uri" { |
| 7 | type = string |
| 8 | } |
| 9 | |
| 10 | variable "port" { |
| 11 | type = number |
| 12 | default = 80 |
| 13 | } |
| 14 | |
| 15 | variable "desired_count" { |
| 16 | type = number |
| 17 | default = 2 |
| 18 | } |
| 19 | |
| 20 | resource "aws_ecs_task_definition" "this" { |
| 21 | family = var.service_name |
| 22 | requires_compatibilities = ["FARGATE"] |
| 23 | network_mode = "awsvpc" |
| 24 | cpu = 256 |
| 25 | memory = 512 |
| 26 | |
| 27 | container_definitions = jsonencode([{ |
| 28 | name = var.service_name |
| 29 | image = var.image_uri |
| 30 | portMappings = [{ |
| 31 | containerPort = var.port |
| 32 | protocol = "tcp" |
| 33 | }] |
| 34 | }]) |
| 35 | } |
| 36 | |
| 37 | output "task_definition_arn" { |
| 38 | value = aws_ecs_task_definition.this.arn |
| 39 | } |
| 40 | |
| 41 | # Root module usage |
| 42 | module "api_service" { |
| 43 | source = "./modules/ecs-service" |
| 44 | service_name = "api" |
| 45 | image_uri = "ghcr.io/myorg/api:v1.0.0" |
| 46 | port = 3000 |
| 47 | } |
| 48 | |
| 49 | module "worker_service" { |
| 50 | source = "./modules/ecs-service" |
| 51 | service_name = "worker" |
| 52 | image_uri = "ghcr.io/myorg/worker:v1.0.0" |
| 53 | desired_count = 1 |
| 54 | } |
best practice
Integrating IaC into CI/CD ensures infrastructure changes go through the same review, testing, and approval processes as application code.
| 1 | name: Terraform Plan & Apply |
| 2 | on: |
| 3 | pull_request: |
| 4 | paths: ["infrastructure/**"] |
| 5 | push: |
| 6 | branches: [main] |
| 7 | paths: ["infrastructure/**"] |
| 8 | |
| 9 | jobs: |
| 10 | plan: |
| 11 | runs-on: ubuntu-latest |
| 12 | steps: |
| 13 | - uses: actions/checkout@v4 |
| 14 | - uses: hashicorp/setup-terraform@v3 |
| 15 | with: |
| 16 | terraform_version: "1.7" |
| 17 | |
| 18 | - name: Terraform Init |
| 19 | run: terraform init |
| 20 | working-directory: infrastructure |
| 21 | |
| 22 | - name: Terraform Plan |
| 23 | run: terraform plan -out=tfplan |
| 24 | working-directory: infrastructure |
| 25 | |
| 26 | - name: Comment plan on PR |
| 27 | if: github.event_name == 'pull_request' |
| 28 | uses: actions/github-script@v7 |
| 29 | with: |
| 30 | script: | |
| 31 | const plan = execSync('terraform show -no-color tfplan', { |
| 32 | cwd: 'infrastructure' |
| 33 | }); |
| 34 | github.rest.issues.createComment({ |
| 35 | ...context.repo, |
| 36 | issue_number: context.issue.number, |
| 37 | body: `## Terraform Plan\n```\n${plan}\n``` |
| 38 | }); |
| 39 | |
| 40 | apply: |
| 41 | if: github.ref == 'refs/heads/main' |
| 42 | needs: plan |
| 43 | runs-on: ubuntu-latest |
| 44 | environment: production |
| 45 | steps: |
| 46 | - uses: actions/checkout@v4 |
| 47 | - uses: hashicorp/setup-terraform@v3 |
| 48 | - run: terraform init |
| 49 | working-directory: infrastructure |
| 50 | - run: terraform apply -auto-approve |
| 51 | working-directory: infrastructure |
info
Secrets in IaC require careful handling. Never hardcode credentials in configuration files. Use external secret managers and the appropriate provider integrations.
AWS: Use SSM Parameter Store or Secrets Manager
Reference secrets via data sources instead of embedding them in state.
Use Environment Variables for Provider Credentials
AWS_ACCESS_KEY_ID andAWS_SECRET_ACCESS_KEY as env vars, not in code. Prefer IAM roles in CI.
Scan for Hardcoded Secrets
Use tfsec, checkov, or trivy to scan IaC code for secrets, misconfigurations, and security violations before merging.
Infrastructure code should be tested like application code. Use static analysis, plan validation, and policy-as-code to catch issues before they reach production.
| Tool | Type | Purpose |
|---|---|---|
| tfsec / trivy | Static analysis | Scan for security misconfigurations |
| checkov | Policy as code | Enforce organizational policies (e.g., encryption, tagging) |
| Terratest | Integration testing | Deploy real infrastructure and validate with Go tests |
| conftest / OPA | Policy evaluation | Write Rego policies for plan output validation |
✓ Use remote state with locking
Never use local state files in team environments. Store state in S3/GCS/Azure with DynamoDB or equivalent locking.
✓ Modularize infrastructure
Break infrastructure into reusable modules with clear interfaces. Publish modules to a registry for cross-team reuse.
✓ Plan before every apply
Review the plan output in CI before approving. Use policy-as-code to auto-approve safe changes and require manual review for risky ones.
✓ Tag all resources
Consistent tagging enables cost allocation, access control, and resource discovery. Enforce tags with policy-as-code.