Deploy a Containerized App to ECS

Deploy a Docker app to AWS ECS Fargate using Terraform as your IaC tool.

Introduction

30 Second Summary

Every time you deploy an app, someone has to set up servers, configure networking, and make sure traffic reaches the right place. Doing that by hand is slow and impossible to repeat reliably.

In this project, you will containerize a web application with Docker, then use Terraform to define and deploy a complete AWS infrastructure that runs your container on ECS Fargate.

What You'll Build

A containerized Nginx web application running on AWS ECS Fargate, with every piece of cloud infrastructure defined in code and deployed through Terraform.

By the end of this project, you'll have:

  • A custom Docker image with a simple HTML page, pushed to Amazon ECR.
  • A complete Terraform configuration that provisions a VPC, subnets, Application Load Balancer, and ECS cluster from scratch.
  • A live Fargate service accessible via a public load balancer URL, proving you can do Docker + Infrastructure as Code + cloud deployment in one shot.
  • Secret Mission: Add auto-scaling to your ECS service so it dynamically adjusts capacity under load.

Are there any prerequisites?

You need a Linux machine with Docker, Terraform CLI (version 1.5.0+), and AWS CLI already installed and configured with your AWS account credentials.

Basic familiarity with the Linux terminal and Docker concepts is helpful, but every command is explained step by step.

Set Up Your Project Structure

Before you can deploy anything to the cloud, you need a solid project foundation.

In this step, you will create the project directory, write your Terraform configuration files, and build a simple Docker image.

In this step, get ready to:
  • Create a project directory and initialize Terraform with the AWS provider.
  • Create a custom HTML page and Dockerfile for your Nginx web app.
  • Verify that Docker builds successfully and Terraform initializes without errors.
Create the project directory and Terraform configuration

Terraform needs a working directory containing .tf files that describe your infrastructure.

You will create three Terraform files: one for the provider configuration, one for input variables, and one for outputs you want to see after deployment.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ecs-fargate-demo
cd ecs-fargate-demo
  • Confirm the folder exists by running:
ls ~/Desktop | grep ecs

You should see ecs-fargate-demo in the output.

  • Create providers.tf inside your ecs-fargate-demo folder with the following content:
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
  required_version = ">= 1.5.0"
}

provider "aws" {
  region = var.aws_region
}

What does this file do?

The terraform block declares which providers your project needs.

  • source = "hashicorp/aws" tells Terraform to download the official AWS provider plugin.
  • version = "~> 6.0" pins the provider to any 6.x release, preventing breaking changes from a future major version.
  • The provider "aws" block configures the region using a variable you will define next.
  • Save providers.tf.
  • Create variables.tf with the following content:
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Project name used for resource naming"
  type        = string
  default     = "ecs-fargate-demo"
}

variable "container_port" {
  description = "Port the container listens on"
  type        = number
  default     = 80
}

What does this file do?

Variables let you reuse values across multiple files without hardcoding them.

  • aws_region sets where your resources are deployed. The default is us-east-1.
  • project_name is used to name every resource consistently.
  • container_port defines the port your container listens on. Nginx uses port 80 by default.
  • Save variables.tf.
  • Create outputs.tf with the following content:
output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = aws_lb.main.dns_name
}

output "ecr_repository_url" {
  description = "URL of the ECR repository"
  value       = aws_ecr_repository.app.repository_url
}

output "ecs_cluster_name" {
  description = "Name of the ECS cluster"
  value       = aws_ecs_cluster.main.name
}

What does this file do?

Outputs tell Terraform which values to display after a successful terraform apply.

You will use the alb_dns_name output at the end of this project to access your running app in a browser.

  • Save outputs.tf.
  • Initialize Terraform by running this command:
terraform init

You should see a message ending with Terraform has been successfully initialized! in green text.

What does terraform init do?

This command downloads the AWS provider plugin you specified in providers.tf and creates a .terraform directory to store it.

It also creates a .terraform.lock.hcl lock file that pins the exact provider version for reproducibility.

Seeing an error during init?

  • If you see Could not retrieve the list of available versions, check your internet connection. Terraform needs to download the provider plugin from the registry.
  • If you see a version constraint error, confirm your Terraform CLI version is 1.5.0 or higher by running terraform --version.

Help me debug my terraform init error.

Create the HTML page and Dockerfile

Your web application is a single HTML page served by Nginx.

