Deploy Containers on AWS ECS Fargate with CDK

AWS ECS Fargate lets you run containers without managing servers. Combined with AWS CDK (Cloud Development Kit), you get type-safe infrastructure as code that's a joy to work with. In this tutorial, we'll deploy a production-ready containerized API from scratch.

What You'll Build

  • A Docker containerized Node.js API
  • VPC with public/private subnets
  • Application Load Balancer (ALB)
  • ECS Fargate service with auto-scaling
  • CloudWatch logging and health checks
  • CI/CD pipeline with CodePipeline
  • Prerequisites

  • AWS account with CLI configured
  • Node.js 18+ and npm
  • Docker installed
  • Basic TypeScript knowledge
  • ---

    Step 1: Create the Application

    First, let's create a simple Express API that we'll containerize:

    mkdir ecs-fargate-app && cd ecs-fargate-app
    

    mkdir app infra

    cd app && npm init -y

    npm install express

    app/src/index.js:

    const express = require('express');
    

    const app = express();

    const PORT = process.env.PORT || 3000;

    app.get('/health', (req, res) => {

    res.json({ status: 'healthy', timestamp: new Date().toISOString() });

    });

    app.get('/api/info', (req, res) => {

    res.json({

    service: 'ecs-fargate-demo',

    version: process.env.APP_VERSION || '1.0.0',

    region: process.env.AWS_REGION || 'unknown',

    task: process.env.ECS_TASK_ARN || 'local',

    });

    });

    app.get('/api/compute/:n', (req, res) => {

    const n = parseInt(req.params.n) || 10;

    const fib = (num) => num <= 1 ? num : fib(num - 1) + fib(num - 2);

    const start = Date.now();

    const result = fib(Math.min(n, 40));

    res.json({ input: n, fibonacci: result, computeMs: Date.now() - start });

    });

    app.listen(PORT, '0.0.0.0', () => {

    console.log(Server running on port ${PORT});

    });

    Step 2: Containerize the Application

    app/Dockerfile:

    FROM node:18-alpine AS builder
    

    WORKDIR /app

    COPY package*.json ./

    RUN npm ci --only=production

    FROM node:18-alpine

    RUN addgroup -S appgroup && adduser -S appuser -G appgroup

    WORKDIR /app

    COPY --from=builder /app/node_modules ./node_modules

    COPY src/ ./src/

    USER appuser

    EXPOSE 3000

    HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \

    CMD wget -qO- http://localhost:3000/health || exit 1

    CMD ["node", "src/index.js"]

    Test locally:

    cd app
    

    docker build -t ecs-demo .

    docker run -p 3000:3000 ecs-demo

    curl http://localhost:3000/api/info

    Step 3: Set Up AWS CDK Project

    cd ../infra
    

    npx cdk init app --language typescript

    npm install @aws-cdk/aws-ec2 @aws-cdk/aws-ecs @aws-cdk/aws-ecs-patterns \

    @aws-cdk/aws-ecr @aws-cdk/aws-logs @aws-cdk/aws-applicationautoscaling

    > Note: With CDK v2, all constructs are in the aws-cdk-lib package:

    npm install aws-cdk-lib constructs
    

    Step 4: Define the Infrastructure

    infra/lib/ecs-fargate-stack.ts:

    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 ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns';

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

    import * as path from 'path';

    import { Construct } from 'constructs';

    export class EcsFargateStack extends cdk.Stack {

    constructor(scope: Construct, id: string, props?: cdk.StackProps) {

    super(scope, id, props);

    // 1. VPC with 2 AZs (public + private subnets)

    const vpc = new ec2.Vpc(this, 'AppVpc', {

    maxAzs: 2,

    natGateways: 1,

    subnetConfiguration: [

    {

    name: 'Public',

    subnetType: ec2.SubnetType.PUBLIC,

    cidrMask: 24,

    },

    {

    name: 'Private',

    subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,

    cidrMask: 24,

    },

    ],

    });

    // 2. ECS Cluster

    const cluster = new ecs.Cluster(this, 'AppCluster', {

    vpc,

    clusterName: 'fargate-demo-cluster',

    containerInsights: true,

    });

    // 3. Fargate Service with ALB (using high-level pattern)

    const fargateService = new ecsPatterns.ApplicationLoadBalancedFargateService(

    this, 'AppService', {

    cluster,

    serviceName: 'fargate-demo-service',

    desiredCount: 2,

    cpu: 256,

    memoryLimitMiB: 512,

    taskImageOptions: {

    image: ecs.ContainerImage.fromAsset(

    path.join(__dirname, '../../app')

    ),

    containerPort: 3000,

    environment: {

    APP_VERSION: '1.0.0',

    NODE_ENV: 'production',

    },

    logDriver: ecs.LogDrivers.awsLogs({

    streamPrefix: 'fargate-demo',

    logRetention: logs.RetentionDays.ONE_WEEK,

    }),

    },

    publicLoadBalancer: true,

    assignPublicIp: false,

    }

    );

    // 4. Health Check Configuration

    fargateService.targetGroup.configureHealthCheck({

    path: '/health',

    interval: cdk.Duration.seconds(30),

    timeout: cdk.Duration.seconds(5),

    healthyThresholdCount: 2,

    unhealthyThresholdCount: 3,

    });

    // 5. Auto-Scaling

    const scaling = fargateService.service.autoScaleTaskCount({

    minCapacity: 2,

    maxCapacity: 10,

    });

    scaling.scaleOnCpuUtilization('CpuScaling', {

    targetUtilizationPercent: 70,

    scaleInCooldown: cdk.Duration.seconds(60),

    scaleOutCooldown: cdk.Duration.seconds(60),

    });

    scaling.scaleOnMemoryUtilization('MemoryScaling', {

    targetUtilizationPercent: 80,

    });

    // 6. Request-based scaling

    scaling.scaleOnRequestCount('RequestScaling', {

    requestsPerTarget: 1000,

    targetGroup: fargateService.targetGroup,

    });

    // Outputs

    new cdk.CfnOutput(this, 'LoadBalancerDNS', {

    value: fargateService.loadBalancer.loadBalancerDnsName,

    description: 'Application Load Balancer DNS',

    });

    new cdk.CfnOutput(this, 'ServiceURL', {

    value: http://${fargateService.loadBalancer.loadBalancerDnsName},

    description: 'Service URL',

    });

    }

    }

    Step 5: Configure the CDK App

    infra/bin/infra.ts:

    import 'source-map-support/register';
    

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

    import { EcsFargateStack } from '../lib/ecs-fargate-stack';

    const app = new cdk.App();

    new EcsFargateStack(app, 'EcsFargateStack', {

    env: {

    account: process.env.CDK_DEFAULT_ACCOUNT,

    region: process.env.CDK_DEFAULT_REGION || 'ap-northeast-1',

    },

    description: 'ECS Fargate Demo with ALB and Auto-Scaling',

    });

    Step 6: Deploy

    cd infra
    
    

    # Bootstrap CDK (first time only)

    npx cdk bootstrap

    # Preview changes

    npx cdk diff

    # Deploy

    npx cdk deploy --require-approval broadening

    CDK will:

    1. Build the Docker image locally

    2. Push it to ECR (auto-created)

    3. Create the VPC, ALB, ECS cluster, and Fargate service

    4. Output the ALB DNS name

    # Test the deployed service
    

    curl http://<ALB-DNS>/api/info

    curl http://<ALB-DNS>/api/compute/30

    Step 7: Add CI/CD Pipeline

    Create a separate stack for the pipeline:

    infra/lib/pipeline-stack.ts:

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

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

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

    import * as codepipeline_actions from 'aws-cdk-lib/aws-codepipeline-actions';

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

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

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

    import { Construct } from 'constructs';

    interface PipelineStackProps extends cdk.StackProps {

    ecrRepo: ecr.IRepository;

    ecsService: ecs.IBaseService;

    githubOwner: string;

    githubRepo: string;

    }

    export class PipelineStack extends cdk.Stack {

    constructor(scope: Construct, id: string, props: PipelineStackProps) {

    super(scope, id, props);

    const sourceOutput = new codepipeline.Artifact();

    const buildOutput = new codepipeline.Artifact();

    // Build project

    const buildProject = new codebuild.PipelineProject(this, 'Build', {

    environment: {

    buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,

    privileged: true, // for Docker

    },

    buildSpec: codebuild.BuildSpec.fromObject({

    version: '0.2',

    phases: {

    pre_build: {

    commands: [

    'echo Logging in to Amazon ECR...',

    'aws ecr get-login-password | docker login --username AWS --password-stdin $ECR_URI',

    ],

    },

    build: {

    commands: [

    'echo Building Docker image...',

    'docker build -t $ECR_URI:$CODEBUILD_RESOLVED_SOURCE_VERSION app/',

    'docker tag $ECR_URI:$CODEBUILD_RESOLVED_SOURCE_VERSION $ECR_URI:latest',

    ],

    },

    post_build: {

    commands: [

    'docker push $ECR_URI:$CODEBUILD_RESOLVED_SOURCE_VERSION',

    'docker push $ECR_URI:latest',

    'printf "[{\\"name\\":\\"web\\",\\"imageUri\\":\\"%s\\"}]" $ECR_URI:$CODEBUILD_RESOLVED_SOURCE_VERSION > imagedefinitions.json',

    ],

    },

    },

    artifacts: {

    files: ['imagedefinitions.json'],

    },

    }),

    environmentVariables: {

    ECR_URI: { value: props.ecrRepo.repositoryUri },

    },

    });

    props.ecrRepo.grantPullPush(buildProject);

    // Pipeline

    new codepipeline.Pipeline(this, 'Pipeline', {

    pipelineName: 'fargate-demo-pipeline',

    stages: [

    {

    stageName: 'Source',

    actions: [

    new codepipeline_actions.GitHubSourceAction({

    actionName: 'GitHub',

    owner: props.githubOwner,

    repo: props.githubRepo,

    oauthToken: cdk.SecretValue.secretsManager('github-token'),

    output: sourceOutput,

    branch: 'main',

    }),

    ],

    },

    {

    stageName: 'Build',

    actions: [

    new codepipeline_actions.CodeBuildAction({

    actionName: 'DockerBuild',

    project: buildProject,

    input: sourceOutput,

    outputs: [buildOutput],

    }),

    ],

    },

    {

    stageName: 'Deploy',

    actions: [

    new codepipeline_actions.EcsDeployAction({

    actionName: 'DeployToECS',

    service: props.ecsService as ecs.BaseService,

    input: buildOutput,

    }),

    ],

    },

    ],

    });

    }

    }

    Step 8: Add Custom Domain with HTTPS

    import * as acm from 'aws-cdk-lib/aws-certificatemanager';
    

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

    import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';

    // In your stack constructor:

    const hostedZone = route53.HostedZone.fromLookup(this, 'Zone', {

    domainName: 'example.com',

    });

    const certificate = new acm.Certificate(this, 'Cert', {

    domainName: 'api.example.com',

    validation: acm.CertificateValidation.fromDns(hostedZone),

    });

    // Update the Fargate service to use HTTPS:

    const fargateService = new ecsPatterns.ApplicationLoadBalancedFargateService(

    this, 'AppService', {

    // ... previous config ...

    certificate,

    domainName: 'api.example.com',

    domainZone: hostedZone,

    redirectHTTP: true,

    }

    );

    Step 9: Monitoring and Alerts

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

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

    import * as cloudwatch_actions from 'aws-cdk-lib/aws-cloudwatch-actions';

    // Create SNS topic for alerts

    const alertTopic = new sns.Topic(this, 'AlertTopic', {

    topicName: 'fargate-demo-alerts',

    });

    // CPU alarm

    const cpuAlarm = new cloudwatch.Alarm(this, 'CpuAlarm', {

    metric: fargateService.service.metricCpuUtilization(),

    threshold: 85,

    evaluationPeriods: 3,

    alarmDescription: 'CPU utilization is above 85%',

    });

    cpuAlarm.addAlarmAction(new cloudwatch_actions.SnsAction(alertTopic));

    // 5XX error alarm

    const http5xxAlarm = new cloudwatch.Alarm(this, 'Http5xxAlarm', {

    metric: fargateService.loadBalancer.metricHttpCodeTarget(

    ec2.HttpCodeTarget.TARGET_5XX_COUNT,

    { period: cdk.Duration.minutes(5) }

    ),

    threshold: 10,

    evaluationPeriods: 2,

    alarmDescription: 'Too many 5XX errors',

    });

    http5xxAlarm.addAlarmAction(new cloudwatch_actions.SnsAction(alertTopic));

    // Dashboard

    const dashboard = new cloudwatch.Dashboard(this, 'Dashboard', {

    dashboardName: 'fargate-demo',

    });

    dashboard.addWidgets(

    new cloudwatch.GraphWidget({

    title: 'CPU & Memory',

    left: [fargateService.service.metricCpuUtilization()],

    right: [fargateService.service.metricMemoryUtilization()],

    }),

    new cloudwatch.GraphWidget({

    title: 'Request Count',

    left: [fargateService.loadBalancer.metricRequestCount()],

    })

    );

    Step 10: Clean Up

    # Destroy all resources
    

    npx cdk destroy --all

    # Verify in AWS Console that all resources are deleted

    Architecture Diagram

    ┌─────────────────────────────────────────────────┐
    

    │ VPC │

    │ ┌──────────────┐ ┌──────────────────────┐ │

    │ │ Public Subnet │ │ Private Subnet │ │

    │ │ │ │ │ │

    │ │ ┌────────┐ │ │ ┌────────────────┐ │ │

    │ │ │ ALB │──┼────┼─▶│ ECS Fargate │ │ │

    │ │ └────────┘ │ │ │ ┌──────────┐ │ │ │

    │ │ │ │ │ │ Task 1 │ │ │ │

    │ └──────────────┘ │ │ │ (container)│ │ │ │

    │ │ │ └──────────┘ │ │ │

    │ Internet │ │ ┌──────────┐ │ │ │

    │ Gateway │ │ │ Task 2 │ │ │ │

    │ │ │ │ (container)│ │ │ │

    │ │ │ └──────────┘ │ │ │

    │ │ └────────────────┘ │ │

    │ └──────────────────────┘ │

    └─────────────────────────────────────────────────┘

    │ │

    CloudWatch Auto-Scaling

    Logs & Alarms (2-10 tasks)

    Key Takeaways

    1. CDK patterns simplify deploymentApplicationLoadBalancedFargateService creates 30+ resources in one construct

    2. Fargate = no server management — AWS handles the underlying infrastructure

    3. Auto-scaling is built-in — scale on CPU, memory, or request count

    4. Infrastructure as real code — TypeScript gives you autocomplete, type checking, and refactoring

    5. CI/CD with CodePipeline — automatic deployments on git push

    Further Reading

  • AWS CDK Documentation
  • ECS Best Practices Guide
  • Fargate Pricing
  • CDK Patterns