AWS Fargate 104: Deploying with CDK, Terraform, and SAM

How to deploy Fargate effectively with different IaC tools. Practical patterns, common gotchas, and what works best for each approach.

After three posts about Fargate (101, 102, 103), you might be thinking "cool, but how do I deploy this stuff without clicking through the AWS Console like it's 2015?"

Good question. After managing 50+ Fargate services with different IaC tools, here's what I've learned about making each approach work effectively for containerized workloads.

The Evolution of Our Fargate IaC Journey#

Year 1: CloudFormation (The Dark Ages)

YAML
# 500 lines of YAML that made developers cry
Resources:
  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: my-app
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      Cpu: '256'
      Memory: '512'
      # ... 50 more lines of boilerplate

Year 2: Terraform (The Renaissance)

hcl
# At least it was readable
resource "aws_ecs_task_definition" "app" {
  family                   = "my-app"
  network_mode            = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                     = "256"
  memory                  = "512"
  # Still verbose, but manageable
}

Year 3: CDK (The Enlightenment)

TypeScript
// Finally, actual programming
const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', {
  memoryLimitMiB: 512,
  cpu: 256,
});

Let me share what we learned the hard way.

Deploying Fargate with CDK#

AWS CDK shines for Fargate deployments when you want programmatic control and high-level abstractions. Here's how to use it effectively:

The CDK Advantage for Fargate#

TypeScript
import * as cdk from 'aws-cdk-lib';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns';

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

    // This single construct creates:
    // - VPC, Subnets, NAT Gateways
    // - ECS Cluster
    // - Fargate Service
    // - Application Load Balancer
    // - Task Definition
    // - Security Groups
    // - CloudWatch Logs
    const fargateService = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'Service', {
      taskImageOptions: {
        image: ecs.ContainerImage.fromRegistry('nginx'),
        containerPort: 80,
        environment: {
          NODE_ENV: 'production',
          API_URL: 'https://api.example.com'
        }
      },
      desiredCount: 3,
      domainName: 'app.example.com',
      domainZone: hostedZone,
      certificate: certificate,
    });

    // Add auto-scaling
    const scaling = fargateService.service.autoScaleTaskCount({
      maxCapacity: 10,
      minCapacity: 2,
    });

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

    // Add CloudWatch alarms
    new cloudwatch.Alarm(this, 'HighMemory', {
      metric: fargateService.service.metricMemoryUtilization(),
      threshold: 80,
      evaluationPeriods: 2,
    });
  }
}

What 20 lines of CDK creates:

  • ~300 lines of CloudFormation
  • 15+ AWS resources
  • All the IAM roles and policies
  • Proper security group rules
  • CloudWatch log groups

Fargate-Specific CDK Patterns#

1. Service Templates with Environment Variations

TypeScript
interface FargateServiceProps {
  serviceName: string;
  image: string;
  environment: 'dev' | 'staging' | 'prod';
  port?: number;
}

class FargateService extends Construct {
  constructor(scope: Construct, id: string, props: FargateServiceProps) {
    super(scope, id);
    
    // Environment-specific sizing
    const configs = {
      dev: { cpu: 256, memory: 512, desiredCount: 1 },
      staging: { cpu: 512, memory: 1024, desiredCount: 2 },
      prod: { cpu: 1024, memory: 2048, desiredCount: 5 }
    };
    
    const config = configs[props.environment];
    
    const service = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'Service', {
      taskImageOptions: {
        image: ecs.ContainerImage.fromRegistry(props.image),
        containerPort: props.port || 80,
      },
      cpu: config.cpu,
      memoryLimitMiB: config.memory,
      desiredCount: config.desiredCount,
      // Auto-configure ALB, VPC, subnets, security groups
    });
    
    // Add Fargate-specific monitoring
    this.addFargateMonitoring(service);
  }
  
  private addFargateMonitoring(service: ecsPatterns.ApplicationLoadBalancedFargateService) {
    // Memory utilization alarm
    new cloudwatch.Alarm(this, 'MemoryAlarm', {
      metric: service.service.metricMemoryUtilization(),
      threshold: 80,
      evaluationPeriods: 2,
    });
    
    // Task count alarm
    new cloudwatch.Alarm(this, 'TaskCountAlarm', {
      metric: service.service.metricDesiredCount(),
      threshold: 1,
      comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN,
    });
  }
}

2. Handling Fargate Spot with CDK

TypeScript
// CDK doesn't directly support Fargate Spot in high-level constructs
// You need to use escape hatches
const service = new ecs.FargateService(this, 'Service', {
  cluster,
  taskDefinition,
  capacityProviderStrategies: [
    {
      capacityProvider: 'FARGATE_SPOT',
      weight: 4,
      base: 0,
    },
    {
      capacityProvider: 'FARGATE',
      weight: 1,
      base: 2, // Always keep 2 on regular Fargate
    }
  ],
});

CDK Gotchas for Fargate#

Issue: ENI Limits

