ECS runs containers for you. If you have a Dockerfile and you want it running in AWS behind a load balancer, this is the shortest path that is still a real production setup — and on Fargate there is no server to patch, size or log into.
Three nouns, and that is the whole model
| Noun | What it is |
|---|---|
| Task definition | The recipe: which image, how much CPU and memory, which ports, which environment variables, where logs go. Versioned — every edit creates a new revision |
| Task | One running instance of that recipe. Roughly "a container, running" |
| Service | Keeps N tasks alive, registers them with a load balancer, and replaces them on deploy or on failure |
A cluster is just a namespace those live in. On Fargate it holds no servers, so creating one costs nothing and means almost nothing.
Fargate or EC2
Launch type decides who owns the machine. On Fargate, AWS runs each task on capacity you never see; you pick vCPU and memory per task and pay for exactly that while it runs. On EC2, you run a cluster of instances and ECS packs tasks onto them.
Fargate costs more per vCPU-hour and is almost always the right default: the price difference is smaller than the cost of someone patching AMIs. EC2 wins for GPUs, very large sustained scale where bin-packing pays, or a daemon on every host.
A task definition
{
"family": "stayhub-api",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::111122223333:role/ecsTaskExecutionRole",
"taskRoleArn": "arn:aws:iam::111122223333:role/stayhub-api-task",
"containerDefinitions": [
{
"name": "api",
"image": "111122223333.dkr.ecr.us-west-2.amazonaws.com/stayhub-api:1.4.0",
"portMappings": [{ "containerPort": 8000 }],
"secrets": [
{ "name": "DATABASE_URL",
"valueFrom": "arn:aws:ssm:us-west-2:111122223333:parameter/stayhub/db-url" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/stayhub-api",
"awslogs-region": "us-west-2",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}Note cpu and memory are strings, and Fargate only accepts specific
pairings — 512 CPU units (half a vCPU) allows 1, 2, 3 or 4 GB and nothing else. An arbitrary
combination is rejected at registration.
The two roles, which is the thing that bites
There are two IAM roles in that file and they are not interchangeable. This is the single most common reason a task dies on startup with nothing useful in the log.
executionRoleArn— used by the ECS agent, before your container starts, to pull the image from ECR, fetchsecrets, and create the log stream. If this is wrong the container never runs, so there is no application log to read.taskRoleArn— used by your code at runtime for the AWS calls it makes. This is the one that needs S3 or DynamoDB access.
The tell: a task that goes PROVISIONING → STOPPED with a
ResourceInitializationError and an empty log group is an execution-role problem every
time. And the log group must already exist — awslogs does not create it unless you set
awslogs-create-group.
Running it
aws ecs register-task-definition --cli-input-json file://task-definition.json
aws ecs create-service \
--cluster stayhub \
--service-name stayhub-api \
--task-definition stayhub-api \
--desired-count 2 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[subnet-aaa,subnet-bbb],securityGroups=[sg-ccc]}" \
--load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:us-west-2:111122223333:targetgroup/stayhub/abc,containerName=api,containerPort=8000"Two subnets in different Availability Zones, because two tasks in one AZ is not redundancy.
networkMode: awsvpc is what makes that block necessary: every task gets its own
elastic network interface and its own private IP, so it is addressed like a small instance rather
than sharing a host's ports. That is why the security group goes on the task rather than on a
machine, and why the load balancer's target type is ip and not
instance.
Put tasks in private subnets with the load balancer in the public ones. A task
in a public subnet with assignPublicIp=ENABLED works and is the default many tutorials
reach for, but it puts your container directly on the internet for no benefit.
Two Spring Boot services, two ports, one cluster
A cluster is a namespace, so running several microservices in one costs nothing extra and is the
normal arrangement. Take two Spring Boot apps: stayhub-api on 8080 and
stayhub-search on 8081.
Each gets its own task definition, its own service, and its own target group — that last one is the piece people try to share and cannot, because a target group has exactly one port and one health check.
aws elbv2 create-target-group --name stayhub-api \
--protocol HTTP --port 8080 --target-type ip --vpc-id vpc-0abc123 \
--health-check-path /actuator/health
aws elbv2 create-target-group --name stayhub-search \
--protocol HTTP --port 8081 --target-type ip --vpc-id vpc-0abc123 \
--health-check-path /actuator/healthSpring Boot Actuator's /actuator/health is the natural health-check path, and it is
worth restricting the exposed endpoints to health so the load balancer probe cannot
reach anything else.
One load balancer fronts both, and listener rules decide which service a request reaches — matched by priority, lowest first, first match wins:
aws elbv2 create-rule --listener-arn "$LISTENER" --priority 10 \
--conditions '[{"Field":"path-pattern","PathPatternConfig":{"Values":["/search*"]}}]' \
--actions '[{"Type":"forward","TargetGroupArn":"'"$SEARCH_TG"'"}]'/search* sits at priority 10 and the API is the listener's default action, so
anything not matching falls through to it. Reverse those and a broad rule swallows every request
before the specific one is evaluated.
How they talk to each other, without leaving the cluster
The obvious way for the API to call search is its public URL. Do not: the request leaves the VPC, crosses the internet, comes back through the load balancer, and you pay for the hop and the latency on every internal call.
Service Connect gives every service a short DNS name inside a namespace.
Declare the port in the task definition with a name:
{
"name": "search",
"portMappings": [
{ "name": "search-8081", "containerPort": 8081, "appProtocol": "http" }
]
}Then the service publishes it under an alias:
aws ecs create-service --cluster stayhub --service-name stayhub-search \
--task-definition stayhub-search --desired-count 2 --launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[subnet-aaa,subnet-bbb],securityGroups=[sg-search]}" \
--load-balancers "targetGroupArn=$SEARCH_TG,containerName=search,containerPort=8081" \
--service-connect-configuration '{
"enabled": true,
"namespace": "stayhub",
"services": [{
"portName": "search-8081",
"discoveryName": "search",
"clientAliases": [{ "dnsName": "search", "port": 8081 }]
}]
}'The API service enables Service Connect on the same namespace with no services
block — it is a client, not a publisher. It can then resolve search directly:
stayhub:
search:
base-url: http://search:8081@Service
@RequiredArgsConstructor
public class SearchClient {
private final RestClient restClient;
// http://search:8081 resolves inside the cluster. No load balancer, no
// public DNS, no internet egress — and the name does not change when
// tasks are replaced.
public List<PropertyDto> search(String query) {
return restClient.get()
.uri("/search?q={q}", query)
.retrieve()
.body(new ParameterizedTypeReference<>() {});
}
}Service Connect runs a proxy sidecar in each task, so it also load-balances across the search tasks and retries a failed connection — which is why this beats plain DNS service discovery.
Two details decide whether this works or half-works.
containerPort must match the task definition, not the listener
port — the load balancer listens on 443, the container on 8081, and the target group bridges them.
And both services must share a namespace, or the name will not resolve; in Spring
that surfaces as a connection failure rather than a configuration error.
Give each service its own security group. With awsvpc the group
attaches per task, so search allows 8081 from the API's group and the load balancer's group, and
nothing else. The API needs no inbound rule from search at all.
Deploying a new version
A deploy is: push a new image, register a new task-definition revision, tell the service to use it. ECS starts new tasks, waits for them to pass the target group's health check, then drains the old ones.
aws ecs update-service --cluster stayhub --service stayhub-api \
--task-definition stayhub-api:42
aws ecs wait services-stable --cluster stayhub --services stayhub-apiThat waiter is what makes a deploy script honest — without it the command returns immediately and your pipeline reports success before anything has actually started.
Turn on the deployment circuit breaker when you create the service. Without it, a revision that crashes on startup loops forever, replacing failed tasks until someone notices; with it, ECS gives up and rolls back to the last working revision on its own.
Scaling the service
--desired-count is a fixed number until you attach Application Auto Scaling. Target
tracking is the version worth using — name a metric and a value, and AWS adds or removes tasks to
hold it there.
aws application-autoscaling register-scalable-target \
--service-namespace ecs --resource-id service/stayhub/stayhub-api \
--scalable-dimension ecs:service:DesiredCount --min-capacity 2 --max-capacity 10Target CPU around 60-70% is a sane start; request count per target is usually better for an API,
because it reacts to load rather than to the symptom of load. Keep --min-capacity at
2 or more — during a rolling deploy, one task means zero tasks.
Reading a failure
aws ecs describe-tasks --cluster stayhub --tasks arn:aws:ecs:us-west-2:111122223333:task/stayhub/abc \
--query "tasks[].[lastStatus,stopCode,stoppedReason]"stoppedReason is the field that answers the question. OutOfMemory
means the container exceeded its memory limit and was killed — raise memory, or find
the leak. Essential container in task exited means your process exited, and the reason
is in CloudWatch. Anything mentioning ResourceInitialization is the execution role.