
If you’ve deployed to ECS through the AWS Console even once, you know the drill. Click into ECS, create a cluster, click into Task Definitions, fill out a form with 20 fields, click into Services, hook up a load balancer, click into Target Groups, set a health check path, click into IAM, attach a role, and pray you remembered all of it correctly — because six months from now when you need to reproduce this in a second environment, you won’t.
I’ve shipped Node.js APIs to ECS/Fargate through the console, through raw CloudFormation YAML, and now almost exclusively through AWS CDK. This guide walks through building a production-shaped ECS deployment — cluster, Fargate service, ALB, secrets, environment config — entirely in TypeScript. No console clicks. By the end you’ll have a stack you can cdk diff, cdk deploy, and cdk destroy like any other piece of code, because that's what it is.

Why Move From Console Clicks to CDK
Before the code, it’s worth being explicit about what you’re actually buying with infrastructure as code, because “it’s best practice” isn’t a reason engineers should accept at face value.
Repeatability. A console-built cluster is a snowflake. Nobody can tell you with certainty what settings were chosen without going and checking every screen. A CDK stack is the settings. Spin up a staging environment identical to production by deploying the same stack with different context values — no tribal knowledge required.
Code review. Infrastructure changes going through a pull request means a teammate can catch “wait, why are we opening port 22 to 0.0.0.0/0” before it merges, not after a security audit six months later. Console changes have no review step by default — someone just clicks “Save.”
Drift detection. When infrastructure lives in code, cdk diff tells you exactly what will change before you touch anything live. If someone manually edited a security group in the console last week, CDK will show you that drift the next time you deploy, instead of silently overwriting it (or worse, silently leaving it and causing confusing inconsistencies).
Rollback and history. git revert is a rollback strategy. "I sort of remember what I clicked" is not.
None of this is theoretical — it’s the difference between a deploy being a 45-minute guided tour through the console and being cdk deploy while you get coffee.
Prerequisites
You’ll need a few things installed and configured before we touch code.
# Node.js 18+ (CDK v2 requires it)
node -v
# AWS CLI v2, configured with credentials that have sufficient permissions
aws --version
aws configure
# AWS Access Key ID: ****************
# AWS Secret Access Key: ****************
# Default region name: us-east-1
# AWS CDK CLI, installed globally
npm install -g aws-cdk
cdk --version
Bootstrap your AWS account/region for CDK (one-time, per account+region combo). This provisions an S3 bucket for CDK assets and an ECR-adjacent IAM setup CDK needs to deploy:
cdk bootstrap aws://ACCOUNT_ID/us-east-1
Now scaffold the CDK project. I keep infra in its own directory alongside the app code, not mixed into src/:
mkdir infra && cd infra
cdk init app --language typescript
This gives you a standard structure:
infra/
├── bin/
│ └── infra.ts # entrypoint, instantiates your stack(s)
├── lib/
│ └── infra-stack.ts # your stack definition lives here
├── cdk.json
├── package.json
└── tsconfig.json
Install the CDK modules we’ll actually use:
npm install aws-cdk-lib constructs
For the Node.js app itself, assume a standard Express API with a Dockerfile at the root:
# Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app ./
EXPOSE 3000
USER node
CMD ["node", "server.js"]
Make sure your app has a lightweight health check route — we’ll wire the ALB to it later:
// server.js (excerpt)
app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok' });
});
Defining the ECS Cluster, Task Definition, and Fargate Service
This is the core of the stack. I’m building it up piece by piece rather than reaching for ApplicationLoadBalancedFargateService (the high-level pattern), because in a real production setup you almost always need more control than the pattern gives you — and understanding the pieces makes debugging a lot easier later.
// lib/infra-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as path from 'path';
export class InfraStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);// --- VPC ---
// 2 AZs is enough for most workloads and keeps NAT Gateway costs down.
// Use natGateways: 1 to save ~$32/mo/gateway if you don't need per-AZ NAT redundancy.
const vpc = new ec2.Vpc(this, 'AppVpc', {
maxAzs: 2,
natGateways: 1,
});
// --- ECS Cluster ---
const cluster = new ecs.Cluster(this, 'AppCluster', {
vpc,
clusterName: 'node-api-cluster',
containerInsights: true, // CloudWatch Container Insights for CPU/mem/network dashboards
});
// --- Docker image build ---
// CDK builds this image locally (or in your CI runner) and pushes it to
// an auto-created ECR repo as part of `cdk deploy`. No manual `docker push`.
const image = new ecr_assets.DockerImageAsset(this, 'AppImage', {
directory: path.join(__dirname, '../../'), // path to your Dockerfile context
platform: ecr_assets.Platform.LINUX_AMD64,
});
// --- Log group ---
const logGroup = new logs.LogGroup(this, 'AppLogGroup', {
logGroupName: '/ecs/node-api',
retention: logs.RetentionDays.TWO_WEEKS,
removalPolicy: cdk.RemovalPolicy.DESTROY, // fine for this demo; use RETAIN in real prod
});
// --- Task Definition ---
const taskDefinition = new ecs.FargateTaskDefinition(this, 'AppTaskDef', {
memoryLimitMiB: 512,
cpu: 256,
// taskRole: permissions your APP CODE needs at runtime (S3, DynamoDB, etc.)
// executionRole: permissions ECS needs to pull images / write logs (set automatically)
});
taskDefinition.addContainer('AppContainer', {
image: ecs.ContainerImage.fromDockerImageAsset(image),
containerName: 'node-api',
portMappings: [{ containerPort: 3000, protocol: ecs.Protocol.TCP }],
logging: ecs.LogDrivers.awsLogs({
streamPrefix: 'node-api',
logGroup,
}),
environment: {
NODE_ENV: 'production',
PORT: '3000',
},
healthCheck: {
// container-level health check, separate from the ALB's
command: ['CMD-SHELL', 'wget -qO- http://localhost:3000/health || exit 1'],
interval: cdk.Duration.seconds(30),
timeout: cdk.Duration.seconds(5),
retries: 3,
startPeriod: cdk.Duration.seconds(10),
},
});// --- Fargate Service ---
const service = new ecs.FargateService(this, 'AppService', {
cluster,
taskDefinition,
desiredCount: 2, // run at least 2 tasks across AZs for basic HA
minHealthyPercent: 100,
maxHealthyPercent: 200,
assignPublicIp: false, // tasks live in private subnets behind the ALB
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
circuitBreaker: { rollback: true }, // auto-rollback failed deployments
});
// Store references for use later in this file (ALB section)
this.vpc = vpc;
this.service = service;
}
public vpc!: ec2.Vpc;
public service!: ecs.FargateService;
}
A few decisions worth calling out:
- DockerImageAsset means you never manually build/tag/push an image — cdk deploy does it, using your local Docker daemon (or CodeBuild in CI). It's fine for most teams; if your build takes several minutes, offload it to a proper CI pipeline that pushes to ECR and have CDK reference that repo instead.
- circuitBreaker: { rollback: true } is not optional in my stacks anymore. Without it, a bad deploy just sits there cycling unhealthy tasks until you notice and intervene manually.
- Private subnets with assignPublicIp: false — tasks should never be directly internet-addressable. The ALB is the only public entry point.
Wiring Up an Application Load Balancer and Health Checks
Now expose the service through an ALB with a proper health check, so ECS only routes traffic to tasks that are actually ready.
// continuing lib/infra-stack.ts, inside the constructor
// --- Application Load Balancer ---
const alb = new elbv2.ApplicationLoadBalancer(this, 'AppAlb', {
vpc,
internetFacing: true,
loadBalancerName: 'node-api-alb',
});
const listener = alb.addListener('AppListener', {
port: 80,
open: true, // opens 0.0.0.0/0 on the ALB security group for port 80
});const targetGroup = listener.addTargets('AppTargetGroup', {
port: 3000,
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [service],
deregistrationDelay: cdk.Duration.seconds(30), // faster drain during deploys
healthCheck: {
path: '/health',
interval: cdk.Duration.seconds(15),
timeout: cdk.Duration.seconds(5),
healthyThresholdCount: 2,
unhealthyThresholdCount: 3,
healthyHttpCodes: '200',
},
});// Output the ALB DNS name so you don't have to dig for it in the console
new cdk.CfnOutput(this, 'AlbDnsName', {
value: alb.loadBalancerDnsName,
description: 'Public URL of the load balancer',
});
Two things that trip people up here:
- open: true on the listener opens the ALB's security group to the world on port 80. That's correct for a public API. If this were internal-only, you'd set internetFacing: false on the ALB and restrict the security group to your VPC CIDR instead.
- The health check path must exist and return a 2xx before you deploy, or your tasks will cycle forever, get killed by ECS for failing health checks, and get relaunched — a loop that looks like a crash but is actually just a missing /health route. I've debugged this exact issue for other people more times than I'd like to admit.
If you’re using HTTPS (you should be, in real production), add a certificate via ACM and a second listener on 443, then redirect 80 → 443:
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
const certificate = acm.Certificate.fromCertificateArn(
this,
'Cert',
'arn:aws:acm:us-east-1:ACCOUNT_ID:certificate/CERT_ID'
);
const httpsListener = alb.addListener('HttpsListener', {
port: 443,
certificates: [certificate],
open: true,
});
httpsListener.addTargets('HttpsTargetGroup', {
port: 3000,
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [service],
healthCheck: { path: '/health' },
});listener.addAction('RedirectToHttps', {
action: elbv2.ListenerAction.redirect({ port: '443', protocol: 'HTTPS' }),
});Environment Variables and Secrets via CDK + Secrets Manager
Plaintext environment variables are fine for things like NODE_ENV or PORT. They are not fine for database passwords or API keys. CDK makes the split between the two explicit and type-safe.
// continuing lib/infra-stack.ts
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
// Create (or import) a secret. In most real setups you'd import an existing
// one rather than let CDK generate it, since your DB was likely provisioned separately.
const dbSecret = secretsmanager.Secret.fromSecretNameV2(
this,
'DbSecret',
'prod/node-api/db-credentials'
);
// Or generate a brand-new secret for something like a JWT signing key:
const jwtSecret = new secretsmanager.Secret(this, 'JwtSecret', {
secretName: 'prod/node-api/jwt-secret',
generateSecretString: {
passwordLength: 32,
excludePunctuation: true,
},
});
Now attach both plain env vars and secrets to the container definition:
const container = taskDefinition.addContainer('AppContainer', {
image: ecs.ContainerImage.fromDockerImageAsset(image),
containerName: 'node-api',
portMappings: [{ containerPort: 3000, protocol: ecs.Protocol.TCP }],
logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'node-api', logGroup }),// Plaintext, visible in the task definition JSON — fine for non-sensitive config
environment: {
NODE_ENV: 'production',
PORT: '3000',
LOG_LEVEL: 'info',
},
// Injected at container start from Secrets Manager, never written to the
// task definition in plaintext, never visible in the console as raw values
secrets: {
DB_PASSWORD: ecs.Secret.fromSecretsManager(dbSecret, 'password'),
DB_USERNAME: ecs.Secret.fromSecretsManager(dbSecret, 'username'),
JWT_SIGNING_KEY: ecs.Secret.fromSecretsManager(jwtSecret),
},
});
Under the hood, secrets resolves to the container agent pulling these values from Secrets Manager at task startup and injecting them as env vars inside the running container — your app code just reads process.env.DB_PASSWORD like normal. Nothing in your app needs to know Secrets Manager exists.
The task’s execution role needs permission to read these secrets. CDK grants this automatically when you use ecs.Secret.fromSecretsManager() in addContainer — worth confirming in cdk diff output (look for secretsmanager:GetSecretValue on the execution role policy) rather than assuming.
cdk diff and cdk deploy Walkthrough
With the stack written, wire it up in the entrypoint:
// bin/infra.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { InfraStack } from '../lib/infra-stack';
const app = new cdk.App();
new InfraStack(app, 'NodeApiStack', {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION ?? 'us-east-1',
},
tags: {
project: 'node-api',
environment: 'production',
},
});Synthesize first to catch TypeScript and construct-level errors before anything touches AWS:
cdk synth
Then diff — this is the step that replaces “click around and hope”:
cdk diff
Output looks something like:
Stack NodeApiStack
Resources
[+] AWS::ECS::Cluster AppCluster AppCluster1234ABCD
[+] AWS::ECS::TaskDefinition AppTaskDef AppTaskDefABCD1234
[+] AWS::ElasticLoadBalancingV2::LoadBalancer AppAlb AppAlbABCD1234
[+] AWS::ECS::Service AppService AppServiceABCD1234
...
IAM Statement Changes
┌───┬──────────────────────────────┬────────┬─────────────────┐
│ │ Resource │ Effect │ Action │
├───┼──────────────────────────────┼────────┼─────────────────┤
│ + │ ${DbSecret} │ Allow │ secretsmanager: │
│ │ │ │ GetSecretValue │
└───┴──────────────────────────────┴────────┴─────────────────┘
That IAM table is exactly why cdk diff should be part of every deploy, including your CI pipeline — you get to see every permission change before it's applied, not after.
Deploy:
cdk deploy NodeApiStack --require-approval broadening
--require-approval broadening prompts for confirmation only when IAM permissions are being widened — a reasonable middle ground between never (dangerous in CI) and the default any-change (annoying for routine updates).
For CI/CD, drop the approval prompt entirely and let the pipeline gate on the diff output instead:
cdk deploy NodeApiStack --require-approval never --outputs-file cdk-outputs.json
Once it finishes, grab the ALB URL from the output and hit it:
curl http://$(jq -r '.NodeApiStack.AlbDnsName' cdk-outputs.json)/health
# {"status":"ok"}
Redeploying after a code change is just rebuilding the image and running cdk deploy again — CDK diffs the new image hash against the deployed task definition, registers a new revision, and rolls the service forward using the circuit-breaker-protected rolling update we configured earlier.
Tearing Down Cleanly with cdk destroy
This is the part console-based setups make painfully manual — deleting a service, then a cluster, then a load balancer, then target groups, then security groups, in the right order, hoping you didn’t miss a dependency.
cdk destroy NodeApiStack
CDK resolves the dependency graph and tears everything down in the correct order automatically. You’ll get a confirmation prompt:
Are you sure you want to delete: NodeApiStack (y/n)?
A few things to watch for:
- Resources with removalPolicy: cdk.RemovalPolicy.RETAIN (or CloudFormation's default retention on things like S3 buckets with data) will survive the destroy — check your stack for anything you explicitly want to keep, like an RDS instance or a production log group.
- The ECR repository CDK creates for DockerImageAsset images is not deleted by cdk destroy by default, to avoid losing image history — clean it up separately if you're fully decommissioning:
aws ecr describe-repositories --query 'repositories[?contains(repositoryName, `nodeapistack`)]'
aws ecr delete-repository --repository-name <name> --force
- For a scratch/demo environment, run cdk destroy right after you're done testing. Fargate tasks and an ALB left running idle is one of the most common sources of surprise AWS bills.
Common CDK Gotchas
A few things that have bitten me (and nearly every team I’ve worked with) enough times to call out explicitly.
Circular Stack Dependencies
Once your infra grows past a single stack — say you split networking, ECS, and a database into separate stacks — it’s easy to accidentally create a cycle: Stack A exports a value Stack B needs, and Stack B exports something Stack A needs back. CloudFormation will refuse to deploy either.
// BAD: NetworkStack references EcsStack's security group,
// while EcsStack references NetworkStack's VPC — if EcsStack
// also needs to hand something back to NetworkStack, you're stuck.
// GOOD: keep dependencies one-directional. Shared "foundational"
// resources (VPC, base security groups) live in their own stack
// that nothing feeds back into.
export class NetworkStack extends cdk.Stack {
public readonly vpc: ec2.Vpc;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
this.vpc = new ec2.Vpc(this, 'Vpc', { maxAzs: 2 });
}
}
export class EcsStack extends cdk.Stack {
constructor(scope: Construct, id: string, vpc: ec2.Vpc, props?: cdk.StackProps) {
super(scope, id, props);
// consume vpc here — never pass anything from EcsStack back into NetworkStack
}
}The fix is architectural, not a CDK trick: draw your dependency graph before you split stacks, and keep it a DAG. If two stacks genuinely need each other’s outputs, they’re one stack.
IAM Least-Privilege
CDK’s high-level grant* methods (grantRead, grantReadWrite, grantPullPush, etc.) are convenient and it's tempting to reach for the broadest one that makes the error go away. Don't.
// LAZY — grants full read/write on the entire bucket
bucket.grantReadWrite(taskDefinition.taskRole);
// BETTER — grants only what the app actually does, scoped where possible
bucket.grantRead(taskDefinition.taskRole);
bucket.grantPut(taskDefinition.taskRole); // if it only ever writes new objects, not overwrites/deletes