TypeScript
// CDK creates many resources that consume ENIs
// Monitor and set up alerts
const eniUsageMetric = new cloudwatch.Metric({
  namespace: 'Custom/VPC',
  metricName: 'ENIsInUse',
});

new cloudwatch.Alarm(this, 'ENIUsage', {
  metric: eniUsageMetric,
  threshold: 4500, // 90% of default 5000 limit
});

Deploying Fargate with Terraform#

Terraform provides explicit, predictable Fargate deployments with excellent state management. Here's how to structure your Fargate infrastructure effectively:

Terraform Fargate Foundations#

hcl
resource "aws_ecs_cluster" "main" {
  name = "production"
  
  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_ecs_task_definition" "app" {
  family                   = "my-app"
  network_mode            = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                     = "512"
  memory                  = "1024"
  execution_role_arn      = aws_iam_role.ecs_task_execution_role.arn
  task_role_arn          = aws_iam_role.ecs_task_role.arn

  container_definitions = jsonencode([{
    name  = "app"
    image = "nginx:latest"
    
    portMappings = [{
      containerPort = 80
      protocol      = "tcp"
    }]
    
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        awslogs-group         = aws_cloudwatch_log_group.app.name
        awslogs-region        = var.aws_region
        awslogs-stream-prefix = "ecs"
      }
    }
    
    environment = [
      {
        name  = "NODE_ENV"
        value = "production"
      }
    ]
  }])
}

resource "aws_ecs_service" "app" {
  name            = "my-app-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = var.app_count
  launch_type     = "FARGATE"

  network_configuration {
    security_groups  = [aws_security_group.ecs_tasks.id]
    subnets          = aws_subnet.private[*].id
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_alb_target_group.app.arn
    container_name   = "app"
    container_port   = 80
  }

  depends_on = [aws_alb_listener.front_end]
}

The Module Pattern That Saved Our Sanity#

hcl
# modules/fargate-service/main.tf
variable "service_name" {}
variable "image" {}
variable "cpu" { default = "256" }
variable "memory" { default = "512" }
variable "desired_count" { default = 2 }

# ... 200 lines of reusable Terraform ...

output "service_url" {
  value = aws_alb.main.dns_name
}

# In your main configuration
module "api_service" {
  source        = "./modules/fargate-service"
  service_name  = "api"
  image        = "myapp/api:latest"
  cpu          = "512"
  memory       = "1024"
  desired_count = 3
}

module "worker_service" {
  source        = "./modules/fargate-service"
  service_name  = "worker"
  image        = "myapp/worker:latest"
  cpu          = "256"
  memory       = "512"
  desired_count = 5
}

The State File Horror Story#

Let me tell you about the time someone ran terraform apply from their laptop with a 3-week-old state file...

Bash
# The morning started innocently
$ terraform plan
Terraform will perform the following actions:
  # aws_ecs_service.app will be destroyed
  - resource "aws_ecs_service" "app" {
      - name = "production-api" -> null
      # ... 50 resources to be destroyed
  }

Plan: 0 to add, 0 to change, 52 to destroy.

# Someone didn't read the plan output
$ terraform apply -auto-approve

# 5 minutes later: production is down

Lesson learned: Always use remote state. Always.

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

SAM: The Lambda-First Approach#

AWS SAM (Serverless Application Model) is great for Lambda, but for Fargate? It's like using a screwdriver to hammer nails.

YAML
# template.yaml
Transform: AWS::Serverless-2016-10-31

Resources:
  FargateCluster:
    Type: AWS::ECS::Cluster
  
  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      RequiresCompatibilities:
        - FARGATE
      NetworkMode: awsvpc
      Cpu: '256'
      Memory: '512'
      # Back to CloudFormation verbosity
  
  # SAM shines when you mix Lambda with Fargate
  ProcessorFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      Runtime: python3.9
      Events:
        ECSTask:
          Type: CloudWatchEvent
          Properties:
            Pattern:
              source:
                - aws.ecs
              detail-type:
                - ECS Task State Change

When SAM makes sense for Fargate:

  • You're primarily Lambda-based with some Fargate
  • You need Step Functions orchestration
  • You're already invested in SAM for other services

When it doesn't:

  • Fargate is your primary compute
  • You need complex networking
  • You want programming language features

The Migration Stories#

Story 1: CloudFormation to Terraform (6 months of pain)#

We had 200+ CloudFormation stacks. The migration plan seemed simple:

  1. Export existing resources
  2. Write equivalent Terraform
  3. Import resources
  4. Delete CloudFormation stacks

Reality:

Bash
# Month 1: Confidence
$ terraform import aws_ecs_service.app production-app-service
Import successful!

# Month 2: Reality hits
$ terraform plan
~ 147 resources to modify
! 23 resources will be recreated

# Month 3: The drift
Error: Resource already exists

# Month 4: The workaround scripts
$ python cloudformation_to_terraform.py --pray

# Month 5: Acceptance
$ terraform apply -target=aws_ecs_service.app

# Month 6: Victory (sort of)
$ aws cloudformation delete-stack --stack-name old-stack-47-of-200

Story 2: Terraform to CDK (The Promised Land?)#