The Dockerfile packages this page into a container image that can run anywhere Docker is supported.

  • Create index.html inside your ecs-fargate-demo folder with the following content:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ECS Fargate App</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
        }
        .container {
            text-align: center;
            padding: 2rem;
        }
        h1 {
            font-size: 2.5rem;
            margin-bottom: 1rem;
        }
        p {
            font-size: 1.2rem;
            opacity: 0.9;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Hello from ECS Fargate!</h1>
        <p>This app was deployed with Terraform.</p>
        <p>Infrastructure as Code in action.</p>
    </div>
</body>
</html>

What does this page look like?

This HTML creates a centered white heading that says "Hello from ECS Fargate!" on a purple gradient background.

You will see this page in your browser once the deployment is complete. It proves your container is running and serving traffic through the load balancer.

  • Save index.html.
  • Create a Dockerfile (no file extension) with the following content:
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

What does this Dockerfile do?

  • FROM nginx:alpine starts from a lightweight Nginx image as the base.
  • COPY places your HTML file where Nginx serves content from.
  • EXPOSE 80 documents that the container listens on port 80.
  • CMD starts Nginx in the foreground so the container stays running.
  • Save the Dockerfile.
Verify your Docker build

A successful Docker build confirms that your Dockerfile and HTML file are correctly structured.

  • Build your Docker image by running this command:
docker build -t ecs-fargate-demo .

You should see output ending with a line like Successfully tagged ecs-fargate-demo:latest or a build ID confirming the image was created.

What does this command do?

The -t ecs-fargate-demo flag gives your image a human-readable name.

The . at the end tells Docker to look for the Dockerfile in the current directory.

Build failed?

  • If you see COPY failed: file not found, make sure index.html is in the same folder as your Dockerfile.
  • If you see Cannot connect to the Docker daemon, make sure Docker is running on your machine.
  • Check that your file is named exactly Dockerfile with a capital D and no file extension.

Help me fix my Docker build error.

  • Confirm your project files are all in place by running:
ls

You should see Dockerfile, index.html, outputs.tf, providers.tf, and variables.tf listed in the output.

✔️ Awesome, I've got everything!

Great. Make sure all your files are saved before moving on to the next step.

ⓧ I'd like to double check the full code

Here are all the files you should have in your ecs-fargate-demo directory.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
  required_version = ">= 1.5.0"
}

provider "aws" {
  region = var.aws_region
}
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Project name used for resource naming"
  type        = string
  default     = "ecs-fargate-demo"
}

variable "container_port" {
  description = "Port the container listens on"
  type        = number
  default     = 80
}
output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = aws_lb.main.dns_name
}

output "ecr_repository_url" {
  description = "URL of the ECR repository"
  value       = aws_ecr_repository.app.repository_url
}

output "ecs_cluster_name" {
  description = "Name of the ECS cluster"
  value       = aws_ecs_cluster.main.name
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ECS Fargate App</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
        }
        .container {
            text-align: center;
            padding: 2rem;
        }
        h1 {
            font-size: 2.5rem;
            margin-bottom: 1rem;
        }
        p {
            font-size: 1.2rem;
            opacity: 0.9;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Hello from ECS Fargate!</h1>
        <p>This app was deployed with Terraform.</p>
        <p>Infrastructure as Code in action.</p>
    </div>
</body>
</html>
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Your project foundation is ready. Next up, you will define the VPC and networking infrastructure that your containers need to communicate with the internet.

Define the VPC and Networking

Your project structure is ready with a Docker image that builds and Terraform initialized. Now you need a network for your container to live in.

In AWS, every resource runs inside a VPC (Virtual Private Cloud). You'll define the entire network from scratch: subnets, internet access, and security rules that control exactly who can talk to your containers.

In this step, get ready to:
  • Create a VPC with two public subnets in different availability zones.
  • Set up internet access with an internet gateway and route table.
  • Define security groups for the load balancer and ECS tasks.
Create the VPC and public subnets

A VPC is your own isolated section of the AWS cloud. Inside it, you place subnets in different availability zones so your app stays available even if one data center has issues.

  • Create a new file called vpc.tf in your ecs-fargate-demo folder and add the following code:
data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.project_name}-vpc"
  }
}

resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-1"
  }
}

resource "aws_subnet" "public_2" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-2"
  }
}

What does this code do?

  • The data source fetches all available availability zones in your region so you don't have to hard-code zone names.
  • The VPC uses a 10.0.0.0/16 CIDR block, giving you 65,536 IP addresses to work with.
  • Each subnet gets a /24 block (256 addresses) in a different availability zone. map_public_ip_on_launch = true means resources in these subnets get public IPs automatically.
  • Save vpc.tf.

Seeing a syntax error when you save?

Check that every opening brace { has a matching closing brace }. HCL is sensitive to unmatched brackets.

Make sure string values like "10.0.0.0/16" are wrapped in double quotes, not single quotes.

Why is my vpc.tf file showing errors?

Add the internet gateway and route table

Subnets alone have no path to the internet. An Internet Gateway connects your VPC to the public internet, and a route table tells traffic where to go.

  • In vpc.tf, add the following code below the second subnet:
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-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 = "${var.project_name}-public-rt"
  }
}

resource "aws_route_table_association" "public_1" {
  subnet_id      = aws_subnet.public_1.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_2" {
  subnet_id      = aws_subnet.public_2.id
  route_table_id = aws_route_table.public.id
}

What does this code do?

  • The internet gateway attaches to your VPC and acts as the door between your network and the public internet.
  • The route table has one rule: send all traffic (0.0.0.0/0) to the internet gateway.
  • The two route table associations link each subnet to this route table, making them truly public.
  • Save vpc.tf.
  • Confirm your HCL syntax is valid by running:
