...
...
#infrastructure #terraform #aws #devops #cloud

Infrastructure as Code: Terraform & CDK Guide

Infrastructure as Code guide: Terraform, CloudFormation & Pulumi. Automation, versioning, and best practices for cloud infrastructure.

V
VooStack Team
October 2, 2025
16 min read

Infrastructure as Code: Managing Cloud Resources with Terraform and CDK

Clicking through cloud consoles to create resources is fine for learning. For production? It’s a disaster waiting to happen.

I’ve seen companies lose entire environments because nobody documented the manual steps. I’ve debugged “works in staging, fails in production” issues caused by configuration drift. And I’ve watched teams spend weeks trying to replicate their infrastructure in a new region.

Infrastructure as Code (IaC) solves all of this. Your infrastructure becomes versioned, testable, and reproducible. Let me show you how.

Why Infrastructure as Code

The Problems IaC Solves

Configuration Drift: Manual changes create differences between environments. IaC keeps environments identical.

Lack of Documentation: Code is documentation. Want to know what’s in production? Read the Terraform files.

Difficult Replication: Spinning up a new environment means running terraform apply, not hours of console clicking.

No Version Control: Infrastructure changes are tracked in Git like code changes.

Disaster Recovery: Rebuild your entire infrastructure from scratch in minutes.

Terraform vs AWS CDK

Terraform

Pros:

  • Cloud-agnostic (AWS, Azure, GCP, 100+ providers)
  • Mature ecosystem
  • Declarative syntax
  • Large community

Cons:

  • HCL is another language to learn
  • State management can be tricky
  • Limited type safety

AWS CDK

Pros:

  • Use TypeScript/Python/Java (languages you know)
  • Type safety and IDE support
  • Higher-level abstractions
  • Better for complex logic

Cons:

  • AWS-only
  • Generates CloudFormation (can be messy)
  • Steeper learning curve

My Take: Use Terraform for multi-cloud or simple infrastructure. Use CDK for complex AWS-specific solutions.

Terraform Fundamentals

Basic Structure

# provider.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket = "my-terraform-state"
    key    = "prod/terraform.tfstate"
    region = "us-east-1"
  }
}

provider "aws" {
  region = var.aws_region
}

# variables.tf
variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "environment" {
  description = "Environment name"
  type        = string
}

# main.tf
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
  }
}

resource "aws_subnet" "public" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name = "${var.environment}-public-${count.index + 1}"
  }
}

data "aws_availability_zones" "available" {
  state = "available"
}

# outputs.tf
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "IDs of public subnets"
  value       = aws_subnet.public[*].id
}

Modules for Reusability

# modules/web-app/main.tf
resource "aws_security_group" "alb" {
  name        = "${var.name}-alb-sg"
  description = "Security group for ALB"
  vpc_id      = var.vpc_id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_lb" "main" {
  name               = "${var.name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = var.subnet_ids

  tags = {
    Name = "${var.name}-alb"
  }
}

resource "aws_lb_target_group" "app" {
  name     = "${var.name}-tg"
  port     = var.app_port
  protocol = "HTTP"
  vpc_id   = var.vpc_id

  health_check {
    enabled             = true
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
  }
}

# modules/web-app/variables.tf
variable "name" {
  description = "Name of the application"
  type        = string
}

variable "vpc_id" {
  description = "VPC ID"
  type        = string
}

variable "subnet_ids" {
  description = "Subnet IDs for ALB"
  type        = list(string)
}

variable "app_port" {
  description = "Application port"
  type        = number
  default     = 3000
}

# Using the module
module "web_app" {
  source = "./modules/web-app"

  name       = "myapp"
  vpc_id     = aws_vpc.main.id
  subnet_ids = aws_subnet.public[*].id
  app_port   = 3000
}

Managing Multiple Environments

# environments/prod/main.tf
module "infrastructure" {
  source = "../../modules/infrastructure"

  environment     = "prod"
  instance_type   = "t3.large"
  min_size        = 3
  max_size        = 10
  db_instance_class = "db.r6g.xlarge"
}