TypeScript
// We thought CDK would be better
class LegacyInfraStack extends cdk.Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    
    // Import existing resources
    const cluster = ecs.Cluster.fromClusterArn(
      this,
      'ImportedCluster',
      'arn:aws:ecs:us-east-1:123456789:cluster/production'
    );
    
    // This worked for about 5 resources
    // Then we hit this:
    
    // CDK doesn't support importing task definitions
    // CDK doesn't support importing services with multiple target groups
    // CDK doesn't support importing services with service registries
    // CDK doesn't support...
  }
}

We ended up running both Terraform and CDK for a year. Do not recommend.

The Decision Matrix#

After all these battles, here's my honest recommendation:

Choose CDK if:#

  • ✅ Your team knows TypeScript/Python well
  • ✅ You're starting fresh (no legacy)
  • ✅ You want high-level abstractions
  • ✅ You're all-in on AWS
  • ✅ You like living on the edge

Choose Terraform if:#

  • ✅ You need multi-cloud potential
  • ✅ Your team prefers declarative syntax
  • ✅ You have existing Terraform modules
  • ✅ Stability > Latest features
  • ✅ You value huge community support

Choose SAM if:#

  • ✅ You're Lambda-first architecture
  • ✅ You need Step Functions
  • ✅ You want minimal tooling
  • ✅ Your Fargate usage is minimal

Still Use CloudFormation if:#

  • ✅ You enjoy pain (kidding!)
  • ✅ You need AWS Support to debug
  • ✅ You're using AWS Service Catalog
  • ✅ Corporate mandate (my condolences)

The Patterns That Work Everywhere#

Regardless of tool, these patterns saved us:

1. The Environment Abstraction#

TypeScript
// CDK
interface EnvironmentConfig {
  cpu: number;
  memory: number;
  desiredCount: number;
  environment: Record<string, string>;
}

const configs: Record<string, EnvironmentConfig> = {
  dev: { cpu: 256, memory: 512, desiredCount: 1 },
  staging: { cpu: 512, memory: 1024, desiredCount: 2 },
  prod: { cpu: 1024, memory: 2048, desiredCount: 5 }
};
hcl
# Terraform
locals {
  env_config = {
    dev     = { cpu = 256, memory = 512, count = 1 }
    staging = { cpu = 512, memory = 1024, count = 2 }
    prod    = { cpu = 1024, memory = 2048, count = 5 }
  }
  
  config = local.env_config[var.environment]
}

2. The Service Template Pattern#

Instead of copying code, create templates:

TypeScript
// CDK: Base service construct
export class BaseEcsService extends Construct {
  public readonly service: ecs.FargateService;
  
  constructor(scope: Construct, id: string, props: BaseEcsServiceProps) {
    super(scope, id);
    
    // 100 lines of boilerplate
    this.service = new ecs.FargateService(this, 'Service', {
      // Common configuration
    });
    
    // Standard alarms
    this.setupAlarms();
    
    // Standard dashboard
    this.setupDashboard();
  }
}

// Usage
new BaseEcsService(this, 'ApiService', {
  image: 'api:latest',
  port: 3000,
  cpu: 512
});

3. The GitOps Pipeline#

YAML
# .github/workflows/deploy.yml
name: Deploy Infrastructure

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

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Terraform Plan
        run: |
          cd infrastructure
          terraform init
          terraform plan -out=tfplan
          
      - name: Post Plan to PR
        uses: actions/github-script@v6
        with:
          script: |
            // Post plan output as PR comment
            
  apply:
    needs: plan
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Terraform Apply
        run: |
          terraform apply tfplan

The Cost of Each Approach#

Let's talk money, because cloud bills don't lie:

ToolLearning CurveMaintenance CostFlexibilityAWS Feature Lag
CDK2 weeksMediumHigh0-2 weeks
Terraform1 weekLowHigh2-4 weeks
SAM3 daysLowLow0 weeks
CloudFormation1 weekHighMedium0 weeks

But the bigger cost? Developer happiness.

Our team velocity:

  • With CloudFormation: 5 story points/sprint
  • With Terraform: 8 story points/sprint
  • With CDK: 12 story points/sprint

The Verdict#

After three years and four different IaC tools, here's what I use:

  • New projects: CDK with TypeScript
  • Existing projects: Whatever's already there (don't migrate unless you must)
  • Multi-cloud potential: Terraform
  • Quick prototypes: SAM
  • Never again: Raw CloudFormation

The dirty secret? They all generate CloudFormation anyway. Pick the abstraction level that makes your team productive.

Remember: The best IaC tool is the one your team will use. Don't let perfect be the enemy of deployed.

AWS Fargate Deep Dive Series

Complete guide to AWS Fargate from basics to production. Learn serverless containers, cost optimization, debugging techniques, and Infrastructure-as-Code deployment patterns through real-world experience.

Progress4/4 posts completed
Loading...

Comments (0)

Join the conversation

Sign in to share your thoughts and engage with the community

No comments yet

Be the first to share your thoughts on this post!

Related Posts