Deploy Containers to ECS Fargate with Terraform

Build a CI/CD pipeline that deploys a containerized app to AWS ECS Fargate.

Introduction

30 Second Summary

Every time you update an app, someone has to get that code from your laptop to a running server. In most teams, that entire process is automated.

In this project, you will build a complete deployment pipeline that takes a containerized Node.js API from GitHub to a running container on AWS, provisioned entirely by Terraform. You will write the infrastructure from scratch, automate deployments, and monitor the result.

What You'll Build

A fully automated CI/CD pipeline that builds a Docker container, provisions AWS infrastructure with Terraform, and deploys to ECS Fargate inside a custom VPC you design from scratch.

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

  • A Node.js health-check API containerized with Docker and deployed to AWS ECS Fargate.
  • Terraform configuration that provisions a custom VPC with public subnets, an ECR repository, ECS cluster, IAM roles, and CloudWatch logging from scratch.
  • A GitHub Actions CI/CD pipeline that automatically builds, pushes, and deploys your container on every push to main.
  • Secret Mission: Add auto-scaling that scales your service from 1 to 3 tasks based on CPU utilization.

Are there any prerequisites?

No prior experience with AWS, Terraform, or Docker is required. This guide walks you through installing and configuring every tool from scratch. The project costs approximately $1-2, covered by the $200 in credits new AWS accounts receive.

Create the App, Dockerfile, and GitHub Repository

Your tools are installed and your AWS account is ready. Now you need something to deploy.

In this step, you will create a simple Node.js API, package it into a Docker container, and push the code to a GitHub repository. This gives you everything the pipeline needs to work with later.

In this step, get ready to:
  • Create a GitHub repository and clone it locally.
  • Build a Node.js health-check API with Express.
  • Containerize the app with Docker and test it locally.
Create the GitHub repository and clone it

Your code needs a home on GitHub so that GitHub Actions can detect pushes and trigger deployments automatically.

  • Return to GitHub in your browser.
  • Click the + icon in the upper-right corner of the page.
  • Select New repository.
  • Enter ecs-fargate-app as the repository name.
  • Leave the repository set to Public.
  • Do not check any initialization options (no README, no .gitignore, no license).
  • Click Create repository.

Why skip the initialization options?

You will be pushing local files into this repository. Starting with an empty repo avoids merge conflicts when you push for the first time.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your new repository by running this command:
git clone [[YOUR_REPO_URL="https://github.com/your-username/ecs-fargate-app.git"]]

Where do I find the clone URL?

After creating the repository, GitHub shows a Quick Setup page. Copy the HTTPS URL displayed there. It looks like https://github.com/your-username/ecs-fargate-app.git.

  • Move into the cloned folder by running:
cd ecs-fargate-app
  • Confirm you are inside the folder by running:
ls -la

You should see a hidden .git folder in the output. This confirms the repository was cloned successfully.

Don't see a .git folder?

Make sure you ran cd ecs-fargate-app after cloning. If the clone failed, double-check that your repo URL matches the one GitHub displayed on the Quick Setup page.

Help me troubleshoot my git clone command.

Create the Node.js application

The app itself is intentionally simple. It exposes two endpoints that return JSON. The real value of this project is the infrastructure and pipeline wrapping it.

  • Open the ecs-fargate-app folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the ecs-fargate-app folder on your Desktop, then click Open.
  • Create a new file called package.json by hovering over the folder name in the Explorer sidebar and clicking the New File icon.
  • Paste the following code into package.json:
{
  "name": "ecs-fargate-app",
  "version": "1.0.0",
  "description": "ECS Fargate demo app",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "^4.21.0"
  }
}

What does this file do?

