Skip to content

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?"

Deploying Fargate services requires choosing the right Infrastructure as Code (IaC) tool for your team and requirements. Each approach offers different trade-offs in complexity, maintainability, and developer experience.

IaC Tool Comparison for Fargate

CloudFormation - The Foundation

yaml
# Verbose but comprehensiveResources:  TaskDefinition:    Type: AWS::ECS::TaskDefinition    Properties:      Family: my-app      NetworkMode: awsvpc      RequiresCompatibilities:        - FARGATE      Cpu: '256'      Memory: '512'      # Requires detailed configuration

Terraform - The Industry Standard

hcl
# Declarative and explicitresource "aws_ecs_task_definition" "app" {  family  = "my-app"  network_mode  = "awsvpc"  requires_compatibilities = ["FARGATE"]  cpu  = "256"  memory  = "512"  # Good balance of readability and control}

CDK - The Programming Approach

typescript
// High-level abstractions with programming constructsconst taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', {  memoryLimitMiB: 512,  cpu: 256,});

Let's explore what works well with each approach.

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 this CDK construct 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 hatchesconst 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 alertsconst 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"  enable_execute_command = true
  # Use track_latest for automatic task definition updates  task_definition_track_latest = true
  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.tfvariable "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 configurationmodule "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}

Essential State Management

Proper state management is critical for Terraform deployments. Outdated state files can lead to unintended resource destruction.

bash
# Always review plan output carefully$ terraform planTerraform 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.
# Never use auto-approve in production$ terraform apply  # Review and confirm manually

Required: Always use remote state for team environments.

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.yamlTransform: 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.12      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

Migration Strategies

CloudFormation to Terraform Migration

Migrating existing infrastructure requires careful planning. Consider these challenges:

Migration Process:

  1. Export existing resources
  2. Write equivalent Terraform
  3. Import resources carefully
  4. Validate before removing CloudFormation

Common Issues:

bash
$ terraform import aws_ecs_service.app production-app-serviceImport successful!
$ terraform plan~ 147 resources to modify  # Resource drift detection! 23 resources will be recreated  # Breaking changes
Error: Resource already exists  # Import conflicts

Best Practices:

  • Start with non-critical resources
  • Use targeted applies: terraform apply -target=resource
  • Maintain parallel stacks during transition
  • Script resource discovery and import

Terraform to CDK Migration

CDK migrations face import limitations:

typescript
class MigrationStack extends cdk.Stack {  constructor(scope: Construct, id: string) {    super(scope, id);
    // Limited import support    const cluster = ecs.Cluster.fromClusterArn(      this,      'ImportedCluster',      'arn:aws:ecs:us-east-1:123456789:cluster/production'    );
    // CDK import limitations:    // - Task definitions require recreation    // - Complex service configurations    // - Service discovery integration  }}

Migration Strategy: Consider running both tools temporarily for complex transitions.

The Decision Matrix

Here's guidance for choosing the right IaC tool:

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
// CDKinterface 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
# Terraformlocals {  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 constructexport 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();  }}
// Usagenew BaseEcsService(this, 'ApiService', {  image: 'api:latest',  port: 3000,  cpu: 512});

3. The GitOps Pipeline

yaml
# .github/workflows/deploy.ymlname: Deploy Infrastructure
on:  push:    branches: [main]    paths:      - 'infrastructure/**'
jobs:  plan:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4            - 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.

The impact on development flow:

  • CloudFormation: Slower iterations, more debugging
  • Terraform: Predictable but verbose workflows
  • CDK: Faster development once team is comfortable

The Verdict

Here's what works well for different scenarios:

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

References

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

Related Posts