terraform fmt vpc.tf

If the file is valid, the command prints the filename and reformats it. If you see an error, it means there is a syntax problem to fix.

Seeing an error from terraform fmt?

The error message will point to a line number. Check that line for missing closing braces, unclosed strings, or stray characters.

Make sure the route table associations reference the correct subnet and route table names: aws_subnet.public_1.id and aws_route_table.public.id.

Help me fix my vpc.tf syntax error

✔️ Awesome, I've got everything!

Great. Double check you've saved vpc.tf before moving on.

ⓧ I'd like to double check the full code

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.project_name}-vpc"
  }
}

resource "aws_subnet" "public_1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-1"
  }
}

resource "aws_subnet" "public_2" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24"
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-2"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-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 = "${var.project_name}-public-rt"
  }
}

resource "aws_route_table_association" "public_1" {
  subnet_id      = aws_subnet.public_1.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_2" {
  subnet_id      = aws_subnet.public_2.id
  route_table_id = aws_route_table.public.id
}

Why public subnets instead of private?

In production, you would typically place containers in private subnets with a NAT Gateway for outbound internet access. NAT Gateways cost around $0.045/hour, which adds up fast for a learning project.

Public subnets with assign_public_ip let your Fargate tasks pull images from ECR directly. This keeps costs near zero while teaching the same networking fundamentals.

Define the security groups

Security groups act as firewalls for your AWS resources. You need two: one for the Application Load Balancer (accepts HTTP from anyone) and one for the ECS tasks (accepts traffic only from the load balancer).

  • Create a new file called security_groups.tf in your ecs-fargate-demo folder and add the ALB security group:
resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb-sg"
  description = "Security group for ALB"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-alb-sg"
  }
}

What does this security group do?

  • The ingress block allows inbound HTTP traffic (port 80) from any IP address. This is how users on the internet reach your load balancer.
  • The egress block allows all outbound traffic. Protocol -1 means all protocols.

You won't see a change yet. The next chunk defines the ECS tasks security group that references this one.

  • Add the ECS tasks security group below the ALB security group in security_groups.tf:
resource "aws_security_group" "ecs_tasks" {
  name        = "${var.project_name}-ecs-tasks-sg"
  description = "Security group for ECS tasks"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "Allow traffic from ALB"
    from_port       = var.container_port
    to_port         = var.container_port
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-ecs-tasks-sg"
  }
}

What does this security group do?

  • Instead of allowing traffic from any IP, the ingress block uses security_groups = [aws_security_group.alb.id]. This means only traffic originating from the ALB can reach your containers.
  • The port is set to var.container_port (which is 80) so it matches what your Nginx container listens on.
  • Save security_groups.tf.
  • Confirm valid HCL syntax by running:
terraform fmt security_groups.tf

The command prints the filename if successful. If you see an error, check the line number it points to for missing braces or typos.

Seeing a reference error?

If you see an error about aws_security_group.alb being undefined, make sure the ALB security group block is above the ECS tasks block in the same file. Terraform resolves references top-to-bottom within a configuration.

Also confirm that vpc.tf is saved in the same folder. The aws_vpc.main.id reference needs the VPC resource to exist in your configuration.

Help me fix my security groups Terraform code

✔️ Awesome, I've got everything!

Great. Double check you've saved security_groups.tf before moving on.

ⓧ I'd like to double check the full code

resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb-sg"
  description = "Security group for ALB"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-alb-sg"
  }
}

resource "aws_security_group" "ecs_tasks" {
  name        = "${var.project_name}-ecs-tasks-sg"
  description = "Security group for ECS tasks"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "Allow traffic from ALB"
    from_port       = var.container_port
    to_port         = var.container_port
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-ecs-tasks-sg"
  }
}
  • Confirm both new files are in your project by running:
ls *.tf

You should see outputs.tf, providers.tf, security_groups.tf, variables.tf, and vpc.tf in the output.

Your network foundation is complete. You have a VPC with public internet access and security groups that lock down traffic to only what's needed. Next up, you'll create the Application Load Balancer that sits in front of your containers and routes user requests to them.

Create the Application Load Balancer

Your VPC and security groups are in place. Now you need a way for users to actually reach your container from the internet.

An Application Load Balancer (ALB) sits between the internet and your containers. It receives incoming HTTP requests, checks that your containers are healthy, and forwards traffic to them. In this step, you will define the ALB, a target group for routing, and a listener that ties them together.

In this step, get ready to:
  • Define the ALB resource in your public subnets with the ALB security group.
  • Create a target group with IP-based routing and health checks for Fargate.
  • Add an HTTP listener on port 80 that forwards traffic to the target group.
Define the ALB resource

The ALB is the entry point for all HTTP traffic heading to your app. It needs to be internet-facing and placed in both of your public subnets so AWS can assign it a public DNS name.

  • Create a new file called alb.tf inside your ecs-fargate-demo folder.
  • Add the ALB resource definition by pasting this code into alb.tf:
resource "aws_lb" "main" {
  name               = "${var.project_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = [aws_subnet.public_1.id, aws_subnet.public_2.id]

  tags = {
    Name = "${var.project_name}-alb"
  }
}