The package.json file defines your Node.js project. It declares Express as a dependency (the web framework that handles HTTP requests) and sets a start script so the container knows how to launch the app.

  • Save package.json.
  • Install the project dependencies by opening a terminal in VS Code (Ctrl+` or Terminal > New Terminal) and running:
npm install

You should see output confirming packages were added, and a node_modules folder appears in the Explorer sidebar.

Seeing 'npm: command not found'?

You need Node.js installed locally to run npm. Download and install it from https://nodejs.org (the LTS version). After installing, close and reopen your terminal, then try npm install again.

Help me install Node.js so I can run npm.

  • Create another new file called index.js in the same folder.
  • Paste the following code into index.js:
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;

// Health check endpoint - returns server status
app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  });
});

// Info endpoint - returns app metadata
app.get('/info', (req, res) => {
  res.json({
    app: 'ecs-fargate-app',
    version: '1.0.0',
    environment: process.env.NODE_ENV || 'development'
  });
});

// Start the server on all network interfaces
app.listen(port, '0.0.0.0', () => {
  console.log(`Server running on port ${port}`);
});

What does this code do?

This file creates a minimal Express web server with two endpoints:

  • `/health` returns the server's status, current timestamp, and uptime. ECS uses health checks like this to know if a container is alive.
  • `/info` returns the app name, version, and current environment. This is useful for verifying which version is deployed.

The server listens on 0.0.0.0 so it accepts connections from outside the container, not just localhost.

  • Save index.js.

✔️ Awesome, I've got everything!

Great. Make sure both package.json and index.js are saved before moving on.

ⓧ I'd like to double check the full code

Here is package.json:

{
  "name": "ecs-fargate-app",
  "version": "1.0.0",
  "description": "ECS Fargate demo app",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "^4.21.0"
  }
}

Here is index.js:

const express = require('express');
const app = express();
const port = process.env.PORT || 3000;

app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  });
});

app.get('/info', (req, res) => {
  res.json({
    app: 'ecs-fargate-app',
    version: '1.0.0',
    environment: process.env.NODE_ENV || 'development'
  });
});

app.listen(port, '0.0.0.0', () => {
  console.log(`Server running on port ${port}`);
});
Build and test the Docker image locally

Before deploying to AWS, you will package the app into a Docker image and test it on your own machine. This catches issues early.

  • Create a new file called Dockerfile (no file extension) in the ecs-fargate-app folder.
  • Paste the following code into Dockerfile:
# Use a lightweight Node.js base image
FROM node:20-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy dependency files first for better caching
COPY package*.json ./
RUN npm ci --only=production

# Copy application code
COPY index.js .

# Document the port the app listens on
EXPOSE 3000

# Start the application
CMD ["node", "index.js"]

What does this Dockerfile do?

  • `FROM node:20-alpine` starts from a minimal Linux image with Node.js 20 pre-installed. Alpine is tiny (about 50 MB) so your image builds fast.
  • `COPY package*.json` and `RUN npm ci` install dependencies in a separate layer. Docker caches this layer so rebuilds only re-install if dependencies change.
  • `COPY index.js .` adds your application code after dependencies are installed.
  • `CMD ["node", "index.js"]` tells Docker what command to run when the container starts.
  • Save Dockerfile.
  • Build the Docker image by running this command in the VS Code terminal:
docker build -t ecs-fargate-app .

You should see Docker download the node:20-alpine base image, install dependencies, and finish with a success message. This takes about 30 seconds the first time.

Seeing 'Cannot connect to the Docker daemon'?

Make sure Docker Desktop is running. Look for the Docker whale icon in your menu bar. If it is not there, open Docker Desktop and wait for it to say "Docker Desktop is running" before trying the build again.

Help me fix my Docker build command.

  • Run the container by executing:
docker run -p 3000:3000 ecs-fargate-app

You should see Server running on port 3000 in the terminal output. The container is now running and accepting requests.

  • Open your browser and navigate to http://localhost:3000/health.

You should see a JSON response containing status, timestamp, and uptime fields. This confirms your containerized app is working correctly.

  • Return to your terminal and press Ctrl+C to stop the container.

Not seeing any response in the browser?

Check that the terminal shows Server running on port 3000 and not an error. If you see a crash, verify that your index.js matches the code above exactly.

If the terminal shows the server is running but the browser shows nothing, make sure you are visiting http://localhost:3000/health (not https).

Help me debug my Docker container.

✔️ Awesome, I've got everything!

Great. Make sure you stopped the container with Ctrl+C before continuing.

ⓧ I'd like to double check the full code

Here is your Dockerfile:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY index.js .

EXPOSE 3000

CMD ["node", "index.js"]

Your app is containerized and tested locally. Next, you will write Terraform configuration to build the VPC and networking layer that will host this container on AWS.

Write Terraform for VPC and Networking

Your app is containerized and ready to deploy. Now you need somewhere to deploy it.

Every container running on AWS needs a network to live in. In this step, you'll write Terraform code that defines a custom VPC (Virtual Private Cloud) with subnets, routing, and firewall rules. This is the networking foundation your ECS Fargate container will run inside.

In this step, get ready to:
  • Create main.tf with the AWS provider and a custom VPC.
  • Add an internet gateway and route table so traffic can reach the internet.
  • Add a security group to control which traffic reaches your containers.
Create main.tf with the provider and VPC

Terraform needs to know which cloud provider you're targeting and what region to deploy into. You'll also create the VPC, which is an isolated virtual network where all your resources will live.

  • In VS Code, create a new file called main.tf inside your ecs-fargate-app folder.
  • Add the Terraform configuration block, AWS provider, availability zone data source, and VPC resource by pasting this code:
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

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

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

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

What does this code do?

The terraform block pins the AWS provider to version ~> 6.0 so your infrastructure is reproducible.

The provider block tells Terraform to use the AWS region from a variable (you'll define it in the next step). The data source fetches the list of available availability zones in your region so you can spread subnets across them.

The VPC uses a 10.0.0.0/16 CIDR block, giving you 65,536 IP addresses. DNS support and hostnames are enabled so resources inside the VPC can resolve each other by name.

Now add two public subnets inside the VPC. Each subnet goes in a different availability zone for high availability.

  • Add these two subnet resources below your VPC resource in main.tf:
resource "aws_subnet" "public_a" {
  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.app_name}-public-a"
  }
}

resource "aws_subnet" "public_b" {
  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.app_name}-public-b"
  }
}

Why two subnets in different availability zones?

Each subnet lives in a single physical data center (availability zone). If one zone goes down, your service can still run in the other. ECS Fargate requires at least two subnets in different zones for this reason.

The map_public_ip_on_launch = true setting gives each Fargate task a public IP address so it can communicate with the internet directly.

  • Save main.tf.
  • Confirm the file exists by running:
ls main.tf

You should see main.tf in the output.

Don't see main.tf?

Make sure your terminal is in the ecs-fargate-app directory. Run pwd to check your current location.

Also confirm you saved the file in VS Code (Cmd+S on macOS or Ctrl+S on Windows).

help me find where my main.tf file was saved

Add the internet gateway and route table

Subnets alone cannot reach the internet. You need an internet gateway (the door to the public internet) and a route table (the map that tells traffic where to go).

  • Add the internet gateway, route table, and route table associations below your subnets in main.tf:
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.app_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.app_name}-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 attaches to your VPC and allows traffic to flow between your subnets and the public internet.

The route table contains a rule: any traffic destined for 0.0.0.0/0 (anywhere on the internet) gets sent through the internet gateway. The two association resources link each subnet to this route table so both subnets use the same routing rules.

  • Save main.tf.

You won't see a visible change yet. The next chunk adds the security group to complete your networking layer.

Create the security group

A security group acts as a virtual firewall for your containers. It controls which traffic is allowed in and out.

  • Add the security group resource at the bottom of main.tf:
resource "aws_security_group" "ecs" {
  name        = "${var.app_name}-ecs-sg"
  description = "Allow inbound traffic to ECS tasks"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = var.container_port
    to_port     = var.container_port
    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.app_name}-ecs-sg"
  }
}

What does this code do?

The ingress block allows inbound TCP traffic on your container's port (3000) from any IP address. This lets users reach your API.

The egress block allows all outbound traffic. Your containers need this to pull images from ECR and send logs to CloudWatch. The -1 protocol means "all protocols."

  • Save main.tf.

Seeing red squiggly lines in VS Code?

If VS Code shows warnings about unknown variables like var.aws_region or var.app_name, that's expected. You'll define these variables in variables.tf in the next step.

If you see syntax errors (mismatched braces or missing quotes), check that each resource block has matching opening and closing braces.

help me fix syntax errors in my main.tf Terraform file

✔️ Awesome, I've got everything!

Great. Double check you've saved main.tf and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete main.tf file at this point:

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

provider "aws" {
  region = var.aws_region
}

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

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

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

resource "aws_subnet" "public_a" {
  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.app_name}-public-a"
  }
}

resource "aws_subnet" "public_b" {
  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.app_name}-public-b"
  }
}

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

  tags = {
    Name = "${var.app_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.app_name}-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
}

resource "aws_security_group" "ecs" {
  name        = "${var.app_name}-ecs-sg"
  description = "Allow inbound traffic to ECS tasks"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = var.container_port
    to_port     = var.container_port
    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.app_name}-ecs-sg"
  }
}

Your networking foundation is ready. Next up, you'll add the ECS cluster, container registry, and IAM roles that let AWS run and manage your containers.

Write Terraform for ECS, IAM, and ECR

Your custom VPC is defined with subnets, an internet gateway, and a route table. Now you need the AWS services that will actually run your container.

In this step, you'll define the ECR repository (where Docker images are stored), the ECS cluster (where containers run), CloudWatch logging, and the IAM roles that grant your containers the right permissions.

In this step, get ready to:
  • Add ECR, ECS cluster, and CloudWatch log group resources to your Terraform configuration.
  • Create IAM roles for ECS task execution and task-level permissions.
  • Create variables.tf and outputs.tf to organize your Terraform project.
Add the ECR repository, ECS cluster, and CloudWatch log group

These three resources form the core service infrastructure. ECR stores your Docker images. The ECS cluster groups your running containers. CloudWatch captures their logs.

  • In VS Code, open main.tf and scroll to the bottom of the file (after the security group resource).
  • Add the following three resources at the end of the file:
resource "aws_ecr_repository" "app" {
  name                 = var.app_name
  image_tag_mutability = "MUTABLE"
  force_delete         = true

  image_scanning_configuration {
    scan_on_push = true
  }
}

resource "aws_ecs_cluster" "main" {
  name = var.app_name

  setting {
    name  = "containerInsights"
    value = "disabled"
  }
}

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

What does this code do?

  • aws_ecr_repository creates a private Docker image registry in AWS. force_delete = true means Terraform can delete the repo even if it contains images (useful for cleanup later).
  • aws_ecs_cluster creates the logical grouping for your Fargate tasks. Container Insights is disabled to avoid extra cost.
  • aws_cloudwatch_log_group is where your container's stdout and stderr output will appear. The 7-day retention keeps logs tidy.
  • Save main.tf.

You won't see a visible change yet. In the next substep, you'll add the IAM roles that authorize these services to work together.

Seeing red squiggles in VS Code?

If VS Code shows errors on var.app_name or var.container_port, that is expected. You will create the variables.tf file that defines these variables in substep 3.

help me fix variable reference errors in my Terraform file

Create IAM roles for ECS tasks

ECS tasks need two separate IAM roles. The task execution role lets AWS pull your image from ECR and write logs to CloudWatch. The task role grants permissions to the application code running inside the container.

  • In main.tf, add the following IAM resources below the CloudWatch log group you just added:
resource "aws_iam_role" "ecs_task_execution" {
  name = "${var.app_name}-task-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

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"
}

resource "aws_iam_role" "ecs_task" {
  name = "${var.app_name}-task-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

What does this code do?

The assume_role_policy on both roles says "only the ECS service can assume this role." This prevents other AWS services or users from using these permissions.

  • ecs_task_execution is what AWS itself uses behind the scenes to pull your container image and send logs. The attached AmazonECSTaskExecutionRolePolicy is a managed policy that grants exactly those permissions.
  • ecs_task is what YOUR code would use if it needed to call other AWS services (like reading from S3 or sending messages to SQS). For now it has no extra policies, but the role is ready for future use.

Why two roles instead of one?

Separation of concerns. The execution role is for AWS infrastructure operations (pull image, write logs). The task role is for your application logic. If your container gets compromised, the attacker only gets the task role's permissions, not the infrastructure-level ones.

  • Save main.tf.

You won't see a change yet. The next substep creates the variable definitions that main.tf references throughout.

Seeing a typo in the policy ARN?

The policy_arn value must be exactly arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy. Double-check for missing slashes or capitalization differences.

help me fix my IAM policy ARN in Terraform

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf with all the new resources.

ⓧ I'd like to double check the full code

Here is the complete main.tf file as it should look at this point:

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

provider "aws" {
  region = var.aws_region
}

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

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

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

resource "aws_subnet" "public_a" {
  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.app_name}-public-a"
  }
}

resource "aws_subnet" "public_b" {
  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.app_name}-public-b"
  }
}

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

  tags = {
    Name = "${var.app_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.app_name}-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
}

resource "aws_security_group" "ecs" {
  name        = "${var.app_name}-ecs-sg"
  description = "Allow inbound traffic to ECS tasks"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = var.container_port
    to_port     = var.container_port
    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.app_name}-ecs-sg"
  }
}

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

  image_scanning_configuration {
    scan_on_push = true
  }
}

resource "aws_ecs_cluster" "main" {
  name = var.app_name

  setting {
    name  = "containerInsights"
    value = "disabled"
  }
}

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

resource "aws_iam_role" "ecs_task_execution" {
  name = "${var.app_name}-task-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

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"
}

resource "aws_iam_role" "ecs_task" {
  name = "${var.app_name}-task-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}
Create variables and outputs files

Terraform best practice is to separate variable definitions and outputs into their own files. This makes your configuration easier to read and reuse.

  • Create a new file called variables.tf in the same ecs-fargate-app folder as main.tf.
  • Add the following variable definitions:
variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "app_name" {
  description = "Application name used for resource naming"
  type        = string
  default     = "ecs-fargate-app"
}

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

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

What does this code do?

Each variable block defines a named input with a type, description, and default value. Every var. reference in main.tf resolves to its matching variable here.

  • aws_region controls which AWS data center your infrastructure lives in.
  • app_name is reused across every resource name tag so they all share a consistent prefix.
  • container_port matches the port your Express app listens on (3000).
  • vpc_cidr defines the IP address range for your entire VPC.
  • Save variables.tf.
  • Create another new file called outputs.tf in the same folder.
  • Add the following output definitions:
output "ecr_repository_url" {
  description = "ECR repository URL"
  value       = aws_ecr_repository.app.repository_url
}

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

output "ecs_service_name" {
  description = "ECS service name"
  value       = aws_ecs_service.app.name
}

output "task_definition_family" {
  description = "Task definition family"
  value       = aws_ecs_task_definition.app.family
}

output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "Public subnet IDs"
  value       = [aws_subnet.public_a.id, aws_subnet.public_b.id]
}

What does this code do?

Outputs print key values to the terminal after terraform apply finishes. You will use ecr_repository_url in the next step to push your Docker image to the right registry.

Some outputs (like ecs_service_name and task_definition_family) reference resources you have not created yet. Terraform will not error on these until you run terraform apply because outputs are evaluated at apply time.

  • Save outputs.tf.
  • Confirm both new files exist by running this in your terminal:
ls *.tf

You should see main.tf, variables.tf, and outputs.tf listed in the output.

Only seeing main.tf?

Make sure you created the new files in the same directory as main.tf (your ecs-fargate-app folder). If you accidentally created them in a subfolder, move them up with mv or drag them in the VS Code file explorer.

help me find where my Terraform files ended up

✔️ Awesome, I've got everything!

Great. Make sure all three .tf files are saved.

ⓧ I'd like to double check the full code

Here is variables.tf in full:

variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "app_name" {
  description = "Application name used for resource naming"
  type        = string
  default     = "ecs-fargate-app"
}

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

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

And here is outputs.tf in full:

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

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

output "ecs_service_name" {
  description = "ECS service name"
  value       = aws_ecs_service.app.name
}

output "task_definition_family" {
  description = "Task definition family"
  value       = aws_ecs_task_definition.app.family
}

output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "Public subnet IDs"
  value       = [aws_subnet.public_a.id, aws_subnet.public_b.id]
}

Your Terraform configuration now defines everything the ECS service needs to run. Next up, you will deploy this infrastructure to AWS and push your Docker image to the new ECR repository.

Deploy Infrastructure and Push Initial Image

Your Terraform configuration is written and your Docker image is built locally. Now it is time to bring everything to life in AWS.

There is a chicken-and-egg problem with ECS: the service needs a container image in ECR before it can start, but the ECR repository does not exist until Terraform creates it. You will solve this in two passes. First, deploy the base infrastructure (VPC, ECR, cluster). Second, push your image to the new ECR repo, then add the task definition and service.

In this step, get ready to:
  • Deploy base infrastructure with Terraform (VPC, ECR, ECS cluster, IAM roles).
  • Push your Docker image to the new ECR repository.
  • Add the ECS task definition and service, then verify your container is running.
Initialize Terraform and deploy base infrastructure

Terraform needs to download the AWS provider plugin before it can create any resources. After initialization, you will apply your configuration to provision the networking, ECR repository, ECS cluster, and IAM roles.

Your outputs.tf references two resources (aws_ecs_service.app and aws_ecs_task_definition.app) that do not exist in main.tf yet. You need to temporarily comment those outputs out so the first apply succeeds.

  • Open outputs.tf in VS Code.
  • Comment out the ecs_service_name and task_definition_family output blocks by adding # at the start of each line in those blocks.
  • Save outputs.tf.

Why comment out these outputs?

Terraform validates all files before applying. If an output references a resource that is not defined yet, Terraform will throw an error. You will uncomment these outputs after adding the task definition and service in substep 3.

  • Initialize the Terraform working directory by running this command in your terminal (make sure you are inside the ecs-fargate-app folder):
terraform init
  • Deploy the base infrastructure by running:
terraform apply
  • Terraform will show a plan listing the resources it will create. Type yes when prompted to confirm.

What does this create?

This first apply creates your custom VPC, two public subnets, internet gateway, route table, security group, ECR repository, ECS cluster, CloudWatch log group, and IAM roles. It does not create a running container yet.

When the apply finishes, Terraform prints your outputs. Note the ecr_repository_url value. You will need it in the next substep.

  • Confirm the apply completed successfully. You should see a message like Apply complete! Resources: 12 added, 0 changed, 0 destroyed. along with your output values.
  • Copy the ecr_repository_url output value and save it somewhere accessible. It looks like 123456789012.dkr.ecr.us-east-1.amazonaws.com/ecs-fargate-app.

Seeing a provider error?

If terraform init fails to download the AWS provider, check your internet connection. If terraform apply fails with a reference error mentioning aws_ecs_service or aws_ecs_task_definition, make sure you commented out those two output blocks in outputs.tf.

If you see a credentials error, run aws sts get-caller-identity to verify your AWS CLI is configured correctly.

help me troubleshoot my Terraform apply failure

Authenticate Docker to ECR and push your image

Your ECR repository is live but empty. You need to authenticate Docker to your private registry, then tag and push the local image so ECS can pull it when you launch the service.

  • Authenticate Docker to your ECR registry by running this command (replace the URL at the end with your ecr_repository_url output, keeping only the registry domain up to .amazonaws.com):
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[ECR_REGISTRY="123456789012.dkr.ecr.us-east-1.amazonaws.com"]]

What does this command do?

This fetches a temporary authentication token from AWS and pipes it directly to docker login. The token lasts 12 hours. The --password-stdin flag avoids showing the token in your terminal history.

  • You should see Login Succeeded in your terminal.
  • Tag your local image for ECR by running:
docker tag ecs-fargate-app:latest [[ECR_URL="123456789012.dkr.ecr.us-east-1.amazonaws.com/ecs-fargate-app"]]:latest
  • Push the tagged image to ECR by running:
docker push [[ECR_URL="123456789012.dkr.ecr.us-east-1.amazonaws.com/ecs-fargate-app"]]:latest
  • Wait for the push to complete. You should see layer upload progress followed by a latest: digest: sha256:... confirmation.

Push failed or access denied?

If you see a denied or no basic auth credentials error, your Docker login may have expired or targeted the wrong registry. Re-run the aws ecr get-login-password command above. Make sure the registry URL matches your Terraform output exactly (including the account ID).

help me fix my Docker push to ECR

Add the ECS task definition and service, then deploy

The image is in ECR. Now you can tell Terraform how to run it by adding the task definition (what to run) and the service (how many to run and where).

  • Open main.tf in VS Code.
  • Add the following ECS task definition at the end of the file, below the aws_iam_role block for ecs_task:
# ─────────────────────────────────────────────
# ECS Task Definition
# ─────────────────────────────────────────────

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

  container_definitions = jsonencode([
    {
      name      = var.app_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.app.name
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
        }
      }
      environment = [
        {
          name  = "NODE_ENV"
          value = "production"
        },
        {
          name  = "PORT"
          value = tostring(var.container_port)
        }
      ]
    }
  ])
}

What does this task definition do?

The task definition tells Fargate exactly how to run your container. Key settings:

  • network_mode = "awsvpc" gives each task its own network interface with a private (and optionally public) IP.
  • cpu = "256" and memory = "512" allocate 0.25 vCPU and 0.5 GB RAM. This is the smallest Fargate configuration.
  • The logConfiguration sends container stdout to the CloudWatch log group you created earlier.
  • Add the ECS service below the task definition:
# ─────────────────────────────────────────────
# ECS Service
# ─────────────────────────────────────────────

resource "aws_ecs_service" "app" {
  name            = var.app_name
  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_a.id, aws_subnet.public_b.id]
    security_groups  = [aws_security_group.ecs.id]
    assign_public_ip = true
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }
}

What does the ECS service do?

The service maintains one running copy of your task (desired_count = 1). It places tasks in your public subnets and assigns them a public IP so you can reach the container from the internet.

The deployment_circuit_breaker automatically rolls back a deployment if the new tasks keep failing to start. This prevents broken deploys from taking down your service.

  • Save main.tf.
  • Now uncomment the two output blocks you commented out earlier in outputs.tf (ecs_service_name and task_definition_family). Remove the # characters and save the file.
  • Deploy the task definition and service by running:
terraform apply
  • Type yes when prompted. Terraform will create the task definition and ECS service.
  • Wait about 30-60 seconds for the Fargate task to start. Then get the running task ARN by running:
aws ecs list-tasks --cluster ecs-fargate-app --desired-status RUNNING
  • Copy the task ARN from the output. Then get the network interface ID by running (replace the task ARN placeholder):
aws ecs describe-tasks --cluster ecs-fargate-app --tasks [[TASK_ARN="your task ARN from the previous command"]] --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' --output text
  • Use the ENI ID to look up your task's public IP by running:
aws ec2 describe-network-interfaces --network-interface-ids [[ENI_ID="eni-xxxxxxxxxxxxxxxxx from previous output"]] --query 'NetworkInterfaces[0].Association.PublicIp' --output text
  • You should see a public IP address printed in your terminal. Verify your container is responding by running:
curl http://[[TASK_IP="your task public IP from previous output"]]:3000/health
  • You should see a JSON response containing "status":"healthy" along with a timestamp and uptime value.

Why three commands to find the IP?

Fargate tasks get their own Elastic Network Interface (ENI). The describe-tasks command gives you the ENI ID but not the public IP directly. You then query describe-network-interfaces to get the public IP from that ENI. In production, you would put a load balancer in front of your service to avoid this lookup entirely.

Container not running or health check failing?

If list-tasks returns an empty array, wait another minute. Fargate needs time to pull your image and start the container. Run the command again.

If curl times out, check that your security group allows inbound TCP on port 3000. Also verify that the task's subnet has a route to the internet gateway. You can check ECS events for errors by running:

aws ecs describe-services --cluster ecs-fargate-app --services ecs-fargate-app --query 'services[0].events[:3]'

help me troubleshoot my ECS Fargate task that is not starting

✔️ Awesome, I've got everything!

Great. Double check that you have saved both main.tf and outputs.tf.

ⓧ I'd like to double check the full code

Here is the complete main.tf after this step:

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

provider "aws" {
  region = var.aws_region
}

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

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

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

resource "aws_subnet" "public_a" {
  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.app_name}-public-a"
  }
}

resource "aws_subnet" "public_b" {
  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.app_name}-public-b"
  }
}

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

  tags = {
    Name = "${var.app_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.app_name}-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
}

resource "aws_security_group" "ecs" {
  name        = "${var.app_name}-ecs-sg"
  description = "Allow inbound traffic to ECS tasks"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = var.container_port
    to_port     = var.container_port
    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.app_name}-ecs-sg"
  }
}

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

  image_scanning_configuration {
    scan_on_push = true
  }
}

resource "aws_ecs_cluster" "main" {
  name = var.app_name

  setting {
    name  = "containerInsights"
    value = "disabled"
  }
}

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

resource "aws_iam_role" "ecs_task_execution" {
  name = "${var.app_name}-task-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

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"
}

resource "aws_iam_role" "ecs_task" {
  name = "${var.app_name}-task-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

# ─────────────────────────────────────────────
# ECS Task Definition
# ─────────────────────────────────────────────

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

  container_definitions = jsonencode([
    {
      name      = var.app_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.app.name
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
        }
      }
      environment = [
        {
          name  = "NODE_ENV"
          value = "production"
        },
        {
          name  = "PORT"
          value = tostring(var.container_port)
        }
      ]
    }
  ])
}

# ─────────────────────────────────────────────
# ECS Service
# ─────────────────────────────────────────────

resource "aws_ecs_service" "app" {
  name            = var.app_name
  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_a.id, aws_subnet.public_b.id]
    security_groups  = [aws_security_group.ecs.id]
    assign_public_ip = true
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }
}

And the complete outputs.tf (with both outputs uncommented):

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

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

output "ecs_service_name" {
  description = "ECS service name"
  value       = aws_ecs_service.app.name
}

output "task_definition_family" {
  description = "Task definition family"
  value       = aws_ecs_task_definition.app.family
}

output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "Public subnet IDs"
  value       = [aws_subnet.public_a.id, aws_subnet.public_b.id]
}

Your container is running on AWS. Next up, you will automate deployments so every push to GitHub triggers a build and deploy automatically.

Set Up GitHub Actions CI/CD Pipeline

Your infrastructure is live and your container is running on ECS Fargate. But right now, every code change requires a manual image build, push, and redeploy.

In this step, you'll create a CI/CD pipeline with GitHub Actions that automatically builds your Docker image, pushes it to ECR, and deploys the new version to ECS every time you push to the main branch. Push code, watch it deploy.

In this step, get ready to:
  • Create a GitHub Actions workflow file and task definition.
  • Add AWS credentials as GitHub repository secrets.
  • Push code and verify the automated pipeline deploys your app.
Create the workflow and task definition files

GitHub Actions uses a YAML file in your repository to define the pipeline. It also needs a task-definition.json file that describes how your ECS task is configured, so the workflow can inject the new image ID before deploying.

  • Create the .github/workflows directory structure by running these commands in your terminal:
mkdir -p .github/workflows
  • Create the workflow file at .github/workflows/deploy.yml in VS Code and paste the following:
name: Deploy to Amazon ECS

on:
  push:
    branches:
      - main

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: ecs-fargate-app
  ECS_SERVICE: ecs-fargate-app
  ECS_CLUSTER: ecs-fargate-app
  ECS_TASK_DEFINITION: task-definition.json
  CONTAINER_NAME: ecs-fargate-app

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Fill in the new image ID in the Amazon ECS task definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ env.ECS_TASK_DEFINITION }}
          container-name: ${{ env.CONTAINER_NAME }}
          image: ${{ steps.build-image.outputs.image }}

      - name: Deploy Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

What does this workflow do?

  • Triggers on every push to the main branch.
  • Checks out your code, configures AWS credentials from repository secrets, and logs into ECR.
  • Builds your Docker image, tags it with the Git commit SHA, and pushes it to your ECR repository.
  • Renders a new task definition with the fresh image ID, then deploys it to ECS and waits for the service to stabilize.

The workflow references a task-definition.json file that acts as a template. The render action injects the new image into this template before deploying.

  • Create a file called task-definition.json in the root of your project (at the same level as index.js) and paste the following:
{
  "family": "ecs-fargate-app",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "executionRoleArn": "arn:aws:iam::[[AWS_ACCOUNT_ID=\"your 12-digit AWS account ID\"]]:role/ecs-fargate-app-task-execution-role",
  "taskRoleArn": "arn:aws:iam::[[AWS_ACCOUNT_ID=\"your 12-digit AWS account ID\"]]:role/ecs-fargate-app-task-role",
  "containerDefinitions": [
    {
      "name": "ecs-fargate-app",
      "image": "PLACEHOLDER",
      "essential": true,
      "portMappings": [
        {
          "containerPort": 3000,
          "protocol": "tcp"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/ecs-fargate-app",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "environment": [
        {
          "name": "NODE_ENV",
          "value": "production"
        },
        {
          "name": "PORT",
          "value": "3000"
        }
      ]
    }
  ]
}

What does this file do?

This JSON mirrors your Terraform-managed task definition but is used by the GitHub Actions render step. The image field says PLACEHOLDER because the workflow replaces it with the freshly built image on every deploy.

You need to replace the AWS_ACCOUNT_ID placeholder with your actual 12-digit AWS account ID. You can find it by running aws sts get-caller-identity in your terminal and copying the Account value.

  • Find your AWS account ID by running:
aws sts get-caller-identity --query Account --output text
  • Copy the 12-digit number from the output and replace both AWS_ACCOUNT_ID placeholders in task-definition.json with it.
  • Save both deploy.yml and task-definition.json.
  • Confirm the files exist by running:
ls .github/workflows/deploy.yml task-definition.json

You should see both file paths listed in the output without any errors.

Not seeing the files?

  • Make sure you created the nested .github/workflows directory with mkdir -p before creating the YAML file inside it.
  • Double check you saved deploy.yml inside .github/workflows/ and not at the project root.

Help me troubleshoot my missing workflow file.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before continuing.

ⓧ I'd like to double check the full code

Here is the full deploy.yml:

name: Deploy to Amazon ECS

on:
  push:
    branches:
      - main

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: ecs-fargate-app
  ECS_SERVICE: ecs-fargate-app
  ECS_CLUSTER: ecs-fargate-app
  ECS_TASK_DEFINITION: task-definition.json
  CONTAINER_NAME: ecs-fargate-app

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Fill in the new image ID in the Amazon ECS task definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ env.ECS_TASK_DEFINITION }}
          container-name: ${{ env.CONTAINER_NAME }}
          image: ${{ steps.build-image.outputs.image }}

      - name: Deploy Amazon ECS task definition
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

And here is the full task-definition.json (with your account ID substituted):

{
  "family": "ecs-fargate-app",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "executionRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecs-fargate-app-task-execution-role",
  "taskRoleArn": "arn:aws:iam::ACCOUNT_ID:role/ecs-fargate-app-task-role",
  "containerDefinitions": [
    {
      "name": "ecs-fargate-app",
      "image": "PLACEHOLDER",
      "essential": true,
      "portMappings": [
        {
          "containerPort": 3000,
          "protocol": "tcp"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/ecs-fargate-app",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        }
      },
      "environment": [
        {
          "name": "NODE_ENV",
          "value": "production"
        },
        {
          "name": "PORT",
          "value": "3000"
        }
      ]
    }
  ]
}
Add AWS credentials as GitHub secrets

Your workflow needs AWS credentials to push images and deploy services. GitHub Actions reads these from encrypted repository secrets, so they never appear in your code.

  • Open your ecs-fargate-app repository on GitHub in your browser.
  • Click Settings (under the repository name at the top).
  • In the left sidebar under the Security section, select Secrets and variables, then click Actions.
  • Click the Secrets tab.
  • Click New repository secret.
  • In the Name field, type AWS_ACCESS_KEY_ID.
  • In the Secret field, paste your IAM user's access key ID.
  • Click Add secret.
  • Click New repository secret again.
  • In the Name field, type AWS_SECRET_ACCESS_KEY.
  • In the Secret field, paste your IAM user's secret access key.
  • Click Add secret.

You should now see both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY listed in your repository secrets.

Why use repository secrets?

Secrets are encrypted by GitHub and only injected into workflow runs at runtime. They never appear in logs or code. This is the standard way to give CI/CD pipelines access to cloud credentials without exposing them.

Don't see the Settings tab?

Only repository owners and admins can access Settings. If you created the repository under your own GitHub account, you should see it. If it is missing, click the dropdown menu next to the tab bar and select Settings from there.

Help me find the Settings tab in my GitHub repository.

Trigger the pipeline and verify automatic deployment

Everything is in place. Now you will commit all your code, push to main, and watch GitHub Actions build and deploy your app automatically.

  • Stage, commit, and push all your code by running these commands:
git add .
git commit -m "Add CI/CD pipeline and task definition"
git push origin main

What does this trigger?

Pushing to main triggers the workflow you just created. GitHub Actions will check out your code, build the Docker image, push it to ECR, and deploy the new task definition to your ECS service.

  • Open your repository on GitHub in your browser.
  • Click the Actions tab at the top of your repository.
  • Click on the running workflow (it will show your commit message as the title).
  • Watch the steps execute. The workflow takes 2-4 minutes to complete.

When all steps show green checkmarks, your container has been redeployed to ECS with the new image.

Workflow failed?

  • Click into the failed step to read the error message. Common causes: secrets misspelled (check AWS_ACCESS_KEY_ID vs AWS_ACCESS_KEY_Id - it is case-sensitive), incorrect account ID in task-definition.json, or the IAM user lacking permissions.
  • If you see a CannotPullContainerError, verify that the ECR repository name in the workflow matches ecs-fargate-app exactly.

Help me debug my failing GitHub Actions ECS deployment workflow.

Now let's prove the pipeline works end-to-end by making a code change and watching it deploy automatically.

  • In index.js, find the /info endpoint and locate this line:
    version: '1.0.0',
  • Change 1.0.0 to 1.1.0, so the line now looks like this:
    version: '1.1.0',
  • Save index.js, then commit and push the change:
git add index.js
git commit -m "Bump version to 1.1.0"
git push origin main
  • Switch back to the Actions tab in GitHub and watch the new workflow run.
  • Once it completes with green checkmarks, wait about 30 seconds for the new task to start.
  • Find your task's public IP (use the same method from the previous step, or check the ECS console) and verify the updated version:
curl http://[[ECS_PUBLIC_IP="your task's public IP address"]]:3000/info

You should see "version":"1.1.0" in the JSON response, confirming the new code deployed automatically.

Still seeing version 1.0.0?

  • The ECS service may still be draining the old task. Wait 1-2 minutes and try again.
  • The task's public IP changes on each deployment. Run aws ecs describe-tasks again to get the new IP address.

Help me verify my new ECS deployment version.

Your CI/CD pipeline is fully operational. Every push to main now triggers a complete build-push-deploy cycle without any manual intervention. Next up, you'll monitor your running app and infrastructure with CloudWatch.

Monitor Your App and Infrastructure

Your app is deployed and your CI/CD pipeline is automatically pushing updates. But how do you know your container is actually running correctly?

In production, teams rely on logs to trace problems and metrics to spot trends before they become outages. In this step, you will view your container's logs, check your service health, and set up cost tracking to avoid surprise charges.

In this step, get ready to:
  • View application logs in CloudWatch and stream them from your terminal.
  • Check ECS service health and resource metrics.
  • Set up a cost budget with email alerts.
View application logs

Every time your container writes to stdout (like the console.log in index.js), CloudWatch captures it automatically. Your Terraform configuration already set up the log group /ecs/ecs-fargate-app with a 7-day retention period.

  • Open the AWS Console in your browser.
  • Search for CloudWatch in the top search bar and open it.
  • In the left navigation pane, click Log groups.
  • Click on the log group named /ecs/ecs-fargate-app.
  • Click on the most recent log stream (it will start with ecs/ecs-fargate-app/).

You should see log entries showing your container's stdout output, including the Server running on port 3000 message from your app's startup.

What are log streams?

A log group is a collection of log streams that share the same retention and permissions settings. Each ECS task creates its own log stream within the group, so if your service restarts or scales, you will see multiple streams.

You can also stream logs directly in your terminal. This is faster for debugging because you see events in real time without refreshing the console.

  • Stream logs in real time by running this command:
aws logs tail /ecs/ecs-fargate-app --follow

What does this command do?

The aws logs tail command fetches recent log events from the specified log group. The --follow flag keeps the session open and continuously polls for new events, similar to tail -f on a local file.

Press Ctrl+C to stop streaming when you are done.

  • You should see timestamped log entries appearing in your terminal. If your service recently started, you will see the Server running on port 3000 message.
  • Press Ctrl+C to stop the log stream.

Not seeing any log events?

If no events appear, your task may not be running. Run aws ecs describe-services --cluster ecs-fargate-app --services ecs-fargate-app and check that runningCount is 1. If it is 0, the task may have crashed during startup.

Help me figure out why my ECS logs are empty.

Check ECS service health and metrics

Logs tell you what happened inside the container. Metrics tell you how hard the container is working. Together they give you a complete picture of service health.

  • In the AWS Console, search for ECS and open the Elastic Container Service.
  • Click on the cluster named ecs-fargate-app.
  • Under Services, click the service named ecs-fargate-app.
  • Click the Events tab to see deployment history and any failures.
  • Click the Metrics tab to see CPUUtilization and MemoryUtilization graphs.

What should I see?

The Events tab shows messages like "has reached a steady state" confirming your deployment succeeded. The Metrics tab shows CPU and memory graphs. For a lightweight health-check API, both should be well under 10%.

You can also check service health from the CLI for quick scripted checks.

  • Check service health at a glance by running this command:
aws ecs describe-services --cluster ecs-fargate-app --services ecs-fargate-app --query 'services[0].{Status:status,Running:runningCount,Desired:desiredCount,Events:events[:3]}'

What does this command do?

This queries the ECS API for your service's current state. The --query flag uses JMESPath to extract only the fields you care about: status, running task count, desired count, and the three most recent events.

If Running matches Desired (both 1), your service is healthy. If Running is 0 while Desired is 1, the task keeps failing and you need to check the events for clues.

  • You should see Status: ACTIVE, Running: 1, and Desired: 1 in the output.
Set up a cost budget

Your ECS Fargate task costs approximately $0.012 per hour. That is well within your Free Tier credits, but setting up a budget ensures you get notified before any unexpected charges accumulate.

  • In the AWS Console, search for Billing and open Billing and Cost Management.
  • In the left navigation pane, click Budgets.
  • Click Create budget at the top of the page.
  • Under Budget setup, choose Use a template (simplified).
  • Under Templates, select Monthly cost budget.
  • Set the budget amount to $5.
  • Enter your email address in the notification recipients field.
  • Set alert thresholds at 50% ($2.50) and 80% ($4.00).
  • Click Create budget.

Why $5 and not $1?

Your project costs well under $1 in total. A $5 budget gives breathing room for experimentation without triggering false alarms. The 50% and 80% thresholds give you early warnings at $2.50 and $4.00, long before you would reach a meaningful spend.

You should also check how much Free Tier credit you have remaining.

  • In the Billing and Cost Management navigation pane, click Credits.
  • Review your remaining credit balance. New accounts receive up to $200 in credits.

What is AWS Budgets?

AWS Budgets lets you set spending limits and receive email notifications when your costs approach or exceed those limits. Budget data refreshes up to three times per day, so alerts may have a delay of 8-12 hours.

Can't find Budgets in the navigation?

Make sure you are in the Billing and Cost Management console, not the regular AWS Console. Search for Billing in the top search bar and select Billing and Cost Management from the results. Budgets appears in the left navigation pane.

Help me find the Budgets page in AWS Console.

You now have full observability over your running service. You can read logs, check health, and track costs. Next up, you will learn how to troubleshoot the most common ECS failures so you know exactly what to do when something breaks.

Troubleshoot Common ECS Failures

Your service is deployed, monitored, and cost-tracked. But what happens when something goes wrong?

In production, containers stop unexpectedly, deployments get stuck, and networking silently breaks. Knowing how to diagnose these failures quickly separates a DevOps engineer from someone who just follows tutorials. In this step, you will build a troubleshooting playbook using real AWS CLI commands.

In this step, get ready to:
  • Diagnose why a Fargate task stopped using stop codes and reasons.
  • Investigate a deployment stuck in progress and understand circuit breaker behavior.
  • Debug networking issues that prevent traffic from reaching your container.
Diagnose a stopped task

When an ECS task stops, it leaves behind a stop code and reason explaining what went wrong. Your first job is to find the stopped task, then read its exit details.

  • List any stopped tasks in your cluster by running this command:
aws ecs list-tasks --cluster ecs-fargate-app --desired-status STOPPED

What does this command do?

The --desired-status STOPPED flag filters for tasks that ECS has terminated. If your service is healthy, this list may be empty. That is fine. The important thing is knowing the command for when something does go wrong.

  • If you have a stopped task ARN from the output above, inspect its stop reason by running this command:
aws ecs describe-tasks --cluster ecs-fargate-app --tasks [[TASK_ARN="your stopped task ARN"]] --query 'tasks[0].{StopCode:stopCode,StopReason:stoppedReason,Container:containers[0].{ExitCode:exitCode,Reason:reason}}'

What do the stop reasons mean?

ECS reports a stop code and reason for every terminated task. Here are the three most common ones you will encounter:

  • "Essential container in task exited" means your app crashed. Check CloudWatch logs for the stack trace.
  • "CannotPullContainerError" means ECS could not pull the image from ECR. This is usually an authentication issue or a wrong image tag.
  • "ResourceInitializationError" means the task could not set up its network interface (ENI). This is usually a VPC or subnet configuration problem.
  • You should see a JSON response showing the stop code, stop reason, and container exit code. An exit code of 0 means the app exited cleanly. A non-zero exit code (like 1) means it crashed.

No stopped tasks yet?

If your service is running smoothly, you may not have any stopped tasks to inspect. Save these commands in a note. You will need them the first time a deployment fails in the real world.

Fix a deployment stuck in progress

Sometimes a deployment hangs with the status showing "In Progress" for minutes. The new tasks keep starting and failing, but the old tasks stay running. The Events tab is where you find out why.

  • Check the most recent service events by running this command:
aws ecs describe-services --cluster ecs-fargate-app --services ecs-fargate-app --query 'services[0].events[:5]'

What does this command do?

This returns the five most recent service events. Each event has a timestamp and message describing what ECS did. Look for messages about tasks being started, stopped, or failing health checks.

You can also see these same events in the AWS Console under ECS > Clusters > ecs-fargate-app > Services > ecs-fargate-app > Events tab.

  • Review the events output. You should see messages about your recent deployments completing successfully.

What is the deployment circuit breaker?

Your Terraform configuration enables the deployment_circuit_breaker with rollback. If new tasks keep failing, ECS automatically rolls back to the last working version instead of retrying forever.

Common causes of stuck deployments:

  • Container failing health checks because the app crashes on startup or listens on the wrong port.
  • Security group blocking outbound traffic to ECR, so the task cannot pull the image.
  • IAM role missing permissions to pull the image or write logs.
Debug networking issues

The trickiest failures are networking issues. Your task is running, the logs look fine, but you cannot reach it from the internet. This means traffic is being blocked somewhere between the internet and your container.

  • Verify your task has a public IP assigned by running this command:
aws ecs describe-tasks --cluster ecs-fargate-app --tasks [[TASK_ARN="your running task ARN"]] --query 'tasks[0].attachments[0].details'

What should I see?

Look for a detail with name set to networkInterfaceId and another with name set to privateIPv4Address. If the subnet has map_public_ip_on_launch = true, ECS assigns a public IP to the task's ENI automatically.

  • Verify your security group allows inbound traffic on port 3000 by running this command:
aws ec2 describe-security-groups --group-ids [[SG_ID="your security group ID"]]
  • Check the IpPermissions array in the output. You should see an ingress rule with FromPort and ToPort both set to 3000 and a CIDR of 0.0.0.0/0.
  • Verify the route table has a route to the internet gateway by running this command:
aws ec2 describe-route-tables --filters Name=vpc-id,Values=[[VPC_ID="your VPC ID"]]
  • Check the Routes array. You should see a route with DestinationCidrBlock of 0.0.0.0/0 and a GatewayId starting with igw-. This confirms traffic can flow from your subnet to the internet.

The networking checklist

When a task runs but is unreachable, walk through these four checks in order:

  • Task has a public IP (from the ENI attachment details).
  • Security group allows inbound on port 3000 from 0.0.0.0/0.
  • Route table has a 0.0.0.0/0 route pointing to the internet gateway.
  • Subnet has map_public_ip_on_launch = true so new tasks get a public IP automatically.

If any of these are missing, that is your culprit. Fix it in main.tf and run terraform apply to update the infrastructure.

Still cannot reach your task?

If all four networking checks pass but you still cannot connect, verify you are using the correct public IP (it changes every time the task restarts). Also confirm your local network or ISP is not blocking outbound connections on port 3000.

Help me debug why I cannot reach my ECS Fargate task even though it has a public IP and my security group looks correct.

Secret mission

Add Auto-Scaling

Your service runs a single task right now. What happens when traffic spikes? Add Application Auto Scaling to your Terraform so your ECS service scales from 1 to 3 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. Your ECS Fargate task is billed per-second for vCPU and memory while running.

Cost warning

Your Fargate task costs approximately $0.01/hour at 0.25 vCPU and 0.5 GB memory. If you leave it running 24/7, that adds up to around $8.50/month.

If you are done with this project, choose Pause or Delete below to avoid ongoing charges. Your $200 in AWS credits covers this project easily, but they expire after 6 months.

Resources you used:

  • ECS Fargate service and task (billed per-second for vCPU and memory).
  • ECR repository (minimal storage cost for Docker images).
  • CloudWatch log group (minimal log ingestion cost).
  • VPC, subnets, internet gateway, route table, and security group (no direct cost).
  • IAM roles (no direct cost).
  • Application Auto Scaling target and policy (no direct cost).
  • AWS Budgets alert (no cost).
  • GitHub repository and secrets.
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment further with your deployment.

  • Your Fargate task continues running at approximately $0.01/hour.
  • Your AWS Budgets alert will notify you if spending approaches $5.
  • GitHub Actions will continue deploying on every push to main.

Pause - I'll come back to this later

Shut down the running task to stop per-second billing while keeping all infrastructure in place. You can scale back up any time.

  • Scale the ECS service to zero tasks by running this command:
aws ecs update-service --cluster ecs-fargate-app --service ecs-fargate-app --desired-count 0
  • This stops all running tasks immediately. No Fargate billing accrues while the desired count is zero.
  • Your ECR images, VPC, IAM roles, and CloudWatch logs remain intact.
  • To resume later, run the same command with --desired-count 1.

Delete - I don't want to use this again

Remove all project resources permanently. This is recommended after completing the project.

  • Destroy all Terraform-managed AWS resources by running this command from your ecs-fargate-app folder:
terraform destroy
  • Type yes when prompted to confirm. This removes the VPC, subnets, internet gateway, route table, security group, ECR repository, ECS cluster, ECS service, task definition, IAM roles, CloudWatch log group, and auto-scaling resources.
  • Delete the AWS Budgets alert. Open the AWS Console, go to Billing and Cost Management then Budgets. Select your $5 budget, click Actions, then Delete.
  • Delete the IAM user you created for this project. Open the AWS Console, go to IAM then Users. Select the checkbox next to your user, click Delete, type the user name to confirm, and click Delete again.
  • Delete the GitHub repository. Go to your repository page on GitHub, click Settings, scroll to the bottom under Danger Zone, and click Delete this repository.
  • Delete the local project files by running:
cd ~/Desktop
rm -rf ecs-fargate-app
  • Revoke the AWS CLI credentials stored on your machine by running:
rm ~/.aws/credentials
rm ~/.aws/config

Keep Docker Desktop and AWS CLI

Docker Desktop and the AWS CLI are general-purpose tools useful for many projects. You do not need to uninstall them unless you want to reclaim disk space.

Nice Work!

Nice Work!

You just built what most DevOps teams spend weeks setting up. From custom networking to automated deployments to a troubleshooting playbook, this is production-grade infrastructure.

You've learned how to:

  • Write Terraform that provisions a complete VPC with public subnets, an internet gateway, route tables, ECR, ECS Fargate, and IAM roles from scratch.
  • Create a GitHub Actions CI/CD pipeline that automatically builds a Docker image, pushes it to ECR, and deploys to ECS on every push to main.
  • Monitor and troubleshoot a live deployment using CloudWatch logs, ECS metrics, AWS Budgets alerts, and CLI diagnostics for stopped tasks, stuck deployments, and networking issues.
  • Added auto-scaling that scales your ECS service from 1 to 3 tasks based on CPU utilization.

Ready to quiz yourself?