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:
{
"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
{
"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