What does this code do?

This creates an internet-facing Application Load Balancer.

  • internal = false makes the ALB publicly accessible from the internet.
  • security_groups attaches the ALB security group you created in the previous step, which allows HTTP traffic on port 80.
  • subnets places the ALB across both public subnets for high availability.
  • Save alb.tf.
  • Run terraform validate in your terminal. You should see Success! The configuration is valid. confirming no syntax errors.

Seeing a validation error?

  • Check that you saved security_groups.tf and vpc.tf from the previous step. The ALB references resources defined in those files.
  • Make sure the resource names match exactly: aws_security_group.alb, aws_subnet.public_1, and aws_subnet.public_2.

Why is my Terraform ALB resource failing validation?

Create the target group with health checks

A target group tells the ALB where to send traffic and how to check that the destination is healthy. For ECS Fargate, the target type must be ip because Fargate tasks get their own IP addresses instead of running on fixed EC2 instances.

  • Add the target group resource below the ALB resource in alb.tf:
resource "aws_lb_target_group" "app" {
  name        = "${var.project_name}-tg"
  port        = var.container_port
  protocol    = "HTTP"
  vpc_id      = aws_vpc.main.id
  target_type = "ip"

  health_check {
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    path                = "/"
    protocol            = "HTTP"
    matcher             = "200"
  }

  tags = {
    Name = "${var.project_name}-tg"
  }
}

What does the health check do?

The ALB periodically sends a request to each target to confirm it is still alive. If a target fails 3 consecutive checks (unhealthy_threshold), the ALB stops sending it traffic.

  • path = "/" means the ALB hits the root URL of your Nginx container.
  • matcher = "200" means only an HTTP 200 response counts as healthy.
  • interval = 30 checks every 30 seconds. After just 2 successful checks (healthy_threshold), the target is marked healthy again.
  • Save alb.tf.
Add the HTTP listener

A listener watches for incoming connections on a specific port and protocol. You need one on port 80 that forwards all traffic to your target group.

  • Add the listener resource below the target group in alb.tf:
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
  }
}

What does this code do?

This listener connects the ALB to your target group. Any HTTP request arriving on port 80 gets forwarded to aws_lb_target_group.app, which will eventually route to your ECS containers.

  • Save alb.tf.
  • Run terraform validate again. You should see Success! The configuration is valid. confirming all three resources are correctly defined.

Validation failing after adding the listener?

  • Check for missing closing braces. Each resource block needs a closing }, and the default_action block needs its own closing brace too.
  • Confirm that load_balancer_arn references aws_lb.main.arn (not .id). Listeners require the ARN, not the ID.

Help me fix my Terraform ALB listener configuration.

✔️ Awesome, I've got everything!

Great. Double check you have saved alb.tf with all three resources.

ⓧ I'd like to double check the full code

Here is the complete alb.tf file with all three resources:

resource "aws_lb" "main" {
  name               = "${var.project_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = [aws_subnet.public_1.id, aws_subnet.public_2.id]

  tags = {
    Name = "${var.project_name}-alb"
  }
}

resource "aws_lb_target_group" "app" {
  name        = "${var.project_name}-tg"
  port        = var.container_port
  protocol    = "HTTP"
  vpc_id      = aws_vpc.main.id
  target_type = "ip"

  health_check {
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    path                = "/"
    protocol            = "HTTP"
    matcher             = "200"
  }

  tags = {
    Name = "${var.project_name}-tg"
  }
}

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 load balancer is fully defined in code. Next up, you will push your Docker image to a private container registry so ECS can pull and run it.

Build and Push Your Docker Image to ECR

Your ALB and networking infrastructure are defined in code. Now your ECS service needs a Docker image to actually run.

In this step, you'll create a private container registry on AWS called ECR (Elastic Container Registry), build your Docker image, and push it there so ECS can pull it during deployment.

In this step, get ready to:
  • Create an ECR repository using Terraform and apply it.
  • Authenticate Docker to ECR, build your image, and push it.
  • Verify the image appears in your ECR repository.
Create the ECR repository with Terraform

ECR is a fully managed Docker container registry that integrates directly with ECS. Instead of pushing your image to Docker Hub, you'll store it inside your AWS account where ECS can pull it with minimal configuration.

Why ECR instead of Docker Hub?

ECR lives inside your AWS account, so ECS can pull images without external network calls or authentication headaches. Docker Hub also has rate limits on pulls that can break deployments unexpectedly.

  • Create a new file called ecr.tf in your ecs-fargate-demo folder and paste the following code:
resource "aws_ecr_repository" "app" {
  name                 = var.project_name
  image_tag_mutability = "MUTABLE"
  force_delete         = true

  tags = {
    Name = "${var.project_name}-ecr"
  }
}

What does this code do?

This creates a private Docker registry in your AWS account.

  • name uses your project name variable (ecs-fargate-demo) as the repository name.
  • image_tag_mutability = "MUTABLE" allows you to overwrite the latest tag with new image versions.
  • force_delete = true ensures terraform destroy can delete the repository even when it contains images.
  • Save ecr.tf.