# environments/staging/main.tf
module "infrastructure" {
  source = "../../modules/infrastructure"

  environment     = "staging"
  instance_type   = "t3.small"
  min_size        = 1
  max_size        = 3
  db_instance_class = "db.t3.medium"
}

Remote State and Locking

terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

# Create S3 bucket and DynamoDB table for state
resource "aws_s3_bucket" "terraform_state" {
  bucket = "my-terraform-state"

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_s3_bucket_versioning" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

AWS CDK (TypeScript)

Basic Stack

import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecs_patterns from 'aws-cdk-lib/aws-ecs-patterns';
import { Construct } from 'constructs';

export class WebAppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC
    const vpc = new ec2.Vpc(this, 'VPC', {
      maxAzs: 2,
      natGateways: 1,
    });

    // ECS Cluster
    const cluster = new ecs.Cluster(this, 'Cluster', {
      vpc,
      containerInsights: true,
    });

    // Fargate Service with ALB
    const fargateService = new ecs_patterns.ApplicationLoadBalancedFargateService(
      this,
      'FargateService',
      {
        cluster,
        taskImageOptions: {
          image: ecs.ContainerImage.fromRegistry('my-app:latest'),
          containerPort: 3000,
          environment: {
            NODE_ENV: 'production',
          },
        },
        desiredCount: 2,
        cpu: 512,
        memoryLimitMiB: 1024,
      }
    );

    // Auto Scaling
    const scaling = fargateService.service.autoScaleTaskCount({
      minCapacity: 2,
      maxCapacity: 10,
    });

    scaling.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 70,
    });

    // Output
    new cdk.CfnOutput(this, 'LoadBalancerDNS', {
      value: fargateService.loadBalancer.loadBalancerDnsName,
    });
  }
}

Reusable Constructs

import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as rds from 'aws-cdk-lib/aws-rds';

export interface DatabaseProps {
  vpc: ec2.IVpc;
  instanceType?: ec2.InstanceType;
  multiAz?: boolean;
}

export class Database extends Construct {
  public readonly instance: rds.DatabaseInstance;

  constructor(scope: Construct, id: string, props: DatabaseProps) {
    super(scope, id);

    // Security Group
    const securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', {
      vpc: props.vpc,
      description: 'Database security group',
    });

    // Database Instance
    this.instance = new rds.DatabaseInstance(this, 'Instance', {
      engine: rds.DatabaseInstanceEngine.postgres({
        version: rds.PostgresEngineVersion.VER_15,
      }),
      instanceType: props.instanceType || ec2.InstanceType.of(
        ec2.InstanceClass.T3,
        ec2.InstanceSize.MICRO
      ),
      vpc: props.vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      },
      securityGroups: [securityGroup],
      multiAz: props.multiAz ?? false,
      allocatedStorage: 20,
      maxAllocatedStorage: 100,
      backupRetention: cdk.Duration.days(7),
      deletionProtection: true,
    });
  }

  public allowConnectionsFrom(other: ec2.IConnectable): void {
    this.instance.connections.allowDefaultPortFrom(other);
  }
}

// Usage
const database = new Database(this, 'Database', {
  vpc,
  multiAz: true,
});

database.allowConnectionsFrom(fargateService.service);

Environment-Specific Configuration

// bin/app.ts
import * as cdk from 'aws-cdk-lib';
import { WebAppStack } from '../lib/web-app-stack';

const app = new cdk.App();

// Production
new WebAppStack(app, 'WebApp-Prod', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: 'us-east-1',
  },
  config: {
    environment: 'prod',
    desiredCount: 3,
    instanceType: ec2.InstanceType.of(
      ec2.InstanceClass.T3,
      ec2.InstanceSize.LARGE
    ),
  },
});

// Staging
new WebAppStack(app, 'WebApp-Staging', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: 'us-east-1',
  },
  config: {
    environment: 'staging',
    desiredCount: 1,
    instanceType: ec2.InstanceType.of(
      ec2.InstanceClass.T3,
      ec2.InstanceSize.SMALL
    ),
  },
});

