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
- AWS account with CLI configured
- Node.js 18+ and npm
- Docker installed
- Basic TypeScript knowledge
Prerequisites
---
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 deployment — ApplicationLoadBalancedFargateService 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