Now you need to create this ECR repository in AWS before you can push an image to it. You'll use a targeted apply so Terraform only creates the ECR resource without attempting to provision the rest of your infrastructure.

  • Apply just the ECR repository by running this command:
terraform apply -target=aws_ecr_repository.app
  • Terraform will show a plan to create 1 resource. Type yes and press Enter to confirm.

What is a targeted apply?

The -target flag tells Terraform to only create a specific resource. You need ECR to exist before you can push a Docker image to it, but you don't need the full VPC, ALB, or ECS cluster yet.

  • After the apply completes, retrieve your ECR repository URL by running:
terraform output ecr_repository_url
  • You should see a URL in the format 123456789012.dkr.ecr.us-east-1.amazonaws.com/ecs-fargate-demo. Copy this URL and save it somewhere. You'll need it for the next commands.

Seeing a validation error?

If Terraform reports an error about undeclared resources in outputs.tf (like aws_ecs_cluster.main), temporarily comment out the ecs_cluster_name output block by adding # at the start of each line. You'll uncomment it in the next step when you create the ECS cluster.

Help me fix a Terraform validation error about undeclared resources

Authenticate Docker and push your image to ECR

ECR is a private registry, so Docker needs an authentication token before it can push images. AWS provides a temporary token through the CLI that lasts 12 hours.

  • Authenticate Docker to your ECR registry by running this command:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[ECR_REGISTRY="your-account-id.dkr.ecr.us-east-1.amazonaws.com"]]

What does this command do?

This command does two things in sequence. First, aws ecr get-login-password fetches a temporary authentication token from AWS. Then the pipe (|) passes that token into docker login so Docker can push to your private registry.

The registry URL is the ECR repository URL without the trailing /ecs-fargate-demo part. So if your repository URL is 123456789012.dkr.ecr.us-east-1.amazonaws.com/ecs-fargate-demo, you only use 123456789012.dkr.ecr.us-east-1.amazonaws.com for the login command.

  • You should see Login Succeeded in your terminal output.

Login failed or access denied?

Make sure your AWS CLI is configured with credentials that have ECR permissions. Run aws sts get-caller-identity to confirm your credentials are valid.

Double-check that the registry URL matches your account ID and region exactly. A typo in either will cause authentication to fail.

Help me fix my ECR Docker login

Now you'll build your Docker image locally and push it to ECR. This is a three-command sequence: build, tag, push.

  • Build your Docker image locally by running:
docker build -t ecs-fargate-demo .
  • You should see output ending with Successfully tagged ecs-fargate-demo:latest or a similar build-complete message.
  • Tag your image with the ECR repository URL by running:
docker tag ecs-fargate-demo:latest [[ECR_REPO_URL="your-ecr-repository-url"]]:latest
  • Push the tagged image to ECR by running:
docker push [[ECR_REPO_URL="your-ecr-repository-url"]]:latest

What do the tag and push commands do?

Docker images need a full registry URL as their tag before they can be pushed to a remote registry. The docker tag command creates an alias pointing your local image to your ECR repository address.

The docker push command then uploads the image layers to that remote address. You'll see progress bars as each layer uploads.

  • Wait for the push to complete. You should see each layer uploaded with a final digest hash confirming success.

Push failed or timed out?

If you see no basic auth credentials, your ECR login token may have expired. Re-run the aws ecr get-login-password command from above to re-authenticate.

If the push hangs or times out, check your internet connection. Docker needs to upload the full image layers to AWS.

Help me troubleshoot my Docker push to ECR

Verify the image in your ECR repository
  • Confirm your image was pushed successfully by listing the images in your ECR repository:
aws ecr list-images --repository-name ecs-fargate-demo --region us-east-1
  • You should see JSON output containing an imageTag of latest and an imageDigest starting with sha256:.

Empty image list?

If the imageIds array is empty, your push did not complete successfully. Scroll up in your terminal to check for errors during the docker push step and try pushing again.

Help me figure out why my ECR repository is empty after pushing

✔️ Awesome, I've got everything!

Great. Make sure you've saved ecr.tf and confirmed your image is visible in ECR before moving on.

ⓧ I'd like to double check the full code

Here is the complete ecr.tf file you should have:

resource "aws_ecr_repository" "app" {
  name                 = var.project_name
  image_tag_mutability = "MUTABLE"
  force_delete         = true

  tags = {
    Name = "${var.project_name}-ecr"
  }
}

Your Docker image is now stored in ECR and ready to be pulled by ECS. Next up, you'll create the ECS cluster and service that actually runs your container in the cloud.

Deploy the ECS Cluster and Service

Your Docker image is sitting in ECR, your network and load balancer are ready, but nothing is actually running yet. You need a compute layer that pulls your image and keeps a container alive behind the ALB.

ECS Fargate is the service that does this. It runs your container without you managing any servers. In this step, you will define the IAM permissions ECS needs, create the cluster and task definition, then launch the service and watch it go live.

