Five AWS services collaborating to run one container: ECR holds the image, ECS schedules tasks, Fargate runs them without you managing servers, an ALB routes traffic, Route 53 + ACM front it with a custom domain and HTTPS. Production-grade, autoscaling, AWS-native. Worth the setup if you're staying β overkill if you're not.
"Why Fargate over a Lightsail box?" is the first question worth asking, because if you don't have a real answer, the answer is don't. Fargate's reasons-to-exist are autoscaling without managing EC2, container-native deploys that fit a CI pipeline, and integration with the rest of AWS (IAM task roles, VPC networking, ALB health checks, CloudWatch). For a small Node API behind a single domain, a $5 Lightsail instance is genuinely the right answer and Fargate is overkill. For a service that needs to scale from 1 to 100 instances based on traffic, run multiple versions in parallel, talk to private VPC resources, and survive its own infrastructure rolling β Fargate exists for exactly that.
The mental model that makes the AWS docs comprehensible: ECR (Elastic Container Registry) is where container images live, like Docker Hub but private and inside your AWS account. ECS (Elastic Container Service) is the orchestrator β the thing that says "I want 3 instances of this container running, replace any that die, route traffic to healthy ones." Fargate is the compute mode β instead of running ECS on EC2 instances you manage, Fargate is "AWS runs the containers wherever; you don't see the servers." A task definition is the spec ("run image X with these env vars on these ports"). A service says "keep N tasks running based on this task definition behind this load balancer." An ALB (Application Load Balancer) is the public entrypoint that distributes traffic across the running tasks. ACM + Route 53 are TLS and DNS, same as the other tutorials.
This tutorial walks the end-to-end first deploy: dockerize the app, push to ECR, write a task definition, create a Fargate service behind an ALB, wire DNS and a cert, deploy. Every step is mechanical once you've done it once; the first time has a lot of moving parts. Budget two hours for the first deploy; subsequent deploys (Step 9) are one command.
Dockerfile in your project.
If you don't have a Dockerfile yet, here's a reasonable Node starter:
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV PORT=8080
EXPOSE 8080
CMD ["node", "server.js"]
Build and test locally:
docker build -t mybrand-api:latest .
docker run -p 8080:8080 mybrand-api:latest
curl http://localhost:8080/health
If it doesn't work locally, it won't work on Fargate. Get the local image healthy before going anywhere near AWS.
# Create the repo
aws ecr create-repository --repository-name mybrand-api --region us-east-1
# Authenticate Docker to ECR
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin \
<ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com
# Tag the local image with the ECR URL
docker tag mybrand-api:latest \
<ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/mybrand-api:latest
# Push
docker push <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/mybrand-api:latest
The full image URI β <ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/<REPO>:<TAG> β is what the task definition will reference. Note the account ID and region; you'll paste them often.
ECS needs two distinct IAM roles, and confusing them is the #1 first-deploy failure:
ecsTaskExecutionRole) β used by ECS/Fargate itself to pull the image from ECR and write logs to CloudWatch. AWS provides a managed policy (AmazonECSTaskExecutionRolePolicy) that's exactly what you need.Create the execution role once per account:
aws iam create-role --role-name ecsTaskExecutionRole \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]
}'
aws iam attach-role-policy --role-name ecsTaskExecutionRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
The task role is project-specific; for a service that doesn't call AWS APIs, you can omit it.
Save as task-definition.json:
{
"family": "mybrand-api",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/ecsTaskExecutionRole",
"containerDefinitions": [{
"name": "api",
"image": "<ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/mybrand-api:latest",
"portMappings": [{"containerPort": 8080, "protocol": "tcp"}],
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/mybrand-api",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs",
"awslogs-create-group": "true"
}
}
}]
}
Smallest Fargate task is 256 CPU units (ΒΌ vCPU) + 512 MB RAM. Most APIs run fine at that size. Register the task definition:
aws ecs register-task-definition --cli-input-json file://task-definition.json
This part is genuinely faster in the console. AWS Console β ECS β Create cluster β name it mybrand-prod β AWS Fargate (serverless) infrastructure β Create.
Then, separately, create the Application Load Balancer: EC2 β Load Balancers β Create load balancer β Application Load Balancer:
mybrand-api-tg with target type IP, protocol HTTP, port 8080, health-check path /health (or wherever your app responds with 200).After creation, the ALB has a DNS name like mybrand-prod-12345.us-east-1.elb.amazonaws.com. Note it; Step 8 points your domain at it.
/health endpoint returning 200, the ALB will mark every task unhealthy and Fargate will kill them in a loop. Add a one-line health endpoint to your app before deploying. app.get('/health', (req, res) => res.send('ok')) is sufficient.
ECS console β your cluster β Create service:
mybrand-api (latest revision).mybrand-api.mybrand-api-tg. Container to load balance: api:8080.Create. Fargate pulls the image, starts the tasks, registers them with the target group. After 2β3 minutes the service shows "ACTIVE" and the target group shows healthy targets. Test:
curl http://<ALB_DNS_NAME>/health
# Expected: ok
Unlike CloudFront (which only reads certs from us-east-1), the ALB uses the cert from the same region the ALB lives in. If your ALB is in us-east-1, the cert needs to be in us-east-1; if it's in eu-west-2, the cert needs to be there.
aws acm request-certificate \
--domain-name api.mybrand.com \
--validation-method DNS \
--region <SAME_REGION_AS_ALB>
If DNS is at Route 53 in the same account, the console offers "Create records in Route 53" for the validation CNAME. Otherwise add the CNAME at your DNS provider. Wait for ACM to show "Issued" β usually 5β15 minutes.
EC2 β Load Balancers β your ALB β Listeners tab β Add listener:
mybrand-api-tg).Optionally edit the HTTP:80 listener to redirect to HTTPS:443. Save.
Now point DNS at the ALB. In Route 53 β your hosted zone β Create record:
api.Save. Test: curl -I https://api.mybrand.com/health. You should see HTTP/2 200.
After all the setup, every subsequent deploy is three commands:
# Build the new image
docker build -t mybrand-api:latest .
# Push to ECR (auth refresh might be needed first)
docker tag mybrand-api:latest \
<ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/mybrand-api:latest
docker push <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/mybrand-api:latest
# Tell ECS to roll out new tasks with the same task definition (which references :latest)
aws ecs update-service \
--cluster mybrand-prod \
--service mybrand-api \
--force-new-deployment
ECS performs a rolling deploy: starts new tasks, waits for them to pass the ALB health check, drains connections from the old tasks, kills them. Zero-downtime by default. Watch the deploy in the ECS console under your service's Deployments tab.
To roll back: ECS keeps prior task-definition revisions forever. Update the service to use the previous revision: aws ecs update-service --cluster mybrand-prod --service mybrand-api --task-definition mybrand-api:<PREVIOUS_REVISION>. Same rolling-deploy mechanics in reverse.
:latest in production task definitions for serious workloads. A rolling deploy started before a push completes could pull an old or partial image. Use immutable tags (mybrand-api:<git-sha>) and update the task definition with each push. Slightly more work; significantly less foot-gun.
For 2 always-on Fargate tasks at the smallest size (0.25 vCPU + 0.5 GB RAM each), 24/7:
Realistic monthly bill for a small API with 2 always-on tasks: ~$35β40/month. A comparable Lightsail box is $5β10/month. The 4β8Γ cost difference is what you're paying for autoscaling, rolling deploys, ALB health checks, and the rest of the production-grade machinery. Worth it when you need them, expensive when you don't.
Single instance, no scaling. Lightsail wins on cost and complexity. Move up only when traffic forces it.
Bursty workloads with idle periods. Fargate bills per second but always-on tasks bill always. If your service is mostly idle, look at Lambda (pay only for invocations) or App Runner (Fargate with simpler ergonomics and idle scale-to-zero).
You don't want to operate AWS networking. ECS Fargate hides Fargate-the-compute but exposes you to VPCs, subnets, security groups, target groups, listeners. If that's too much, AWS App Runner is a thinner abstraction over the same machinery β fewer knobs, similar capabilities, slightly less control.