Skip to main content

Hands-On: Run an Express Container on Fargate

What We Are Building

An Express API image already sits in ECR at 123456789012.dkr.ecr.us-east-1.amazonaws.com/express-api:1.0.0, listening on port 3000. By the end of this walkthrough it runs as a Fargate service that restarts itself on crash, answers on a public IP, and streams logs to CloudWatch.

You will need your default VPC ID and two subnet IDs:

aws ec2 describe-vpcs --filters Name=is-default,Values=true \
--query "Vpcs[0].VpcId" --output text

aws ec2 describe-subnets --filters Name=vpc-id,Values=vpc-0f1e2d3c4b5a69788 \
--query "Subnets[0:2].SubnetId" --output text

Step 1: Create the Cluster and Log Group

aws ecs create-cluster --cluster-name express-demo

aws logs create-log-group --log-group-name /ecs/express-api
aws logs put-retention-policy --log-group-name /ecs/express-api --retention-in-days 14

A Fargate cluster is purely logical — creating it costs nothing and provisions nothing. The log group must exist before the first task starts, or the task fails with a log driver error.

Step 2: Check the Execution Role Exists

Fargate needs the task execution role to pull the image from ECR and write logs. Most accounts already have it:

aws iam get-role --role-name ecsTaskExecutionRole --query "Role.Arn"

If it is missing, create it:

ecs-trust.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ecs-tasks.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
aws iam create-role --role-name ecsTaskExecutionRole \
--assume-role-policy-document file://ecs-trust.json

aws iam attach-role-policy --role-name ecsTaskExecutionRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

This role is what ECS itself uses to start the task — distinct from a task role, which the app would use to call AWS APIs at runtime (we do not need one here).

Step 3: Register the Task Definition

task-def.json
{
"family": "express-api",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "express-api",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/express-api:1.0.0",
"essential": true,
"portMappings": [{ "containerPort": 3000, "protocol": "tcp" }],
"environment": [
{ "name": "NODE_ENV", "value": "production" },
{ "name": "PORT", "value": "3000" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/express-api",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
aws ecs register-task-definition --cli-input-json file://task-def.json

The response reports revision 1 — the definition is now express-api:1. The 256/512 sizing is the smallest Fargate combination, about $9 a month if left running, and plenty for a demo API.

Step 4: Create the Security Group

With awsvpc networking, each task gets its own network interface, so the security group attaches to the task itself:

aws ec2 create-security-group \
--group-name express-api-sg \
--description "Inbound 3000 for express-api Fargate tasks" \
--vpc-id vpc-0f1e2d3c4b5a69788

aws ec2 authorize-security-group-ingress \
--group-id sg-0aa11bb22cc33dd44 \
--protocol tcp --port 3000 --cidr 0.0.0.0/0

Open to the world on 3000 is acceptable for this exercise; in a real deployment the ALB gets the public exposure and this rule narrows to the ALB's security group. Outbound stays at the default allow-all — the task needs it to pull the image and reach CloudWatch.

Step 5: Create the Fargate Service

aws ecs create-service \
--cluster express-demo \
--service-name express-api \
--task-definition express-api:1 \
--desired-count 1 \
--launch-type FARGATE \
--network-configuration 'awsvpcConfiguration={
subnets=[subnet-0123abcd4567efgh8,subnet-08765zyxw4321vuts],
securityGroups=[sg-0aa11bb22cc33dd44],
assignPublicIp=ENABLED
}'

assignPublicIp=ENABLED matters twice over in default-VPC subnets: it makes the API reachable for testing, and without it the task cannot reach ECR to pull the image (no NAT gateway in the path), so it dies with CannotPullContainerError.

Wait for steady state:

aws ecs wait services-stable --cluster express-demo --services express-api

Step 6: Find the Task and Hit the API

aws ecs list-tasks --cluster express-demo --service-name express-api

aws ecs describe-tasks --cluster express-demo \
--tasks arn:aws:ecs:us-east-1:123456789012:task/express-demo/9d1e8f7a6b5c4d3e \
--query "tasks[0].[lastStatus,healthStatus,attachments[0].details]"

lastStatus should read RUNNING. The attachment details include the network interface ID — resolve it to the public IP:

aws ec2 describe-network-interfaces \
--network-interface-ids eni-0fe1dc2ba3987654f \
--query "NetworkInterfaces[0].Association.PublicIp" --output text
curl http://54.210.167.88:3000/health
{ "status": "ok", "uptime": 42.7 }

Step 7: View the Logs

Everything the container writes to stdout and stderr lands in the log group from Step 1:

aws logs tail /ecs/express-api --follow
2026-07-06T11:03:11 ecs/express-api/9d1e8f7a Server listening on port 3000
2026-07-06T11:04:02 ecs/express-api/9d1e8f7a GET /health 200 3ms

The console equivalent lives at ECS → Clusters → express-demo → Tasks → task → Logs. To watch the self-healing in action, stop the task by hand:

aws ecs stop-task --cluster express-demo \
--task arn:aws:ecs:us-east-1:123456789012:task/express-demo/9d1e8f7a6b5c4d3e

Within a minute list-tasks shows a replacement — the service holds desiredCount at 1 without any intervention. New task, new network interface, new public IP; that churn is exactly why real setups put an ALB in front.

Cleanup

Fargate bills by the second while tasks run, so tear down when done:

aws ecs update-service --cluster express-demo --service express-api --desired-count 0
aws ecs delete-service --cluster express-demo --service express-api
aws ecs delete-cluster --cluster express-demo
aws ec2 delete-security-group --group-id sg-0aa11bb22cc33dd44