Deploy a Python App to ECS Fargate
Deploy a containerized Flask app to ECS Fargate behind a load balancer.
Introduction
30 Second Summary
Think about how streaming services handle millions of viewers without crashing. They distribute incoming traffic across many copies of the same application so no single server gets overwhelmed.
In this project, you will deploy a containerized Python web app to AWS using ECS Fargate and an Application Load Balancer. You'll use Terraform to define your entire infrastructure as code and watch the load balancer distribute traffic across multiple containers in real time.
What You'll Build
A Python Flask web application deployed to AWS ECS Fargate behind an internet-facing Application Load Balancer, with the entire stack managed by Terraform.
By the end of this project, you'll have:
- A Python Flask app containerized with Docker and pushed to Amazon ECR.
- A multi-AZ VPC with public subnets, an internet gateway, and route tables built entirely in Terraform.
- An Application Load Balancer distributing HTTP traffic across multiple ECS Fargate tasks, proving load balancing works by displaying unique task IDs on each refresh.
- Secret Mission: Add ECS auto-scaling that dynamically adjusts the number of running tasks based on CPU utilization.
Are there any prerequisites?
This project assumes you have completed the "Deploy an EC2 Instance with Terraform" project. You should be comfortable with Terraform basics (init/plan/apply/destroy), VPC networking, and security groups.
You also need Docker and Python installed on your machine. Your AWS account should have free-tier credits available.
Build and Push the Container Image
Before you can deploy containers to ECS Fargate, you need a container image stored somewhere AWS can access it.
In this step, you will create a simple Flask web app, package it into a Docker image, and push that image to Amazon ECR (Elastic Container Registry).
In this step, get ready to:
- Create a Python Flask app and Dockerfile.
- Push the container image to Amazon ECR.
- Verify the image is stored in ECR.
Create the Flask application and Dockerfile
The Flask app returns the container's hostname, which in Fargate is the ECS task ID. This means each container responds with a unique identifier, letting you see load balancing in action later.
- In your project directory, create a file called app.py with the following code:
import socket
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
hostname = socket.gethostname()
return f"""
<html>
<head><title>ECS Load Balancer Demo</title></head>
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
<h1>Hello from ECS Fargate!</h1>
<p>Served by task: <strong>{hostname}</strong></p>
<p>Refresh this page to see the load balancer distribute traffic.</p>
</body>
</html>
"""
if __name__ == "__main__":
app.run(host="0.0.0.0", port=80)
What does this code do?
The app creates a single route at / that returns an HTML page displaying the container's hostname.
In ECS Fargate, each task gets a unique hostname matching its task ID. When you later run two tasks behind a load balancer, refreshing the page shows different hostnames, proving traffic is distributed.
- Create a file called requirements.txt to pin the Flask dependency:
flask==3.1.1
- Create a file called Dockerfile to containerize the app:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 80
CMD ["python", "app.py"]
What does the Dockerfile do?
- FROM python:3.12-slim starts from a lightweight Python base image.
- COPY requirements.txt and RUN pip install installs Flask before copying app code. This layer ordering means Docker caches the install step unless dependencies change.
- EXPOSE 80 documents that the container listens on port 80.
- CMD ["python", "app.py"] starts the Flask server when the container runs.
- Save all three files (app.py, requirements.txt, Dockerfile).
- Confirm they exist by running:
ls
You should see app.py, requirements.txt, and Dockerfile listed in the output.
Don't see all three files?
Make sure you created each file in your project directory and not in a subfolder. Double check the file names are exactly app.py, requirements.txt, and Dockerfile (capital D, no extension).
Help me find my project files.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here are the complete files for reference:
import socket
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
hostname = socket.gethostname()
return f"""
<html>
<head><title>ECS Load Balancer Demo</title></head>
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
<h1>Hello from ECS Fargate!</h1>
<p>Served by task: <strong>{hostname}</strong></p>
<p>Refresh this page to see the load balancer distribute traffic.</p>
</body>
</html>
"""
if __name__ == "__main__":
app.run(host="0.0.0.0", port=80)
flask==3.1.1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 80
CMD ["python", "app.py"]
Push the image to Amazon ECR
Amazon ECR is a managed container registry that stores your Docker images so AWS services like ECS can pull them. You will create a repository, authenticate Docker, build the image, and push it.
Find your AWS account ID
Several commands below need your 12-digit AWS account ID. You can find it by clicking your username in the top-right corner of the AWS Management Console. Keep it handy for the next few commands.
- Create an ECR repository called flask-alb-app by running:
aws ecr create-repository --repository-name flask-alb-app --region us-east-1
What does this command do?
This creates a private container image repository in your AWS account. The repository name flask-alb-app is where you will store the Docker image that ECS pulls from during deployment.
You should see JSON output containing the repository URI in the format 123456789012.dkr.ecr.us-east-1.amazonaws.com/flask-alb-app.
Seeing a RepositoryAlreadyExistsException?
This means you already created a repository with this name. That is fine. You can continue to the next command.
Help me fix my ECR repository creation error.
- Authenticate Docker to your ECR registry by running:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.us-east-1.amazonaws.com
What does this command do?
This generates a temporary authentication token and passes it to docker login. Docker needs this credential to push images to your private ECR repository. The token expires after 12 hours.
You should see Login Succeeded in the output.
Login failed or access denied?
Make sure your AWS CLI credentials have the AmazonEC2ContainerRegistryFullAccess permission. Also verify that Docker Desktop is running.
Help me fix my ECR login error.
- Build the Docker image by running:
docker build -t flask-alb-app .
This reads the Dockerfile in the current directory and builds an image tagged flask-alb-app. The first build downloads the base image and installs Flask, which may take a minute.
- Tag the image for ECR and push it by running these commands:
docker tag flask-alb-app:latest [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.us-east-1.amazonaws.com/flask-alb-app:latest
docker push [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.us-east-1.amazonaws.com/flask-alb-app:latest
What do these commands do?
- docker tag creates a new tag that points to your local image but uses the full ECR repository URI. Docker needs this full path to know where to push.
- docker push uploads the image layers to your ECR repository. Only layers that do not already exist remotely are uploaded.
You should see progress bars as each layer uploads, ending with a latest: digest: sha256:... confirmation.
Push denied or authorization error?
Your Docker login token may have expired, or the account ID in the tag does not match your actual account. Re-run the aws ecr get-login-password command above and try the push again.
Help me fix my Docker push error.
Verify the image in ECR
- Confirm the image was pushed successfully by running:
aws ecr list-images --repository-name flask-alb-app --region us-east-1
You should see JSON output containing an imageTag of latest along with an imageDigest. This confirms your image is stored in ECR and ready for ECS to pull.
Seeing an empty imageIds list?
An empty list means the push did not complete. Scroll up in your terminal and check the docker push output for errors. Verify the repository name in your tag matches flask-alb-app exactly.
Help me figure out why my ECR repository is empty.
Your container image is stored in ECR and ready to deploy. Next up, you will write the Terraform configuration that creates the networking and load balancer infrastructure.
Write the Terraform Networking and ALB Configuration
Your container image is sitting in ECR, ready to run. Now you need somewhere to run it and a way for users to reach it.
In this step, you will write the Terraform configuration that creates the network foundation and the Application Load Balancer that will route HTTP traffic to your containers.
In this step, get ready to:
- Create a multi-AZ VPC with public subnets, an internet gateway, and route tables.
- Define security groups for the ALB and ECS tasks.
- Configure an Application Load Balancer with a target group and HTTP listener.
Create main.tf with the provider and VPC networking
Your ECS Fargate tasks need a VPC to live in, and the ALB requires subnets in at least two different Availability Zones.
- In your text editor, create a new file called main.tf inside your project folder (the same folder that contains app.py).
- Add the Terraform provider block and VPC networking resources by pasting this code into main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.2"
}
provider "aws" {
region = "us-east-1"
}
# --- VPC Networking ---
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "ecs-alb-vpc"
}
}
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
tags = {
Name = "ecs-public-subnet-a"
}
}
resource "aws_subnet" "public_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1b"
map_public_ip_on_launch = true
tags = {
Name = "ecs-public-subnet-b"
}
}
What does this code do?
The terraform block pins the AWS provider to version ~> 6.0 so your configuration stays compatible.
The VPC uses 10.0.0.0/16 which gives you over 65,000 IP addresses. Two subnets are placed in different Availability Zones (us-east-1a and us-east-1b) because the ALB requires subnets in at least two AZs for high availability.
The VPC alone cannot reach the internet yet. You need an internet gateway and a route table to make the subnets public.
- Add the internet gateway, route table, and route table associations below the subnet resources in main.tf:
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "ecs-igw"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "ecs-public-rt"
}
}
resource "aws_route_table_association" "public_a" {
subnet_id = aws_subnet.public_a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_b" {
subnet_id = aws_subnet.public_b.id
route_table_id = aws_route_table.public.id
}
What does this code do?
The internet gateway connects your VPC to the public internet. The route table sends all outbound traffic (0.0.0.0/0) through that gateway.
The route table associations link both subnets to this route table, making them public subnets. Fargate tasks need public IPs to pull container images from ECR without a NAT gateway.
- Save main.tf.
Seeing syntax errors in your editor?
Make sure you have no stray closing braces or missing opening braces between resources. Each resource block must be fully closed before the next one begins.
help me fix a syntax error in my Terraform main.tf file
Add security groups for the ALB and ECS tasks
Security groups control which traffic can reach your ALB and your containers. The ALB needs to accept HTTP traffic from anywhere on the internet. The ECS tasks should only accept traffic from the ALB itself.
- Add the following security group resources below the route table associations in main.tf:
# --- Security Groups ---
resource "aws_security_group" "alb" {
name = "alb-sg"
description = "Allow HTTP inbound to ALB"
vpc_id = aws_vpc.main.id
tags = {
Name = "alb-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "alb_all_outbound" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "ecs_tasks" {
name = "ecs-tasks-sg"
description = "Allow traffic from ALB to ECS tasks"
vpc_id = aws_vpc.main.id
tags = {
Name = "ecs-tasks-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "ecs_from_alb" {
security_group_id = aws_security_group.ecs_tasks.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "ecs_all_outbound" {
security_group_id = aws_security_group.ecs_tasks.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
What does this code do?
The ALB security group (alb-sg) allows inbound HTTP on port 80 from any IP address. This is how users reach your app from the internet.
The ECS tasks security group (ecs-tasks-sg) only allows inbound traffic on port 80 from the ALB security group. This means containers are not directly reachable from the internet. Traffic must flow through the load balancer.
Both groups allow all outbound traffic so your containers can pull images and communicate with AWS services.
- Save main.tf.
Not sure about referenced_security_group_id?
The referenced_security_group_id attribute means "allow traffic from any resource that belongs to this other security group." It is more secure than opening a port to a CIDR block because it ties access to identity, not IP addresses.
explain how referenced_security_group_id works in AWS security group rules
Add the Application Load Balancer, target group, and listener
The ALB is the entry point for all HTTP traffic. It forwards requests to healthy containers registered in a target group.
- Add the ALB, target group, and listener resources below the security groups in main.tf:
# --- Application Load Balancer ---
resource "aws_lb" "main" {
name = "ecs-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id]
tags = {
Name = "ecs-alb"
}
}
resource "aws_lb_target_group" "app" {
name = "ecs-target-group"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
}
tags = {
Name = "ecs-target-group"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
Why is target_type set to ip?
Fargate tasks use the awsvpc network mode, which gives each task its own network interface with a private IP address. Because tasks are not EC2 instances, the target group must use ip as the target type to register these IPs directly.
The health check hits the / path on each target. If a container stops responding, the ALB removes it from rotation and stops sending traffic to it.
- Save main.tf.
Your main.tf now defines the complete networking and load balancing layer. Time to validate it.
- Initialize Terraform to download the AWS provider by running this command:
terraform init
- You should see Terraform has been successfully initialized! in the output.
- Validate your configuration by running:
terraform validate
- You should see Success! The configuration is valid. in the output.
Seeing validation errors?
If you see an error about missing closing braces or unexpected tokens, check that every resource block has matching opening and closing { } braces.
If you see an error about the provider not being installed, make sure you ran terraform init first. Terraform needs to download the provider before it can validate your configuration.
help me fix a terraform validate error in my ECS ALB project
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf and that terraform validate passes without errors.
ⓧ I'd like to double check the full code
Here is the complete main.tf up to this point. Compare it against your file:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.2"
}
provider "aws" {
region = "us-east-1"
}
# --- VPC Networking ---
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "ecs-alb-vpc"
}
}
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
tags = {
Name = "ecs-public-subnet-a"
}
}
resource "aws_subnet" "public_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1b"
map_public_ip_on_launch = true
tags = {
Name = "ecs-public-subnet-b"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "ecs-igw"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "ecs-public-rt"
}
}
resource "aws_route_table_association" "public_a" {
subnet_id = aws_subnet.public_a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_b" {
subnet_id = aws_subnet.public_b.id
route_table_id = aws_route_table.public.id
}
# --- Security Groups ---
resource "aws_security_group" "alb" {
name = "alb-sg"
description = "Allow HTTP inbound to ALB"
vpc_id = aws_vpc.main.id
tags = {
Name = "alb-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "alb_all_outbound" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "ecs_tasks" {
name = "ecs-tasks-sg"
description = "Allow traffic from ALB to ECS tasks"
vpc_id = aws_vpc.main.id
tags = {
Name = "ecs-tasks-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "ecs_from_alb" {
security_group_id = aws_security_group.ecs_tasks.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "ecs_all_outbound" {
security_group_id = aws_security_group.ecs_tasks.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
# --- Application Load Balancer ---
resource "aws_lb" "main" {
name = "ecs-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id]
tags = {
Name = "ecs-alb"
}
}
resource "aws_lb_target_group" "app" {
name = "ecs-target-group"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
}
tags = {
Name = "ecs-target-group"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
Your networking and load balancing infrastructure is defined. Next up, you will add the ECS cluster, task definition, and service that actually runs your containers behind this load balancer.
Write the ECS Cluster, Task Definition, and Service
Your networking and load balancer are ready to receive traffic. Now you need the compute layer that actually runs your containers.
In this step, you will define the ECS Fargate resources that pull your container image from ECR, run it as tasks, and register those tasks with the load balancer. This is where your infrastructure goes from "ready to receive" to "ready to serve."
In this step, get ready to:
- Create an IAM execution role so ECS can pull images and write logs.
- Define the ECS cluster, task definition, and CloudWatch log group.
- Wire up the ECS service to the load balancer and output the ALB URL.
Add the IAM execution role
ECS tasks need permission to pull container images from ECR and write logs to CloudWatch. An IAM execution role grants these permissions to the ECS agent running inside each task.
- In main.tf, find the closing } of the aws_lb_listener resource at the bottom of your file.
- Add the following IAM resources below it:
# --- IAM ---
resource "aws_iam_role" "ecs_task_execution" {
name = "ecsTaskExecutionRole"
# Trust policy: allows the ECS service to assume this role
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
tags = {
Name = "ecsTaskExecutionRole"
}
}
# Attach the AWS-managed policy that grants ECR pull + CloudWatch logs permissions
resource "aws_iam_role_policy_attachment" "ecs_task_execution_policy" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
What does this code do?
The aws_iam_role resource creates a role with a trust policy that lets ecs-tasks.amazonaws.com assume it. This is what allows ECS Fargate to act on behalf of your tasks.
The aws_iam_role_policy_attachment attaches the AWS-managed AmazonECSTaskExecutionRolePolicy. This policy grants permissions to pull images from ECR and write logs to CloudWatch.
- Save main.tf.
- You won't see a change yet. The next chunk uses this role in the task definition.
Add the ECS cluster, task definition, and log group
A task definition is like a blueprint for your container. It tells ECS which image to run, how much CPU and memory to allocate, which ports to expose, and where to send logs.
- In main.tf, add the following ECS resources below the IAM section you just added:
# --- ECS ---
# Log group where container stdout/stderr will be stored
resource "aws_cloudwatch_log_group" "ecs" {
name = "/ecs/flask-alb-app"
retention_in_days = 1
tags = {
Name = "ecs-flask-logs"
}
}
resource "aws_ecs_cluster" "main" {
name = "flask-alb-cluster"
tags = {
Name = "flask-alb-cluster"
}
}
resource "aws_ecs_task_definition" "app" {
family = "flask-alb-task"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_task_execution.arn
# Container definition: which image to run, ports, and logging
container_definitions = jsonencode([
{
name = "flask-app"
image = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-east-1.amazonaws.com/flask-alb-app:latest"
essential = true
portMappings = [
{
containerPort = 80
protocol = "tcp"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = "/ecs/flask-alb-app"
"awslogs-region" = "us-east-1"
"awslogs-stream-prefix" = "flask"
}
}
}
])
tags = {
Name = "flask-alb-task"
}
}
What does this code do?
The aws_cloudwatch_log_group creates a destination for container logs. The 1-day retention keeps costs at zero for this demo.
The aws_ecs_cluster is a logical grouping for your tasks and services.
The aws_ecs_task_definition configures Fargate with 256 CPU units (0.25 vCPU) and 512 MiB of memory. The awsvpc network mode gives each task its own network interface. The container definition references your ECR image and routes logs to CloudWatch using the awslogs driver.
Why awsvpc network mode?
Fargate tasks always use the awsvpc network mode. Each task gets its own elastic network interface with a private IP address. This is why the target group uses target type ip instead of instance. The ALB routes traffic directly to each task's IP.
- Save main.tf.
- You won't see a change yet. The next chunk adds the service that ties everything together, plus the data source that the task definition's image reference needs.
Add the ECS service, data source, and output
The ECS service is the orchestrator that keeps your desired number of tasks running and registers them with the load balancer. It connects all the pieces you have built so far.
- In main.tf, add the following below the task definition:
resource "aws_ecs_service" "app" {
name = "flask-alb-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 2
launch_type = "FARGATE"
# Place tasks in both public subnets with the ECS security group
network_configuration {
subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id]
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = true
}
# Register tasks with the target group so the ALB can route to them
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "flask-app"
container_port = 80
}
# The listener must exist before the service tries to register targets
depends_on = [aws_lb_listener.http]
tags = {
Name = "flask-alb-service"
}
}
# --- Data Sources ---
data "aws_caller_identity" "current" {}
# --- Outputs ---
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
What does this code do?
The aws_ecs_service keeps 2 Fargate tasks running at all times. It places them across both public subnets for high availability. The load_balancer block registers each task's IP with the target group so the ALB can route traffic to them.
The depends_on ensures the ALB listener exists before the service tries to register targets. Without it, Terraform might create resources in the wrong order.
The data "aws_caller_identity" block fetches your AWS account ID at plan time. The task definition uses it to construct the full ECR image URL. The output prints the ALB's public DNS name after deployment.
- Save main.tf.
- Validate your complete Terraform configuration by running:
terraform validate
- You should see Success! The configuration is valid. in the output.
Seeing a validation error?
- If you see an error about data.aws_caller_identity not being defined, make sure the data "aws_caller_identity" "current" {} block is present in your file.
- If you see a reference error for aws_iam_role.ecs_task_execution, check that the IAM role resource from the first chunk is present above the task definition.
- Check for missing closing braces. Each resource block must have a matching }.
Help me debug my terraform validate error for ECS resources
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf and you are ready to deploy.
ⓧ I'd like to double check the full code
Here is the complete main.tf with all resources added so far:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.2"
}
provider "aws" {
region = "us-east-1"
}
# --- VPC Networking ---
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "ecs-alb-vpc"
}
}
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
tags = {
Name = "ecs-public-subnet-a"
}
}
resource "aws_subnet" "public_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1b"
map_public_ip_on_launch = true
tags = {
Name = "ecs-public-subnet-b"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "ecs-igw"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "ecs-public-rt"
}
}
resource "aws_route_table_association" "public_a" {
subnet_id = aws_subnet.public_a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_b" {
subnet_id = aws_subnet.public_b.id
route_table_id = aws_route_table.public.id
}
# --- Security Groups ---
resource "aws_security_group" "alb" {
name = "alb-sg"
description = "Allow HTTP inbound to ALB"
vpc_id = aws_vpc.main.id
tags = {
Name = "alb-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "alb_all_outbound" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "ecs_tasks" {
name = "ecs-tasks-sg"
description = "Allow traffic from ALB to ECS tasks"
vpc_id = aws_vpc.main.id
tags = {
Name = "ecs-tasks-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "ecs_from_alb" {
security_group_id = aws_security_group.ecs_tasks.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "ecs_all_outbound" {
security_group_id = aws_security_group.ecs_tasks.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
# --- Application Load Balancer ---
resource "aws_lb" "main" {
name = "ecs-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id]
tags = {
Name = "ecs-alb"
}
}
resource "aws_lb_target_group" "app" {
name = "ecs-target-group"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
}
tags = {
Name = "ecs-target-group"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
# --- IAM ---
resource "aws_iam_role" "ecs_task_execution" {
name = "ecsTaskExecutionRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
tags = {
Name = "ecsTaskExecutionRole"
}
}
resource "aws_iam_role_policy_attachment" "ecs_task_execution_policy" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# --- ECS ---
resource "aws_cloudwatch_log_group" "ecs" {
name = "/ecs/flask-alb-app"
retention_in_days = 1
tags = {
Name = "ecs-flask-logs"
}
}
resource "aws_ecs_cluster" "main" {
name = "flask-alb-cluster"
tags = {
Name = "flask-alb-cluster"
}
}
resource "aws_ecs_task_definition" "app" {
family = "flask-alb-task"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_task_execution.arn
container_definitions = jsonencode([
{
name = "flask-app"
image = "${data.aws_caller_identity.current.account_id}.dkr.ecr.us-east-1.amazonaws.com/flask-alb-app:latest"
essential = true
portMappings = [
{
containerPort = 80
protocol = "tcp"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = "/ecs/flask-alb-app"
"awslogs-region" = "us-east-1"
"awslogs-stream-prefix" = "flask"
}
}
}
])
tags = {
Name = "flask-alb-task"
}
}
resource "aws_ecs_service" "app" {
name = "flask-alb-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 2
launch_type = "FARGATE"
network_configuration {
subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id]
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "flask-app"
container_port = 80
}
depends_on = [aws_lb_listener.http]
tags = {
Name = "flask-alb-service"
}
}
# --- Data Sources ---
data "aws_caller_identity" "current" {}
# --- Outputs ---
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
Your entire Terraform configuration is now complete. Next up, you will deploy the full stack and watch the load balancer distribute traffic across your running containers.
Deploy and Test Load Balancing
Your Terraform configuration is validated and ready to go. Now it is time to bring all those resources to life on AWS and see your load balancer in action.
In this step, you will deploy the entire stack with a single command, then open the ALB URL in your browser. Each time you refresh, the load balancer routes your request to a different Fargate task, and the page displays that task's unique hostname as proof.
In this step, get ready to:
- Deploy all AWS resources with Terraform.
- Test load balancing by refreshing the ALB URL and observing different task IDs.
- Tear down the infrastructure with a single destroy command.
Deploy the infrastructure with Terraform
Before Terraform can create resources, it needs to show you exactly what it plans to do. The terraform plan command reads your main.tf and outputs a preview of every resource it will create, without actually making any changes.
- Preview the infrastructure Terraform will create by running this command:
terraform plan
What does this command do?
Terraform evaluates your configuration, compares it to the current state (empty, since nothing is deployed yet), and lists every resource it needs to create. You should see a plan with approximately 20 resources to add.
- Review the plan output. You should see a summary line like Plan: 20 to add, 0 to change, 0 to destroy (the exact count may vary slightly).
- Deploy the full stack by running:
terraform apply
What happens during apply?
Terraform generates a fresh plan and then prompts you for confirmation before making any changes. It displays the plan and waits for your approval.
You will see this prompt:
Do you want to perform these actions? ... Only 'yes' will be accepted to approve. Enter a value:
- Type yes and press Enter to confirm the deployment.
Terraform will now create all your resources. This process takes 2-4 minutes as it provisions the VPC, subnets, ALB, ECS cluster, and launches the Fargate tasks.
- Wait for Terraform to finish. At the end of the output, you should see Apply complete! followed by the alb_dns_name output value.
- Copy the alb_dns_name value from the output. It looks something like ecs-alb-123456789.us-east-1.elb.amazonaws.com.
Apply failed with an error?
If you see an error about the IAM role already existing, a previous attempt may have partially created resources. Run terraform destroy to clean up, then try terraform apply again.
If you see a permissions error, make sure your AWS CLI user has the required policies: AmazonECS_FullAccess, AmazonEC2ContainerRegistryFullAccess, ElasticLoadBalancingFullAccess, CloudWatchLogsFullAccess, and IAMFullAccess.
Help me debug my terraform apply error.
Test load balancing in your browser
Your ECS tasks need a moment to start up and register as healthy with the target group. The ALB health checks run every 30 seconds, so it can take up to a minute before the tasks pass their first health check.
- Wait about 60-90 seconds after the apply completes for the tasks to become healthy.
- Open your browser and paste the alb_dns_name URL into the address bar.
- Press Enter to load the page.
You should see a page that says "Hello from ECS Fargate!" with a task ID displayed below it.
- Refresh the page several times (5-10 times).
- Watch the "Served by task" value change between refreshes. You should see two different task IDs appear, proving the ALB is distributing traffic across both Fargate tasks.
Why do I see two different task IDs?
Your ECS service runs 2 tasks (set by desired_count = 2 in the Terraform config). Each Fargate task gets a unique hostname, which is its ECS task ID.
The Application Load Balancer uses a round-robin algorithm by default to distribute incoming requests across healthy targets in the target group. Each refresh may land on a different task.
Seeing a 503 error or blank page?
The tasks may still be starting up. Wait another 60 seconds and try again.
If the 503 persists, check that your ECR image was pushed correctly. The ECS tasks need to pull the image to start. You can verify by checking the ECS service events in the AWS console.
My ALB is returning a 503 error and the ECS tasks are not running.
Tear down the infrastructure
You have confirmed that load balancing works. Now it is time to destroy the resources so you do not incur any ongoing charges from the ALB or Fargate tasks.
- Destroy all Terraform-managed resources by running:
terraform destroy
What does this command do?
Terraform reads its state file, generates a destroy plan showing every resource it will delete, and prompts for confirmation. You will see a prompt asking:
Do you really want to destroy all resources? ... There is no undo. Only 'yes' will be accepted to confirm. Enter a value:
- Type yes and press Enter to confirm the destruction.
- Wait for Terraform to finish. You should see Destroy complete! at the end of the output, confirming all resources have been removed.
Your ECR repository still exists
The ECR repository you created in Step 1 was made with the AWS CLI, not Terraform. It is not tracked in the Terraform state, so terraform destroy does not remove it. You will clean this up in the Before You Go section.
Destroy taking a long time?
Terraform destroys resources in dependency order. The ECS service must drain its tasks before the target group and ALB can be removed. This can take 2-5 minutes total.
If it hangs for more than 10 minutes, check the AWS console to see if any resources are stuck in a deleting state.
Help me troubleshoot why terraform destroy is taking too long.
Secret mission
Add ECS Auto-Scaling
Your service runs a fixed number of tasks right now. What happens when traffic spikes? In this secret mission, you'll add auto-scaling so ECS automatically adjusts the number of running tasks based on CPU utilization.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The ALB and Fargate tasks will consume AWS credits while running, so make sure to clean up if you are done experimenting.
Cost warning
The Application Load Balancer incurs hourly charges (~$0.02/hr) while running. Combined with the Fargate tasks, expect ~$1-2/day if left running. If you are finished with this project, choose Pause or Delete below to avoid unnecessary charges.
Resources you used:
- ECS cluster, service, and Fargate tasks. Consumes free-tier credits while running.
- Application Load Balancer. Incurs hourly charges while active.
- ECR repository (flask-alb-app) with stored container image. Minimal storage cost.
- VPC, subnets, internet gateway, route tables, and security groups. No ongoing cost.
- CloudWatch log group (/ecs/flask-alb-app). Minimal cost for stored logs.
- IAM role (ecsTaskExecutionRole). No ongoing cost.
- Local project files (app.py, Dockerfile, requirements.txt, main.tf).
Keep everything running
No action needed. Choose this if you are still actively building or testing your deployment.
- Your ALB and Fargate tasks will remain active and continue consuming credits.
- Your ECR repository will keep the container image stored.
- Your local project files remain on your Desktop.
Pause - I'll come back to this later
Shut down running tasks to stop charges while keeping your infrastructure and files intact.
- You cannot pause Fargate tasks directly. Instead, set desired_count to 0 in the aws_ecs_service resource in main.tf.
- Run terraform apply to update the service. This stops all running tasks and removes the ALB charges for processing traffic.
- When you are ready to resume, set desired_count back to 2 and run terraform apply again.
Why not just stop the ALB?
The ALB itself incurs a small hourly charge even with no traffic. However, with zero running tasks, no traffic flows through, so the cost is minimal. If you want to eliminate all charges, choose Delete instead.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes everything created during the project.
- Destroy all Terraform-managed resources by running this command from the folder containing your main.tf:
terraform destroy
- Type yes when prompted to confirm.
- Delete the ECR repository (which was created outside of Terraform) by running:
aws ecr delete-repository --repository-name flask-alb-app --force --region us-east-1
- Delete your local project files. On macOS/Linux:
cd ~/Desktop
rm -rf flask-alb-app
- On Windows:
cd ~\Desktop
rmdir /s /q flask-alb-app
- Verify that the resources are gone by checking the ECS console. You should see no clusters, services, or tasks remaining.
Nice Work!
Nice Work!
Nice work! You just deployed a containerized Python application to AWS ECS Fargate behind an Application Load Balancer, all managed as reproducible infrastructure code with Terraform.
You've learned how to:
- Built a Python Flask container image and pushed it to Amazon ECR for ECS to pull at deployment time.
- Wrote a Terraform configuration that creates a multi-AZ VPC, Application Load Balancer, ECS cluster, Fargate task definition, and service.
- Deployed the stack and observed the ALB distributing HTTP requests across multiple container tasks, proving load balancing in action by seeing different task IDs on each refresh.
- Secret Mission: Added ECS auto-scaling with a target tracking policy to dynamically adjust task count based on CPU utilization.
Ready to quiz yourself?