In this step, get ready to:
  • Define an IAM role that lets ECS pull images and write logs.
  • Create an ECS cluster, task definition, and service in Terraform.
  • Run terraform apply and confirm your container is running behind the ALB.
Create the IAM role for ECS task execution

ECS needs an IAM role so it can pull your Docker image from ECR and send container logs to CloudWatch. Without this role, the service cannot start.

  • Create a new file called iam.tf in your ecs-fargate-demo folder.
  • Add the following code to define the task execution role:
# Define who can assume this role (ECS tasks service)
data "aws_iam_policy_document" "ecs_task_execution_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

# Create the role with the trust policy above
resource "aws_iam_role" "ecs_task_execution" {
  name               = "${var.project_name}-ecs-task-execution-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_execution_assume_role.json

  tags = {
    Name = "${var.project_name}-ecs-task-execution-role"
  }
}

# Attach the AWS managed policy that grants ECR pull and CloudWatch log permissions
resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
  role       = aws_iam_role.ecs_task_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

What does this code do?

The data "aws_iam_policy_document" block creates a trust policy that says "only the ECS tasks service can assume this role." This prevents other AWS services from using the role.

The AmazonECSTaskExecutionRolePolicy is a managed policy from AWS that bundles together the exact permissions ECS needs to pull images from ECR and write logs to CloudWatch.

  • Save iam.tf.

You won't see a change yet. The IAM role gets created when you run terraform apply later in this step.

Define the ECS cluster and task definition

An ECS cluster is a logical grouping for your tasks and services. The task definition tells ECS exactly how to run your container: which image to use, how much CPU and memory to allocate, and where to send logs.

  • Create a new file called ecs.tf in your ecs-fargate-demo folder.
  • Add the cluster and log group resources:
# Create the ECS cluster that groups your tasks and services
resource "aws_ecs_cluster" "main" {
  name = "${var.project_name}-cluster"

  tags = {
    Name = "${var.project_name}-cluster"
  }
}

# Create a log group so container output is captured in CloudWatch
resource "aws_cloudwatch_log_group" "ecs" {
  name              = "/ecs/${var.project_name}"
  retention_in_days = 7

  tags = {
    Name = "${var.project_name}-logs"
  }
}

What does this code do?

The cluster is like a namespace. All your ECS tasks and services live inside it. The CloudWatch log group captures everything your container prints to stdout/stderr.

Setting retention_in_days = 7 keeps your log storage costs low by automatically deleting logs older than a week.

  • Save ecs.tf.

You won't see a change yet. The next chunk adds the task definition that references this log group.

  • Below the log group resource in ecs.tf, add the task definition:
# Define how to run the container: image, CPU, memory, networking, and logs
resource "aws_ecs_task_definition" "app" {
  family                   = var.project_name
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.ecs_task_execution.arn

  container_definitions = jsonencode([
    {
      name      = var.project_name
      image     = "${aws_ecr_repository.app.repository_url}:latest"
      essential = true

      portMappings = [
        {
          containerPort = var.container_port
          protocol      = "tcp"
        }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.ecs.name
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
        }
      }
    }
  ])

  tags = {
    Name = "${var.project_name}-task"
  }
}

What does this code do?

  • network_mode = "awsvpc" gives the task its own network interface. This is required for Fargate.
  • cpu = "256" and memory = "512" allocate 0.25 vCPU and 0.5 GB of RAM. This is the smallest (and cheapest) Fargate size.
  • The image field points to the Docker image you pushed to ECR in the previous step.
  • logConfiguration routes all container output to the CloudWatch log group you just defined.
  • Save ecs.tf.

You won't see a change yet. The task definition describes your container, but the ECS service in the next substep actually launches it.

Seeing a syntax error in your editor?

The jsonencode() block uses JSON-style keys like portMappings (camelCase). These are AWS API field names, not Terraform argument names. Make sure you copied them exactly as shown.

Help me fix a syntax error in my ECS task definition

Create the ECS service

The task definition describes your container. The ECS service is what actually launches it, keeps it running, and connects it to the load balancer.

  • Below the task definition resource in ecs.tf, add the service resource:
# Launch the container and connect it to the ALB
resource "aws_ecs_service" "app" {
  name            = "${var.project_name}-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = [aws_subnet.public_1.id, aws_subnet.public_2.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   = var.project_name
    container_port   = var.container_port
  }

  depends_on = [aws_lb_listener.http, aws_iam_role_policy_attachment.ecs_task_execution]

  tags = {
    Name = "${var.project_name}-service"
  }
}

What does this code do?

  • desired_count = 1 tells ECS to keep exactly one running copy of your container.
  • assign_public_ip = true gives your task a public IP so it can pull images from ECR without a NAT Gateway.
  • The load_balancer block registers the container with the ALB target group so the load balancer can route traffic to it.
  • depends_on ensures Terraform creates the listener and IAM policy before attempting to start the service. Without this, the deployment can fail due to a race condition.
  • Save ecs.tf.

Why public subnets instead of private?

