Skip to content
AWS projects 8 min read

Project: Containerized App on ECS Fargate

This project takes a plain web application, packages it into a container image, and runs it on AWS without you ever touching a server. You will push the image to a registry, run it on ECS Fargate (a way to run containers where AWS manages the underlying machines for you), and put a load balancer in front so the public internet can reach it. Containers are the modern default for deploying apps because they bundle your code and its dependencies into one portable unit that runs the same everywhere. By the end you will have a live, publicly reachable app and know exactly how to tear it all down so you stop paying.

What you are building

Here is the full picture. A user hits a public URL. That URL points at an Application Load Balancer (ALB) — a managed traffic distributor that lives in your public subnets. The ALB forwards requests to your containers, which run as ECS tasks on Fargate inside private subnets (subnets with no direct route to the internet). The container image itself lives in ECR (Elastic Container Registry), AWS’s private Docker registry.

PieceWhat it isWhy it is here
ECRPrivate container image registryStores the image Fargate pulls
ECS clusterLogical grouping for your servicesContainer that holds your service
Task definitionBlueprint for a running containerCPU, memory, image, roles, ports
FargateServerless compute for containersNo EC2 instances to patch
ALBLayer-7 load balancerPublic entry point, health checks
Target groupPool of running tasksWhere the ALB sends traffic

This assumes you already have a VPC (Virtual Private Cloud, your isolated network) with public and private subnets and a NAT gateway. If not, build the three-tier VPC project first.

Step 1 — Build and push an image to ECR

ECR is where your image lives. When to use ECR instead of Docker Hub: any time you run on AWS — pulls stay inside the AWS network (faster, no rate limits) and access is controlled by IAM (Identity and Access Management, AWS’s permission system). Use Docker Hub only for public, open-source images.

First create the repository.

aws ecr create-repository --repository-name my-app --region us-east-1

Output:

{
    "repository": {
        "repositoryArn": "arn:aws:ecr:us-east-1:123456789012:repository/my-app",
        "repositoryUri": "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app"
    }
}

Console: ECS/ECR → Amazon ECR → Repositories → Create repository → name my-app → Create.

Now log in, build, tag, and push. The login command pipes a temporary token into Docker.

aws ecr get-login-password --region us-east-1 \
  | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com

docker build -t my-app .
docker tag my-app:latest 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest

Cost note: ECR storage is about $0.10 per GB-month. A typical app image is well under 1 GB, so this is cents. Delete old image tags to keep it that way.

Step 2 — Create the IAM roles

This is where most beginners get confused, so read carefully. You need two different roles, and they are not interchangeable.

RoleUsed byGrants permission to
Task execution roleThe Fargate agent (the infrastructure)Pull the image from ECR, write logs to CloudWatch
Task roleYour application codeCall AWS APIs your app needs (e.g. read from S3, DynamoDB)

A simple way to remember it: the execution role gets the container running; the task role lets the running app do things. If your app doesn’t call any AWS service, you can skip the task role entirely, but you almost always need the execution role.

Create the execution role using the AWS-managed policy built exactly for this.

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

Console: IAM → Roles → Create role → Trusted entity Elastic Container Service → use case Elastic Container Service Task → attach AmazonECSTaskExecutionRolePolicy.

Step 3 — Create the cluster

A cluster is just a namespace that groups your services and tasks. With Fargate there are no servers in it to manage.

aws ecs create-cluster --cluster-name my-cluster

Console: ECS → Clusters → Create cluster → name my-cluster → leave Fargate selected (it is the default) → Create.

Step 4 — Register the task definition

The task definition is the blueprint: which image, how much CPU and memory, which port, which roles, and where logs go. Note networkMode: awsvpc — this is mandatory for Fargate and gives each task its own elastic network interface and private IP. This is why you must use IP-type targets later (more on that in Step 5).

Save this as task-def.json:

{
  "family": "my-app",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "containerDefinitions": [{
    "name": "my-app",
    "image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest",
    "portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "/ecs/my-app",
        "awslogs-region": "us-east-1",
        "awslogs-stream-prefix": "ecs",
        "awslogs-create-group": "true"
      }
    }
  }]
}
aws ecs register-task-definition --cli-input-json file://task-def.json

The cpu: "256" / memory: "512" pair is the smallest Fargate size: 0.25 vCPU and 0.5 GB RAM. Cost note: that runs roughly $9 per month if left on 24/7. Pick the smallest size that works and scale up only when load demands it.

Step 5 — Create the ALB and target group

The ALB needs its own security group allowing inbound HTTP on port 80, and the tasks need a security group allowing inbound traffic from the ALB on port 8080.