Advanced Patterns

Blue-Green Deployments

import * as codedeploy from 'aws-cdk-lib/aws-codedeploy';

const deploymentGroup = new codedeploy.EcsDeploymentGroup(
  this,
  'BlueGreenDeployment',
  {
    service: fargateService.service,
    blueGreenDeploymentConfig: {
      blueTargetGroup: fargateService.targetGroup,
      greenTargetGroup: greenTargetGroup,
      listener: fargateService.listener,
      testListener: testListener,
    },
    deploymentConfig: codedeploy.EcsDeploymentConfig.CANARY_10PERCENT_5MINUTES,
  }
);

Multi-Region Deployment

# terraform
provider "aws" {
  alias  = "us_east"
  region = "us-east-1"
}

provider "aws" {
  alias  = "eu_west"
  region = "eu-west-1"
}

module "us_infrastructure" {
  source = "./modules/infrastructure"
  providers = {
    aws = aws.us_east
  }
}

module "eu_infrastructure" {
  source = "./modules/infrastructure"
  providers = {
    aws = aws.eu_west
  }
}

# Route53 for global routing
resource "aws_route53_record" "global" {
  zone_id = aws_route53_zone.main.zone_id
  name    = "app.example.com"
  type    = "A"

  set_identifier = "US"
  latency_routing_policy {
    region = "us-east-1"
  }

  alias {
    name                   = module.us_infrastructure.alb_dns
    zone_id               = module.us_infrastructure.alb_zone_id
    evaluate_target_health = true
  }
}

Secrets Management

import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';

// Create secret
const dbSecret = new secretsmanager.Secret(this, 'DBSecret', {
  generateSecretString: {
    secretStringTemplate: JSON.stringify({ username: 'admin' }),
    generateStringKey: 'password',
    excludePunctuation: true,
  },
});

// Use in RDS
const database = new rds.DatabaseInstance(this, 'Database', {
  credentials: rds.Credentials.fromSecret(dbSecret),
  // ...
});

// Use in ECS
const taskDefinition = new ecs.FargateTaskDefinition(this, 'Task');

const container = taskDefinition.addContainer('app', {
  image: ecs.ContainerImage.fromRegistry('my-app'),
  secrets: {
    DB_PASSWORD: ecs.Secret.fromSecretsManager(dbSecret, 'password'),
  },
});

Testing Infrastructure

Terraform Validation

# Validate syntax
terraform validate

# Format code
terraform fmt -recursive

# Plan changes
terraform plan -out=plan.tfplan

# Show plan in readable format
terraform show plan.tfplan

Terraform Testing with Terratest

package test