Same principle applies to the execution role vs task role distinction, which people conflate constantly:
- Execution role: what ECS itself needs — pull the image from ECR, write logs to CloudWatch, fetch secrets. CDK manages this mostly automatically.
- Task role: what your application code needs at runtime — S3, DynamoDB, SQS, whatever your app calls via the AWS SDK. You attach permissions here explicitly, and only the ones the code actually uses.
Every time I’ve seen an ECS task role with * on a resource, it was because someone hit a permissions error mid-deploy and reached for the broadest grant to unblock themselves, then never came back to tighten it. Treat that as a TODO with a deadline, not a solution.
Wrapping Up
You now have a full ECS/Fargate deployment — cluster, task definition, service, ALB with health checks, and secrets — defined entirely in TypeScript, reviewable in a pull request, diffable before every deploy, and destroyable in one command. That’s the actual win here: not that CDK is “infrastructure as code” in the abstract, but that your deploy process now behaves like your application code does — versioned, reviewed, and reproducible.
From here, a few concrete next steps worth taking:
- Split the stack. Pull networking (VPC) into its own stack from ECS/ALB, so you can tear down and rebuild the app layer without touching the VPC — useful once you have a database or other long-lived resources sharing the network.
- Add auto scaling. service.autoScaleTaskCount({ minCapacity: 2, maxCapacity: 10 }).scaleOnCpuUtilization(...) takes about five lines and turns this from a fixed-capacity deployment into one that actually responds to load.
- Push this through CI. Wire cdk diff into a GitHub Actions job that comments the diff on every PR touching infra/, and gate cdk deploy behind a merge to main — the same pattern I've covered in my CI/CD pipeline writeups, just pointed at CDK instead of raw aws ecs update-service calls.
- If you’re coming from Terraform, the honest comparison is: Terraform’s state model and multi-cloud support are real advantages if you’re not all-in on AWS; CDK’s advantage is that it’s just TypeScript — real loops, real types, real functions, and your IDE catches mistakes before plan/diff does. For an all-AWS Node.js shop, that tradeoff usually favors CDK.
Whichever direction you take it, the point of this exercise was to get you off the console entirely. If your next production change is a git diff instead of a memory of which buttons you clicked, this did its job.