Create the target group with --target-type ip. This is critical: because Fargate uses awsvpc mode, each task has its own IP, so the ALB must register IP addresses, not EC2 instance IDs. Choosing instance here will fail to register your tasks.

aws elbv2 create-target-group --name my-app-tg \
  --protocol HTTP --port 8080 --vpc-id vpc-0a1b2c3d \
  --target-type ip --health-check-path /health

aws elbv2 create-load-balancer --name my-app-alb \
  --subnets subnet-0a1b2c3d subnet-0e4f5a6b \
  --security-groups sg-0a1b2c3d --scheme internet-facing

Then create a listener on port 80 that forwards to the target group (use the ARNs returned above):

aws elbv2 create-listener --load-balancer-arn <alb-arn> \
  --protocol HTTP --port 80 \
  --default-actions Type=forward,TargetGroupArn=<tg-arn>

Console: EC2 → Target Groups → Create → target type IP addresses → port 8080. Then EC2 → Load Balancers → Create → Application Load Balancer → public subnets → add a listener on 80 forwarding to the group.

Step 6 — Create the service

The service keeps the desired number of tasks running and wires them into the ALB. Put the tasks in private subnets and attach the task security group.

aws ecs create-service --cluster my-cluster --service-name my-app-svc \
  --task-definition my-app --desired-count 2 --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[subnet-0c7d8e9f,subnet-0a2b3c4d],securityGroups=[sg-0task1234],assignPublicIp=DISABLED}" \
  --load-balancers "targetGroupArn=<tg-arn>,containerName=my-app,containerPort=8080"

Output:

{
    "service": {
        "serviceName": "my-app-svc",
        "status": "ACTIVE",
        "desiredCount": 2,
        "runningCount": 0,
        "launchType": "FARGATE"
    }
}

Within a minute or two runningCount should reach 2 and the target group should show healthy targets. Open the ALB’s DNS name in a browser and your app loads.

The big gotcha: tasks stuck in PENDING

This is the single most common Fargate failure, so internalize it. Your tasks sit in PENDING then die, or never go healthy. The cause is almost always networking: a Fargate task in a private subnet with assignPublicIp=DISABLED has no route to the internet. It therefore cannot reach ECR to pull your image or CloudWatch to write logs — both are public AWS endpoints.

You have two fixes:

FixHow it worksWhen to choose
NAT gatewayPrivate subnet routes outbound through a NAT in a public subnetYou already have one; simplest; ~$32/mo + data
VPC endpointsPrivate links to ECR, S3, and CloudWatch inside your VPCCheaper at scale; no internet egress needed; more setup

For ECR specifically you need three endpoints if you go the VPC-endpoint route: ecr.api, ecr.dkr, and an S3 gateway endpoint (ECR stores image layers in S3), plus a logs endpoint for CloudWatch.

Warning: If assignPublicIp=DISABLED and there is no NAT and no VPC endpoints, your tasks will always fail to start. Check the stopped task’s “Stopped reason” in the console — it will say something like CannotPullContainerError. That message is your confirmation it is a networking problem, not a code problem.

Step 7 — Clean up

Containers and ALBs cost money every hour. Delete in reverse order of creation.

aws ecs update-service --cluster my-cluster --service my-app-svc --desired-count 0
aws ecs delete-service --cluster my-cluster --service my-app-svc --force
aws elbv2 delete-load-balancer --load-balancer-arn <alb-arn>
aws elbv2 delete-target-group --target-group-arn <tg-arn>
aws ecs delete-cluster --cluster my-cluster
aws ecr delete-repository --repository-name my-app --force

If you created a NAT gateway just for this, delete it too — at ~$32/month it is the most expensive piece in the whole stack.

Best Practices

  • Use the smallest Fargate task size that meets demand (start at 256/512) and rely on service auto scaling for spikes rather than over-provisioning.
  • Keep the task execution role and task role separate, and grant each only the permissions it actually needs.
  • Always put tasks in private subnets and front them with the ALB; never give tasks public IPs unless you have a deliberate reason.
  • Prefer VPC endpoints over a NAT gateway when ECR/CloudWatch are your only outbound needs — it is cheaper and keeps traffic off the internet.
  • Pin image tags (e.g. a git SHA) instead of latest so deployments are reproducible and rollbacks are precise.
  • Set a meaningful health check path (like /health) so the ALB only sends traffic to genuinely ready tasks.
  • Use a lifecycle policy on the ECR repo to expire old, untagged images and keep storage costs near zero.
Last updated June 15, 2026
Was this helpful?