In production, Fargate tasks typically run in private subnets with a NAT Gateway for outbound internet access. NAT Gateways cost around $0.045/hour. For a learning project, placing tasks in public subnets with assign_public_ip = true avoids that cost entirely.

✔️ Awesome, I've got everything!

Make sure you have saved both iam.tf and ecs.tf before continuing.

ⓧ I'd like to double check the full code

Here is the complete iam.tf file:

data "aws_iam_policy_document" "ecs_task_execution_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ecs_task_execution" {
  name               = "${var.project_name}-ecs-task-execution-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_execution_assume_role.json

  tags = {
    Name = "${var.project_name}-ecs-task-execution-role"
  }
}

resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
  role       = aws_iam_role.ecs_task_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

Here is the complete ecs.tf file:

resource "aws_ecs_cluster" "main" {
  name = "${var.project_name}-cluster"

  tags = {
    Name = "${var.project_name}-cluster"
  }
}

resource "aws_cloudwatch_log_group" "ecs" {
  name              = "/ecs/${var.project_name}"
  retention_in_days = 7

  tags = {
    Name = "${var.project_name}-logs"
  }
}

resource "aws_ecs_task_definition" "app" {
  family                   = var.project_name
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.ecs_task_execution.arn

  container_definitions = jsonencode([
    {
      name      = var.project_name
      image     = "${aws_ecr_repository.app.repository_url}:latest"
      essential = true

      portMappings = [
        {
          containerPort = var.container_port
          protocol      = "tcp"
        }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.ecs.name
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
        }
      }
    }
  ])

  tags = {
    Name = "${var.project_name}-task"
  }
}

resource "aws_ecs_service" "app" {
  name            = "${var.project_name}-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = [aws_subnet.public_1.id, aws_subnet.public_2.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   = var.project_name
    container_port   = var.container_port
  }

  depends_on = [aws_lb_listener.http, aws_iam_role_policy_attachment.ecs_task_execution]

  tags = {
    Name = "${var.project_name}-service"
  }
}
Run terraform apply to deploy your service

All your Terraform code is ready. Time to provision the ECS cluster, IAM role, task definition, and service in one command.

  • In your terminal, make sure you are inside the ecs-fargate-demo directory.
  • Run the plan command to preview what Terraform will create:
terraform plan

You should see Terraform listing several new resources to add (the ECS cluster, log group, task definition, IAM role, and service). No resources should be destroyed.

  • Apply the changes by running:
terraform apply -auto-approve

What should I see?

Terraform will take 2-3 minutes to finish. The ECS service needs time to pull your Docker image, start the container, and pass the ALB health check. This is normal.

When it completes, you will see Apply complete! followed by your outputs, including the alb_dns_name value.

  • Wait for the apply to complete.
  • Confirm you see Apply complete! in the terminal output.
  • Note the alb_dns_name value printed in the outputs section. You will use this URL in the next step to access your running app.

Apply failed or timed out?

  • If you see a "role cannot be assumed" error, check that your iam.tf has ecs-tasks.amazonaws.com in the identifiers list. A typo there prevents ECS from assuming the role.
  • If the service fails to stabilize, the most common cause is the container failing the health check. Make sure your ECR image was pushed successfully in the previous step.
  • If you see "Error creating ECS service: InvalidParameterException", verify that depends_on includes both aws_lb_listener.http and aws_iam_role_policy_attachment.ecs_task_execution.

Help me troubleshoot my ECS service deployment failure

Your container is now running on AWS. Next up, you will open the ALB URL in your browser and verify your custom web page loads successfully.

Test and Verify Your Deployment

Your ECS service is running and your Fargate task has reached a steady state. Now it is time to prove that the full pipeline works end-to-end.

In this step, you will retrieve the public URL of your Application Load Balancer, load your custom HTML page in a browser, and inspect the ECS console to confirm everything is healthy.

In this step, get ready to:
  • Retrieve the ALB DNS name using Terraform outputs.
  • Verify your custom HTML page loads in the browser.
  • Inspect the ECS console to confirm your task is running healthy.
Retrieve the ALB DNS name

Terraform stores outputs from your infrastructure so you can easily retrieve values like URLs and resource names without hunting through the AWS console.

  • Get the ALB DNS name by running this command in your terminal:
terraform output alb_dns_name

You should see a DNS name printed that looks something like ecs-fargate-demo-alb-123456789.us-east-1.elb.amazonaws.com.

What is this DNS name?

This is the public URL that AWS assigns to your Application Load Balancer. Anyone on the internet can use this URL to reach your containerized app.

The ALB receives the request on port 80, forwards it to your ECS Fargate task, and returns the response to the browser.

Seeing an empty output?

Make sure you are running the command from inside your project directory where the outputs.tf file lives. If you moved to a different directory, cd back into it and try again.

Help me troubleshoot why terraform output is empty.

Access your app in the browser
  • Copy the DNS name from your terminal output.
  • Open a browser and paste the DNS name into the address bar.
  • Make sure the URL starts with http:// (not https://), since the ALB listener only accepts HTTP on port 80.

You should see a purple gradient page with the heading Hello from ECS Fargate! and two lines of text below it.