import (
    "testing"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestVPCCreation(t *testing.T) {
    terraformOptions := &terraform.Options{
        TerraformDir: "../",
        Vars: map[string]interface{}{
            "environment": "test",
        },
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    vpcID := terraform.Output(t, terraformOptions, "vpc_id")
    assert.NotEmpty(t, vpcID)
}

CDK Testing

import { Template } from 'aws-cdk-lib/assertions';
import { WebAppStack } from '../lib/web-app-stack';

test('VPC Created', () => {
  const app = new cdk.App();
  const stack = new WebAppStack(app, 'TestStack');

  const template = Template.fromStack(stack);

  template.hasResourceProperties('AWS::EC2::VPC', {
    CidrBlock: '10.0.0.0/16',
  });
});

test('Fargate Service Has Auto Scaling', () => {
  const app = new cdk.App();
  const stack = new WebAppStack(app, 'TestStack');

  const template = Template.fromStack(stack);

  template.resourceCountIs('AWS::ApplicationAutoScaling::ScalingPolicy', 1);
});

CI/CD for Infrastructure

GitHub Actions for Terraform

name: Terraform

on:
  push:
    branches: [main]
    paths:
      - 'terraform/**'
  pull_request:
    paths:
      - 'terraform/**'

jobs:
  terraform:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: 1.6.0

      - name: Terraform Init
        run: terraform init
        working-directory: ./terraform

      - name: Terraform Format
        run: terraform fmt -check
        working-directory: ./terraform

      - name: Terraform Validate
        run: terraform validate
        working-directory: ./terraform

      - name: Terraform Plan
        run: terraform plan -out=plan.tfplan
        working-directory: ./terraform
        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 plan.tfplan
        working-directory: ./terraform
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

GitHub Actions for CDK

name: CDK

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-node@v3
        with:
          node-version: '20'

      - run: npm ci

      - name: CDK Diff
        run: npx cdk diff
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: CDK Deploy
        if: github.ref == 'refs/heads/main'
        run: npx cdk deploy --all --require-approval never
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Cost Optimization

Tagging Strategy

locals {
  common_tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
    Project     = var.project_name
    CostCenter  = var.cost_center
  }
}

resource "aws_instance" "web" {
  # ...

  tags = merge(
    local.common_tags,
    {
      Name = "${var.environment}-web"
      Role = "web-server"
    }
  )
}

Resource Scheduling

# Auto Scaling Schedule for Dev Environments
resource "aws_autoscaling_schedule" "scale_down_evening" {
  count                  = var.environment == "dev" ? 1 : 0
  scheduled_action_name  = "scale-down-evening"
  min_size               = 0
  max_size               = 0
  desired_capacity       = 0
  recurrence             = "0 18 * * MON-FRI"  # 6 PM weekdays
  autoscaling_group_name = aws_autoscaling_group.main.name
}

resource "aws_autoscaling_schedule" "scale_up_morning" {
  count                  = var.environment == "dev" ? 1 : 0
  scheduled_action_name  = "scale-up-morning"
  min_size               = 1
  max_size               = 3
  desired_capacity       = 1
  recurrence             = "0 8 * * MON-FRI"  # 8 AM weekdays
  autoscaling_group_name = aws_autoscaling_group.main.name
}

Best Practices

State Management

Never commit state files:

# .gitignore
*.tfstate
*.tfstate.*
.terraform/

Always use remote state:

terraform {
  backend "s3" {
    # ...
  }
}

Module Versioning

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.2"  # Pin version

  # ...
}

Sensitive Data

variable "db_password" {
  type      = string
  sensitive = true
}

output "db_endpoint" {
  value     = aws_db_instance.main.endpoint
  sensitive = false  # OK to expose
}

output "db_password" {
  value     = var.db_password
  sensitive = true  # Hide in logs
}

Common Pitfalls

Hardcoded Values

# Bad
resource "aws_instance" "web" {
  ami           = "ami-12345"
  instance_type = "t3.micro"
}

# Good
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]  # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type
}

Circular Dependencies

# Causes issues
resource "aws_security_group" "app" {
  ingress {
    security_groups = [aws_security_group.db.id]
  }
}

resource "aws_security_group" "db" {
  ingress {
    security_groups = [aws_security_group.app.id]
  }
}

# Fix with security group rules
resource "aws_security_group" "app" {}
resource "aws_security_group" "db" {}

resource "aws_security_group_rule" "app_to_db" {
  type                     = "ingress"
  from_port                = 5432
  to_port                  = 5432
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.app.id
  security_group_id        = aws_security_group.db.id
}

Conclusion

Infrastructure as Code transforms infrastructure management from risky manual work to safe, automated deployments.

Choose Terraform for multi-cloud, simple infrastructure, or when you need cloud-agnostic configuration.

Choose CDK for complex AWS infrastructure where programming logic adds value.

Start small. Automate one environment. Add testing. Expand to other environments. Soon manual infrastructure changes will feel as outdated as FTPing files to production.

Need help implementing Infrastructure as Code? VooStack specializes in cloud infrastructure automation with Terraform and AWS CDK. Let’s discuss your infrastructure needs.

Topics

infrastructure terraform aws devops cloud
V

Written by VooStack Team

Contact author

Share this article