Fargate & ALB
Run Fargate tasks, wire ALB target groups, configure service discovery, and debug the ECS failures that leave you at half capacity.
Prerequisites
Terminal Notes
New terminal session setup
If you open a new terminal tab, shell variables like $AWS_PROFILE, $AWS_REGION, and $ALB_DNS may not carry over automatically. Use the desired AWS CLI profile and region before running lab commands:
export AWS_PROFILE=perpetual
export AWS_REGION=us-east-1Verify that you're using the right AWS account:
aws sts get-caller-identityTo save all lab variables into a reusable env file:
cat > ~/ecs-lab.env << EOF
export AWS_REGION="$AWS_REGION"
export AWS_ACCOUNT="$AWS_ACCOUNT"
# Networking
export VPC_ID="$VPC_ID"
export SUBNET_1="$SUBNET_1"
export SUBNET_2="$SUBNET_2"
export ALL_SUBNETS="$ALL_SUBNETS"
export TASK_SG="$TASK_SG"
export ALB_SG="$ALB_SG"
# Load balancer
export ALB_ARN="$ALB_ARN"
export ALB_DNS="$ALB_DNS"
export TG_ARN="$TG_ARN"
# ECS runtime
export TASK_ARN="$TASK_ARN"
export ENI_ID="$ENI_ID"
export PUBLIC_IP="$PUBLIC_IP"
export EXEC_TASK="$EXEC_TASK"
# App / image
export IMAGE_URI="$IMAGE_URI"
export IMAGE_URI_V2="$IMAGE_URI_V2"
export IMAGE_URI_V3="$IMAGE_URI_V3"
# IAM / secrets / storage
export S3_BUCKET="$S3_BUCKET"
export SECRET_ARN="$SECRET_ARN"
export AWS_PROFILE=perpetual
EOFCluster + Task Definition + Run Task
Goal
Get a container running on Fargate from scratch, using only the CLI. Understand the full task lifecycle.
Estimated time: 2–3 hours
Create your cluster
What's happening here
A cluster is just a logical boundary, it holds no compute by itself. We're registering FARGATE and FARGATE_SPOT as the available capacity providers so later tasks/services can use serverless compute without managing EC2. containerInsights=enabled turns on CloudWatch metrics for the cluster from day one, you'll use this in Session 5.
aws ecs create-cluster \
--cluster-name ecs-lab \
--capacity-providers FARGATE FARGATE_SPOT \
--default-capacity-provider-strategy \
capacityProvider=FARGATE,weight=1,base=1 \
--settings name=containerInsights,value=enabled \
--region $AWS_REGIONVerify:
aws ecs describe-clusters --clusters ecs-lab --region $AWS_REGION \
--query 'clusters[0].{name:clusterName,status:status,settings:settings}'Checkpoint: status should be ACTIVE: settings should show containerInsights: enabled.
Create an ECR repo and push an image
What's happening here
ECS pulls your container image from a registry at task launch time. ECR is AWS's private registry, it lives inside your account, so there's no internet exposure and auth is handled by IAM. We're building a minimal Python HTTP server (not nginx) so you can see exactly what's running and modify it later. The image tag :v1 will matter when you deploy v2 in Session 2, you'll see both versions respond during the rollout.
# Create the ECR repository
aws ecr create-repository \
--repository-name ecs-lab-app \
--region $AWS_REGION
# Authenticate Docker to ECR
aws ecr get-login-password --region $AWS_REGION | \
docker login --username AWS \
--password-stdin $AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com
# Create a minimal app
mkdir ecs-lab-app && cd ecs-lab-app
cat > app.py << 'EOF'
from http.server import HTTPServer, BaseHTTPRequestHandler
import os
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
msg = f"Hello from ECS! ENV={os.getenv('APP_ENV', 'not-set')}\n"
self.wfile.write(msg.encode())
def log_message(self, format, *args):
print(f"[{self.address_string()}] {format % args}")
HTTPServer(('0.0.0.0', 8080), Handler).serve_forever()
EOF
cat > Dockerfile << 'EOF'
FROM python:3.11-alpine
WORKDIR /app
COPY app.py .
EXPOSE 8080
CMD ["python", "app.py"]
EOF
# Build, tag, push
IMAGE_URI=$AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/ecs-lab-app:v1
docker build --platform linux/amd64 -t ecs-lab-app .
docker tag ecs-lab-app:latest $IMAGE_URI
docker push $IMAGE_URI
cd ..Checkpoint: docker push completes with no error. Verify:
aws ecr describe-images --repository-name ecs-lab-app --region $AWS_REGIONCreate the Task Execution Role
What's happening here
Before your container even starts, the ECS agent needs to pull the image from ECR and create the CloudWatch log stream. It can't do that with your credentials, it needs its own IAM role. This is the Task Execution Role: it's assumed by the ECS agent (not your app code). The managed policy AmazonECSTaskExecutionRolePolicy grants exactly the right permissions: ECR pull + CloudWatch log write. The trust policy says only ecs-tasks.amazonaws.com can assume this role.
This role lets the ECS agent pull your image and write logs.
# Trust policy
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "ecs-tasks.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"aws:SourceAccount": "018326344213"
}
}
}]
}
EOF
# Create the role
aws iam create-role \
--role-name ecsLabExecutionRole \
--assume-role-policy-document file://trust-policy.json
# Attach the AWS managed policy
aws iam attach-role-policy \
--role-name ecsLabExecutionRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicyCheckpoint:
aws iam get-role --role-name ecsLabExecutionRole \
--query 'Role.{name:RoleName,arn:Arn}'Create a CloudWatch log group
What's happening here
The awslogs log driver streams your container's stdout/stderr to CloudWatch Logs. The log group must exist before the task starts, otherwise the ECS agent fails to initialise the log stream and the task stops immediately. We pre-create it here. In the task definition (next step) we'll reference this group name exactly.
aws logs create-log-group \
--log-group-name /ecs/ecs-lab \
--region $AWS_REGIONRegister your first Task Definition
What's happening here
The task definition is the immutable blueprint for how your container runs. Key decisions made here:
networkMode: awsvpc: each task gets its own ENI and private IP (required for Fargate)cpu: 256, memory: 512: task-level resource cap (Fargate enforces these hard)
CPU/memory note
ECS CPU is measured in CPU units: 1024 units = 1 vCPU: so cpu: 256 gives the task 0.25 vCPU. Memory is measured in MB, so memory: 512 means 512 MB / 0.5 GB RAM. On Fargate, these are hard limits; if the app exceeds the memory limit, the task can stop with an OOM error. On EC2, ECS also uses these values to place tasks on instances, and memory can be configured as a hard limit (memory) or a soft reservation (memoryReservation).
executionRoleArn: the agent role from step 1.3 (for pulling image + writing logs)environment: static env vars baked in at deploy timehealthCheck, ECS uses this to decide if the container is healthy; unhealthy = task replacedlogConfiguration: wires stdout to the CloudWatch log group from step 1.4
Every time you change this JSON and re-register, you get a new revision (:1, :2: etc). Old revisions are never modified, this is your deployment history.
cat > taskdef-v1.json << EOF
{
"family": "ecs-lab-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"executionRoleArn": "arn:aws:iam::${AWS_ACCOUNT}:role/ecsLabExecutionRole",
"runtimePlatform": {
"cpuArchitecture": "ARM64",
"operatingSystemFamily": "LINUX"
},
"containerDefinitions": [
{
"name": "ecs-lab-app",
"image": "${AWS_ACCOUNT}.dkr.ecr.${AWS_REGION}.amazonaws.com/ecs-lab-app:v1",
"essential": true,
"portMappings": [
{ "containerPort": 8080, "protocol": "tcp" }
],
"environment": [
{ "name": "APP_ENV", "value": "lab" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/ecs-lab",
"awslogs-region": "${AWS_REGION}",
"awslogs-stream-prefix": "app"
}
},
"healthCheck": {
"command": ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8080\")' || exit 1"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 10
}
}
]
}
EOF
aws ecs register-task-definition \
--cli-input-json file://taskdef-v1.json \
--region $AWS_REGIONVerify revision was created:
aws ecs describe-task-definition \
--task-definition ecs-lab-app \
--region $AWS_REGION \
--query 'taskDefinition.{family:family,revision:revision,status:status}'Checkpoint: revision 1: status ACTIVE.
Find your default VPC subnets and create a security group
What's happening here
Fargate tasks with awsvpc networking need a VPC subnet and a security group, they work exactly like EC2 instances from a networking perspective. We're using the default VPC for simplicity. The security group is the firewall for your task's ENI: here we open port 8080 from anywhere so you can curl directly. In a real setup you'd restrict this to the ALB's security group only (Session 2 does this properly). We grab two subnets across different AZs so later when we run multiple tasks, ECS can spread them for HA.
# Get default VPC
export VPC_ID=$(aws ec2 describe-vpcs \
--filters Name=isDefault,Values=true \
--query 'Vpcs[0].VpcId' --output text \
--region $AWS_REGION)
echo "VPC: $VPC_ID"
# Get two subnets from that VPC
export SUBNET_1=$(aws ec2 describe-subnets \
--filters Name=vpc-id,Values=$VPC_ID \
--query 'Subnets[0].SubnetId' --output text \
--region $AWS_REGION)
export SUBNET_2=$(aws ec2 describe-subnets \
--filters Name=vpc-id,Values=$VPC_ID \
--query 'Subnets[1].SubnetId' --output text \
--region $AWS_REGION)
echo "Subnets: $SUBNET_1 $SUBNET_2"
# Create a security group for tasks
export TASK_SG=$(aws ec2 create-security-group \
--group-name ecs-lab-task-sg \
--description "ECS lab task security group" \
--vpc-id $VPC_ID \
--query 'GroupId' --output text \
--region $AWS_REGION)
echo "Task SG: $TASK_SG"
# Allow inbound on 8080 from anywhere (lab only, in prod, restrict to ALB SG)
aws ec2 authorize-security-group-ingress \
--group-id $TASK_SG \
--protocol tcp --port 8080 --cidr 0.0.0.0/0 \
--region $AWS_REGIONRun your first task
What's happening here
run-task is a one-shot execution: ECS starts the task but doesn't restart it if it stops. Think of it as the ECS equivalent of docker run. Watch the lifecycle closely: PROVISIONING (ECS finding Fargate capacity) → PENDING (agent pulling the image) → RUNNING (container started, health checks passing). Once running, ECS has allocated a real ENI with a public IP, we extract that IP from the ENI attachment and curl it directly. This confirms end-to-end: image pulled ✅, container started ✅, port 8080 reachable ✅.
TASK_ARN=$(aws ecs run-task \
--cluster ecs-lab \
--task-definition ecs-lab-app:6 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[$SUBNET_1,$SUBNET_2],securityGroups=[$TASK_SG],assignPublicIp=ENABLED}" \
--region $AWS_REGION \
--query 'tasks[0].taskArn' --output text)
echo "Task ARN: $TASK_ARN"Watch the task lifecycle in real time:
Poll every 3 seconds, watch PROVISIONING → PENDING → RUNNING
watch -n 3 "aws ecs describe-tasks \
--cluster ecs-lab \
--tasks $TASK_ARN \
--region $AWS_REGION \
--query 'tasks[0].{status:lastStatus,health:healthStatus,ip:attachments[0].details[?name==\`networkInterfaceId\`].value|[0]}'"Once RUNNING, get the public IP and curl it:
# Get ENI ID from task
ENI_ID=$(aws ecs describe-tasks \
--cluster ecs-lab --tasks $TASK_ARN \
--region $AWS_REGION \
--query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' \
--output text)
# Get public IP from ENI
PUBLIC_IP=$(aws ec2 describe-network-interfaces \
--network-interface-ids $ENI_ID \
--region $AWS_REGION \
--query 'NetworkInterfaces[0].Association.PublicIp' \
--output text)
echo "Public IP: $PUBLIC_IP"
curl http://$PUBLIC_IP:8080
# Expected: Hello from ECS! ENV=labCheckpoint: you get a 200 response from the running task.