What just happened?

Your browser sent an HTTP request to the ALB. The ALB forwarded that request to the Fargate task's container. Nginx inside the container served your custom index.html page back through the ALB to your browser.

Every piece of this chain was created by your Terraform code. No console clicks required.

Page not loading?

The ECS service can take 2-3 minutes to register healthy targets with the ALB. Wait a moment and refresh the page.

If you see a 503 Service Temporarily Unavailable error, the health checks have not passed yet. The target group health check runs every 30 seconds and requires 2 healthy responses before routing traffic.

Help me troubleshoot why my ECS Fargate app is not loading through the ALB.

Inspect the ECS console

The browser test confirms your app is reachable. Now verify the health status from the AWS side to confirm your task is stable.

  • Open the AWS Management Console in your browser.
  • Search for ECS in the top search bar and select Elastic Container Service.
  • Click on the cluster named ecs-fargate-demo-cluster.
  • Click on the service named ecs-fargate-demo-service.
  • Select the Tasks tab.

You should see one task listed with a status of RUNNING and a health status of HEALTHY.

  • Click on the task ID to view details.
  • Select the Logs tab to see the container logs.

You should see Nginx access logs showing the ALB health check requests hitting your container every 30 seconds. These logs are stored in CloudWatch under the log group /ecs/ecs-fargate-demo.

What are these health check logs?

The ALB target group sends an HTTP GET request to / on your container every 30 seconds. If it gets a 200 response, the task stays marked as healthy and continues receiving traffic.

If 3 consecutive checks fail, the ALB marks the task as unhealthy and stops sending it requests. This is why you see regular log entries even when nobody is visiting your site.

Task showing STOPPED or UNHEALTHY?

Check the Logs tab for error messages. A common cause is the Docker image failing to pull from ECR. This happens when the ECR authentication token has expired (tokens last 12 hours).

If you see a pull error, re-authenticate Docker to ECR and push your image again, then force a new deployment from the ECS console.

Help me troubleshoot why my ECS task is unhealthy or stopped.

Your deployment is fully verified. You have a containerized application running on AWS ECS Fargate, accessible to anyone on the internet through an Application Load Balancer. Every piece of infrastructure was provisioned through Terraform. Next up, you can extend this setup with auto-scaling in the Secret Mission.

Secret mission

Add Auto-Scaling to Your ECS Service

Your ECS service currently runs a single task. If traffic spikes, that one container handles everything alone. Add auto-scaling so your service automatically adjusts capacity based on CPU utilization.

Clean Up Your Resources

Clean Up Your Resources

This project created AWS resources that incur hourly charges while running. Even at minimal capacity, leaving everything up will cost roughly $1.50 per day.

Cost warning

Your ECS Fargate task and Application Load Balancer are billed per second they exist. If you do not destroy the infrastructure, you will see charges accumulate on your AWS bill. Running terraform destroy removes every resource and stops all charges immediately.

Resources you used:

  • ECS Fargate cluster with one running task (0.25 vCPU, 0.5GB memory).
  • Application Load Balancer with target group and HTTP listener.
  • ECR repository containing your Docker image.
  • VPC with two public subnets, internet gateway, and route table.
  • Security groups for the ALB and ECS tasks.
  • IAM role for ECS task execution.
  • CloudWatch log group for container logs.

Keep everything running

No action needed. Choose this if you are still actively experimenting with your deployment or plan to continue within the next hour.

  • Your app stays live at the ALB DNS URL.
  • Expect charges of approximately $0.03-0.05 per hour while resources are running.
  • Come back and run terraform destroy when you are done.

Pause - I'll come back to this later

ECS Fargate tasks and Application Load Balancers do not have a pause or stop option. The only way to stop charges is to destroy the resources entirely.

  • Run terraform destroy from your project directory to remove all AWS resources.
  • Your Terraform files and Docker configuration remain on your local machine.
  • When you want to redeploy, run terraform apply again to recreate everything from your code.

This is the power of Infrastructure as Code

Because your entire infrastructure is defined in Terraform files, you can destroy and recreate it any time. Your code is the blueprint.

Delete - I don't want to use this again

Remove all project resources from AWS and delete your local files.

  • Run terraform destroy from your project directory and type yes when prompted.
  • Wait for Terraform to confirm all resources have been destroyed.
  • Delete your local project folder if you no longer need the files.
  • Verify in the AWS Console that your ECS cluster, ALB, and ECR repository no longer appear.

That's a Wrap!

That's a Wrap!

Nice work! You just deployed a containerized application to AWS ECS Fargate using nothing but Terraform. No console clicking. Pure Infrastructure as Code.

You've learned how to:

  • Built a custom Docker image and pushed it to Amazon ECR.
  • Wrote Terraform code that provisions a complete VPC, Application Load Balancer, and ECS infrastructure from scratch.
  • Deployed a running Fargate task accessible via a public load balancer URL.
  • Secret Mission: Added auto-scaling policies to dynamically adjust your service capacity based on CPU utilization.

Ready to quiz yourself?