Deploy a 3-Tier API to ECS Fargate

Deploy a containerized Node.js API to AWS ECS Fargate using Terraform.

Introduction

30 Second Summary

Every time you deploy through the AWS console, you build something that exists only in clicks and memory. One wrong click, one missed setting, and you're starting over from scratch. Real infrastructure teams define everything in code so they can create, destroy, and recreate environments in minutes.

In this project, you will write Terraform configuration from scratch to deploy a containerized Node.js API to AWS ECS Fargate. You'll build a production-grade VPC, push a Docker image to ECR, and serve traffic through an Application Load Balancer.

What You'll Build

You can hit a URL in your browser and get a live JSON response from your API running in a private subnet, completely isolated from the internet except through the load balancer you defined in code.

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

  • A live API accessible via an Application Load Balancer URL, running in private subnets that only accept traffic from the ALB.
  • A production-grade VPC with public and private subnet isolation across two availability zones, all defined as Terraform code you can version and share.
  • One-command infrastructure management where you can tear everything down with terraform destroy and recreate it with terraform apply in minutes.
  • Secret Mission: Add a health check endpoint and configure CloudWatch Container Insights for production observability.

Are there any prerequisites?

This project assumes you have an AWS account with the AWS CLI already configured, Docker Desktop installed, and Terraform installed. You should also have VS Code (or a similar code editor) ready to go.

If you have not set up any of these tools yet, install them before starting. Step 1 will confirm each one is working.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

You're about to deploy a live API to AWS using nothing but code. Every piece of infrastructure will be defined in Terraform files you write yourself, not clicked together in a console that someone else can't reproduce.

Set Up Your Terraform Project

Terraform needs a specific project structure before it can provision any AWS resources. Without initialization, Terraform cannot download the AWS provider plugin or validate your configuration.

In this step, you'll create a project directory, define your AWS provider configuration, and initialize Terraform so it's ready to start building infrastructure.

In this step, get ready to:
  • Create the project directory and provider configuration file.
  • Define variables for AWS region, project naming, and container port.
  • Initialize Terraform to download the AWS provider plugin.
Create your project directory and provider configuration

Terraform reads all .tf files from the current directory and combines them into a single configuration. Your first file tells Terraform which cloud provider to use and which version to download.

  • Open a terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir three-tier-ecs
cd three-tier-ecs
  • Open this folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the three-tier-ecs folder on your Desktop, then click Open.
  • If VS Code asks whether you trust the authors of this folder, click Yes, I trust the authors.
  • Open a new terminal in VS Code by pressing Ctrl+`.
  • Create providers.tf by clicking the new file icon in the Explorer sidebar (or pressing Cmd+N on macOS / Ctrl+N on Windows).
  • Name the file providers.tf and paste in the following code:
terraform {
  required_version = ">= 1.5.7"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What does this code do?

This file has two blocks that configure your Terraform project:

  • The terraform block sets the minimum Terraform version (>= 1.5.7) and pins the AWS provider to any 5.x release. The ~> 5.0 constraint means "any version from 5.0 up to but not including 6.0."
  • The provider block configures the AWS provider to use a region defined by a variable. You'll define that variable next.
  • Save providers.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the file appears in the Explorer sidebar with the name providers.tf.

Don't see providers.tf in the sidebar?

  • Make sure you saved the file with the exact name providers.tf (not providers.tf.txt or Providers.tf). Terraform only reads files ending in .tf.
  • Check that the Explorer sidebar shows THREE-TIER-ECS as the workspace root. If it shows a different folder, you opened the wrong directory.
  • help me troubleshoot why my providers.tf file isn't showing in VS Code
Define your project variables

Variables let you parameterize your Terraform configuration so you can reuse values across multiple files without hardcoding them.

  • Create a new file called variables.tf in the same folder and paste in the following code:
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     = "three-tier-api"
}

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

What does this code do?

Each variable block declares a reusable value:

  • aws_region sets where your resources will be created. The us-east-1 default means Northern Virginia.
  • project_name is used as a prefix when naming AWS resources (like three-tier-api-vpc, three-tier-api-cluster).
  • container_port tells Terraform which port your application listens on. Your Node.js API will use port 3000.
  • Save variables.tf.
  • Confirm both providers.tf and variables.tf appear in your Explorer sidebar.
  • Verify the files exist by running this in the terminal:
ls

You should see providers.tf and variables.tf listed in the output.

Only seeing one file or no files?

  • Run pwd to check your terminal is in the three-tier-ecs folder. If not, run cd ~/Desktop/three-tier-ecs to get there.
  • Check that each file was saved with the correct name and .tf extension.
  • help me find my Terraform files in the right directory
Initialize Terraform

The terraform init command downloads the AWS provider plugin you specified in providers.tf. Without this step, Terraform has no idea how to talk to AWS.

  • First, confirm Terraform is available by running:
terraform version

You should see a version number like Terraform v1.15.8 (or similar). This confirms Terraform is installed correctly.

  • Before you run the next command, what do you think terraform init will do with the ~> 5.0 constraint you wrote?
  • Initialize your Terraform project by running:
terraform init

Terraform downloads the AWS provider, installs it into a hidden .terraform directory, and creates a lock file (.terraform.lock.hcl) to pin the exact version.

You should see output ending with:

Terraform has been successfully initialized!

What does terraform init actually do?

Terraform read your providers.tf file, found the hashicorp/aws provider requirement, downloaded the latest 5.x release from the Terraform Registry, and stored it locally.

The .terraform.lock.hcl file records exactly which provider version was installed. This ensures that anyone else (or your future self) running terraform init gets the same version.

Seeing 'command not found' or provider errors?

  • If terraform version returns "command not found", Terraform is not in your system PATH. Reinstall using brew install hashicorp/tap/terraform (macOS) or check the official install guide for your OS.
  • If init fails with a provider error, double-check that providers.tf has source = "hashicorp/aws" with no typos. Terraform is case-sensitive.
  • If you see a network timeout, check your internet connection. Terraform needs to download the provider from the registry.
  • help me troubleshoot my terraform init failure

✔️ Awesome, I've got everything!

Great. Double check you've saved both files and your terminal shows the successful initialization message.

ⓧ I'd like to double check the full code

Here is the complete content of each file. Make sure yours matches exactly.

terraform {
  required_version = ">= 1.5.7"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 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     = "three-tier-api"
}

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

Your Terraform project is initialized and ready to provision AWS resources. Next up, you'll build a Node.js API and package it into a Docker container for deployment.

Build and Containerize a Node.js API

Your Terraform project is initialized and ready to provision AWS resources. Before you can deploy anything to ECS Fargate, you need a working container image.

Starting with a local build gives you instant feedback that the application works. You can verify the API responds correctly on your own machine before introducing any cloud complexity.

In this step, get ready to:
  • Write a minimal Node.js API that returns JSON with tier information.
  • Create a Dockerfile to containerize the API.
  • Build and run the container locally to verify it works.
Write a minimal Node.js API

The API is a single Express server with one endpoint. It returns JSON describing the application tier, which you will use later to confirm requests are reaching your container through the load balancer.

  • In VS Code's integrated terminal, create the app directory inside your project folder by running this command:
mkdir app
  • Confirm the directory was created by running:
ls

You should see app listed alongside your providers.tf and variables.tf files.

  • Create a new file called package.json inside the app folder. Paste the following content into it:
{
  "name": "three-tier-api",
  "version": "1.0.0",
  "description": "3-tier API for ECS Fargate deployment",
  "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 and its dependencies. The express dependency is the web framework that handles HTTP requests and sends JSON responses.

You do not need Node.js installed locally. Docker will handle installing dependencies inside the container when you build it.

  • Save package.json.
  • Create a new file called index.js inside the app folder. Paste the following code into it:
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.json({
    tier: 'application',
    status: 'healthy',
    message: '3-tier API running on ECS Fargate',
    timestamp: new Date().toISOString()
  });
});

app.listen(port, '0.0.0.0', () => {
  console.log(`API server listening on port ${port}`);
});

What does this code do?

  • express() creates the HTTP server application.
  • app.get('/', ...) defines a route that responds to GET requests at the root path with a JSON object containing tier, status, message, and timestamp fields.
  • app.listen(port, '0.0.0.0', ...) starts the server on all network interfaces. Binding to 0.0.0.0 is required for Docker to forward traffic into the container.
  • Save index.js.
  • Confirm both files exist by running:
ls app/

You should see index.js and package.json listed.

Don't see both files?

Make sure you created both files inside the app subdirectory, not in the project root alongside your .tf files.

If a file is missing, create it now inside app/ and paste the content from above.

Help me find my missing files

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is app/package.json:

{
  "name": "three-tier-api",
  "version": "1.0.0",
  "description": "3-tier API for ECS Fargate deployment",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "^4.21.0"
  }
}

Here is app/index.js:

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

app.get('/', (req, res) => {
  res.json({
    tier: 'application',
    status: 'healthy',
    message: '3-tier API running on ECS Fargate',
    timestamp: new Date().toISOString()
  });
});

app.listen(port, '0.0.0.0', () => {
  console.log(`API server listening on port ${port}`);
});
Create the Dockerfile

A Dockerfile is a set of instructions that tells Docker how to package your application into an image. This image is what ECS Fargate will run in the cloud.

  • Create a new file called Dockerfile (no file extension) inside the app folder. Paste the following content:
FROM node:22-alpine

WORKDIR /app

COPY package.json ./
RUN npm install --production

COPY index.js ./

EXPOSE 3000

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

What does this Dockerfile do?

  • FROM node:22-alpine starts from a lightweight Node.js 22 base image. Alpine Linux keeps the image small.
  • COPY package.json then RUN npm install installs dependencies first. Docker caches this layer, so rebuilds are fast when only your code changes.
  • 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.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved with no extension.

ⓧ I'd like to double check the full code

FROM node:22-alpine

WORKDIR /app

COPY package.json ./
RUN npm install --production

COPY index.js ./

EXPOSE 3000

CMD ["node", "index.js"]
Build and run the container locally

With all three files in place, you can now build a Docker image and run it on your machine. This proves the API works before you push it to AWS.

  • Build the Docker image by running this command:
docker build -t three-tier-api ./app

Docker downloads the node:22-alpine base image, installs your dependencies, and packages everything into an image tagged three-tier-api. You will see each Dockerfile instruction execute in order.

Build failed?

Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray or menu bar.

If you see a "no such file or directory" error, confirm that Dockerfile, package.json, and index.js are all inside the app folder.

Help me fix my Docker build

  • Run the container in detached mode with port 3000 mapped to your local machine:
docker run -d -p 3000:3000 --name api-test three-tier-api

What does this command do?

  • -d runs the container in the background so you get your terminal back.
  • -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container.
  • --name api-test gives the container a name so you can stop it easily later.

Docker prints a long container ID to your terminal. That confirms the container started successfully in the background.

Before you test the API, what do you think curl http://localhost:3000 will return? Think about what your app.get('/') handler sends back.

  • Test the API by running:
curl http://localhost:3000

Your terminal returns a JSON response:

{"tier":"application","status":"healthy","message":"3-tier API running on ECS Fargate","timestamp":"..."}

The tier and status fields confirm your Express API is running inside Docker and responding to requests.

Getting "connection refused"?

Check that the container is actually running:

docker ps

If you do not see api-test listed, the container exited. Check the logs with docker logs api-test to see the error.

If port 3000 is already in use by another process, stop that process first or use a different host port (e.g. -p 3001:3000).

Help me debug my container

  • Stop and remove the test container now that you have confirmed it works:
docker stop api-test && docker rm api-test

Your API is containerized and confirmed working. Next up, you will push this image to AWS ECR so that ECS Fargate can pull and run it in the cloud.

Create ECR Repository and Push Your Image

Your API runs locally, but ECS Fargate needs to pull your container image from a cloud registry it can access.

Amazon ECR (Elastic Container Registry) is that registry. In this step, you will define an ECR repository in Terraform, push your Docker image to it, and confirm the image is ready for Fargate to pull.

In this step, get ready to:
  • Write Terraform for the ECR repository and apply it.
  • Authenticate Docker to your ECR registry.
  • Build and push your container image to ECR.
Write the ECR Terraform configuration

Terraform manages ECR the same way it manages any AWS resource. You define the repository in a .tf file, and Terraform creates it for you.

  • In VS Code, create a new file called ecr.tf in your project root (the same folder as providers.tf).
  • Add the following Terraform configuration to ecr.tf:
resource "aws_ecr_repository" "api" {
  name                 = "${var.project_name}-api"
  image_tag_mutability = "MUTABLE"
  force_delete         = true
}

output "ecr_repository_url" {
  value = aws_ecr_repository.api.repository_url
}

What does this code do?

This defines an ECR repository named three-tier-api-api (built from the project_name variable plus -api).

  • image_tag_mutability = "MUTABLE" lets you overwrite the latest tag when you push new versions.
  • force_delete = true allows Terraform to destroy the repository even if it contains images. This makes cleanup easy later.
  • The output block prints the repository URL after creation. You will need this URL to tag and push your image.
  • Save ecr.tf.
  • Apply the Terraform configuration by running this command in your terminal:
terraform apply

Terraform will show you a plan listing one resource to create. It will prompt you to confirm.

  • Type yes and press Enter to approve the plan.

Terraform creates the ECR repository and prints the repository URL as an output value. Copy this URL. You will use it in the next substep.

Seeing a credentials error?

Make sure your AWS CLI is configured with valid credentials. Run aws sts get-caller-identity to confirm you are authenticated.

If you see a region mismatch, check that variables.tf has the correct default region for your AWS account.

Still stuck? Help me troubleshoot my Terraform apply error for ECR

Authenticate Docker to ECR

Docker needs permission to push images to your private ECR registry. AWS provides a temporary authentication token that lasts 12 hours.

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

Where do I find my AWS account ID?

Run aws sts get-caller-identity in your terminal. The 12-digit number in the Account field is your account ID.

You can also find it in the top-right dropdown of the AWS Console.

You should see Login Succeeded in your terminal output.

Login failed?

Double-check that your account ID is exactly 12 digits with no dashes or spaces.

Confirm Docker Desktop is running. The docker login command requires the Docker daemon to be active.

Need more help? Help me fix my ECR Docker login error

Build and push the image to ECR

Now that Docker can talk to ECR, you will tag your local image with the ECR repository URL and push it.

  • Build a fresh image tagged with your ECR repository URL by running this command:
docker build -t [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/three-tier-api-api:latest ./app
  • Push the image to ECR by running:
docker push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/three-tier-api-api:latest

What does this do?

The docker build -t command builds your image and tags it with the full ECR repository path in one step. The :latest tag is what Fargate will look for when it pulls your image.

The docker push command uploads all the image layers to ECR. You will see progress bars as each layer uploads.

Before you verify, what do you expect aws ecr describe-images to show if the push succeeded?

  • Confirm the image exists in ECR by running:
aws ecr describe-images --repository-name three-tier-api-api

You should see output listing your image with the latest tag, along with its size and push timestamp.

Image not showing up?

If describe-images returns an error saying the repository does not exist, confirm terraform apply completed successfully in the first substep.

If the push itself failed with an authorization error, re-run the aws ecr get-login-password command from the previous substep. The token may have expired if significant time passed.

Still stuck? Help me troubleshoot pushing my Docker image to ECR

✔️ Awesome, I've got everything!

Great. Double check you have saved ecr.tf and that terraform apply completed without errors.

ⓧ I'd like to double check the full code

Here is the complete ecr.tf file:

resource "aws_ecr_repository" "api" {
  name                 = "${var.project_name}-api"
  image_tag_mutability = "MUTABLE"
  force_delete         = true
}

output "ecr_repository_url" {
  value = aws_ecr_repository.api.repository_url
}

Your image is now stored in ECR, ready for Fargate to pull. Next up, you will build the full network infrastructure and deploy your container as a live, internet-accessible service.

Deploy VPC, ALB, and ECS Fargate Service

Your container image is sitting in ECR, ready to run. Now you need the infrastructure to actually run it on.

In this step, you will write the core infrastructure as Terraform code: a VPC with public and private subnets, an Application Load Balancer to receive internet traffic, and an ECS Fargate service to run your container. Terraform handles the dependency ordering automatically, so you can define everything and deploy it in one command.

In this step, get ready to:
  • Write the VPC configuration with public and private subnet isolation.
  • Write the ALB and ECS Fargate service configuration with security group enforcement.
  • Run terraform apply to deploy approximately 25 resources.
Write the VPC configuration

A VPC is your private network inside AWS. You need public subnets for the load balancer (internet-facing) and private subnets for your containers (hidden from the internet).

  • In VS Code, create a new file called vpc.tf in your three-tier-api project folder.
  • Add the VPC, internet gateway, and availability zone data source by pasting 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_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

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

What does this code do?

The data block queries AWS for available availability zones in your region. This means you do not need to hard-code zone names.

The VPC uses a 10.0.0.0/16 CIDR block, giving you over 65,000 IP addresses to work with. The internet gateway allows resources in public subnets to reach the internet.

  • Below the internet gateway resource, add the public and private subnets:
resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.${count.index + 1}.0/24"
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true

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

resource "aws_subnet" "private" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index + 10}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name = "${var.project_name}-private-${count.index + 1}"
  }
}

What does this code do?

The count = 2 creates two of each subnet type, spread across two availability zones for redundancy. Public subnets get CIDRs 10.0.1.0/24 and 10.0.2.0/24. Private subnets get 10.0.10.0/24 and 10.0.11.0/24.

Public subnets have map_public_ip_on_launch = true so resources placed there get public IPs. Private subnets do not, keeping your containers hidden from direct internet access.

  • Validate your configuration so far by running:
terraform validate

You should see: Success! The configuration is valid.

Seeing a validation error?

Check that your vpc.tf file is saved and that all curly braces are properly closed. Terraform reports the exact line number where it found the problem.

help me fix a Terraform validation error in my vpc.tf file

  • Below the private subnets, add the NAT gateway and its Elastic IP:
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name = "${var.project_name}-nat-eip"
  }
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

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

  depends_on = [aws_internet_gateway.main]
}

Why do containers in private subnets need a NAT gateway?

Your containers need to pull their image from ECR, which is an internet-accessible service. The NAT gateway lets private subnet resources make outbound requests (like pulling images) without being directly reachable from the internet.

  • Complete vpc.tf by adding the route tables and their subnet associations below the NAT gateway:
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" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.id
  }

  tags = {
    Name = "${var.project_name}-private-rt"
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "private" {
  count          = 2
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

What does this code do?

Route tables tell subnets where to send traffic. The public route table sends all outbound traffic (0.0.0.0/0) through the internet gateway. The private route table sends it through the NAT gateway instead.

The associations link each subnet to its correct route table. Without them, subnets use the VPC's default route table, which has no internet access.

  • Save vpc.tf and run a plan to preview what Terraform will create:
terraform plan

You should see approximately 13 resources listed under "Plan: 13 to add" (the VPC, subnets, gateways, route tables, and associations).

Seeing errors in the plan?

If Terraform reports an error referencing a resource it cannot find, check that your resource names match exactly. For example, aws_subnet.public[0] in the NAT gateway must reference the same resource name defined above.

help me fix a Terraform plan error in my VPC configuration

✔️ Awesome, I've got everything!

Great. Double check you have saved vpc.tf before moving on.

ⓧ I'd like to double check the full code

Your vpc.tf should contain these four sections in order:

  • The data "aws_availability_zones" block, VPC resource, and internet gateway (first chunk you added).
  • The two aws_subnet resources (public and private, both with count = 2).
  • The aws_eip and aws_nat_gateway resources.
  • The two aws_route_table resources and two aws_route_table_association resources.

Scroll up and confirm each section is present in that order.

Write the ALB and ECS configuration

Now you need the components that actually serve traffic and run your container. The ALB sits in public subnets to receive HTTP requests. The ECS service runs in private subnets and only accepts traffic from the ALB.

  • Create a new file called alb.tf in your project folder.
  • Add the ALB security group that allows HTTP traffic from the internet:
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 internet"
    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 is a security group?

A security group acts as a virtual firewall for your resources. This one allows inbound HTTP traffic on port 80 from any IP address (0.0.0.0/0), and allows all outbound traffic. The ALB needs to be publicly accessible so users can reach your API.

  • Below the security group, add the ALB itself and its target group:
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[*].id

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

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

  health_check {
    enabled             = true
    healthy_threshold   = 2
    interval            = 30
    matcher             = "200"
    path                = "/"
    port                = "traffic-port"
    timeout             = 5
    unhealthy_threshold = 3
  }

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

What does this code do?

The ALB is internet-facing (internal = false) and placed in both public subnets. The target group tells the ALB where to forward traffic. It uses target_type = "ip" because Fargate tasks get their own IP addresses.

The health check hits the root path (/) every 30 seconds. If a container fails to return a 200 response, the ALB stops sending it traffic.

  • Validate the configuration to check your work so far:
terraform validate

You should see Success! The configuration is valid. again.

  • Complete alb.tf by adding the listener and output below the target group:
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.api.arn
  }
}

output "alb_dns_name" {
  value = aws_lb.main.dns_name
}

What does this code do?

The listener watches port 80 and forwards all traffic to the target group. The output block prints the ALB's DNS name after deployment so you can access your API.

  • Save alb.tf.
  • Create a new file called ecs.tf in your project folder.
  • Add the ECS security group that only allows traffic from the ALB:
resource "aws_security_group" "ecs" {
  name        = "${var.project_name}-ecs-sg"
  description = "Security group for ECS tasks"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "Allow traffic from ALB only"
    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-sg"
  }
}

Why reference the ALB security group instead of an IP range?

By setting security_groups = [aws_security_group.alb.id] in the ingress rule, only traffic originating from the ALB can reach your containers. No one can bypass the load balancer and hit your containers directly. This is the network isolation that makes a 3-tier architecture secure.

  • Below the security group, add the ECS cluster and IAM execution role:
resource "aws_ecs_cluster" "main" {
  name = "${var.project_name}-cluster"
}

resource "aws_iam_role" "ecs_task_execution" {
  name = "${var.project_name}-ecs-exec-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"
}

What does this code do?

The cluster is a logical grouping for your Fargate tasks. The IAM role gives ECS permission to pull images from ECR and write logs to CloudWatch on your behalf. The AmazonECSTaskExecutionRolePolicy is an AWS-managed policy that grants exactly these permissions.

  • Validate once more to catch any issues before adding the task definition:
terraform validate

You should see Success! The configuration is valid. confirming your references are correct.

  • Below the IAM policy attachment, add the CloudWatch log group and task definition:
resource "aws_cloudwatch_log_group" "ecs" {
  name              = "/ecs/${var.project_name}"
  retention_in_days = 7
}

resource "aws_ecs_task_definition" "api" {
  family                   = "${var.project_name}-api"
  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}-api"
      image     = "${aws_ecr_repository.api.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"
        }
      }
    }
  ])
}

What does this code do?

The task definition tells Fargate how to run your container: 0.25 vCPU, 512 MB memory, port 3000, and the image URL from your ECR repository. The awsvpc network mode gives each task its own network interface inside your VPC.

Logs are sent to CloudWatch via the awslogs driver. You will use these logs in the next step to diagnose an issue.

  • Complete ecs.tf by adding the ECS service below the task definition:
resource "aws_ecs_service" "api" {
  name            = "${var.project_name}-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.api.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = aws_subnet.private[*].id
    security_groups  = [aws_security_group.ecs.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.api.arn
    container_name   = "${var.project_name}-api"
    container_port   = var.container_port
  }

  depends_on = [aws_lb_listener.http]
}

What does this code do?

The service runs one task (desired_count = 1) in your private subnets with no public IP. It registers itself with the ALB target group so the load balancer can route traffic to it.

The depends_on ensures the listener exists before the service tries to register. Without it, Terraform might try to create the service before the load balancer is ready.

  • Save ecs.tf.

✔️ Awesome, I've got everything!

Great. Make sure both alb.tf and ecs.tf are saved.

ⓧ I'd like to double check the full code

Your alb.tf should contain these sections in order:

  • The aws_security_group "alb" resource allowing port 80 ingress.
  • The aws_lb "main" resource and aws_lb_target_group "api" resource with health check.
  • The aws_lb_listener "http" resource and alb_dns_name output.

Your ecs.tf should contain these sections in order:

  • The aws_security_group "ecs" resource with ingress from the ALB security group.
  • The aws_ecs_cluster, aws_iam_role, and aws_iam_role_policy_attachment resources.
  • The aws_cloudwatch_log_group and aws_ecs_task_definition resources.
  • The aws_ecs_service "api" resource at the bottom.

Scroll up and confirm each section is present in that order in both files.

Deploy everything with terraform apply

All your infrastructure is defined. Terraform will read all .tf files in the directory, calculate the dependency graph, and create resources in the correct order.

  • Deploy the full infrastructure by running:
terraform apply

What happens during terraform apply?

Terraform shows you a plan of everything it will create. It prompts you to confirm with yes. Type yes and press Enter to proceed.

This takes 2-4 minutes because the NAT gateway and ALB both take time to provision. Watch the progress in your terminal.

  • When prompted, type yes and press Enter.

After a few minutes, you should see output like: "Apply complete! Resources: 25 added, 0 changed, 0 destroyed." followed by an alb_dns_name output value.

Before you test the ALB URL, think about this: do you expect your API to respond successfully when you hit that URL? Why or why not?

  • Test your deployed API by running this command, replacing the URL with your alb_dns_name output value:
curl http://[[ALB_DNS="your ALB DNS name from the terraform output"]]

You get a 503 Service Temporarily Unavailable error. The ALB is running, but it has no healthy targets to forward traffic to.

Seeing a 503? That is expected.

Your ECS tasks are starting and immediately crashing. The ALB health checks are failing because no container is running long enough to respond. This is the designed obstacle for this project.

In the next step, you will check CloudWatch Logs to find out exactly why the tasks are failing and fix the root cause.

Your infrastructure is deployed and your load balancer is live. The 503 means something is wrong inside the container. Next up, you will dig into CloudWatch Logs to find the smoking gun and fix it.

Diagnose and Fix the Platform Mismatch

Your infrastructure is deployed, but the ALB returns a 503 and ECS keeps restarting your task. Something is crashing the container before it can serve a single request.

When containers fail on ECS Fargate, CloudWatch Logs is your first diagnostic tool. In this step, you will pull the error from the logs, identify the root cause, and rebuild the image to fix it.

In this step, get ready to:
  • Find the root cause of the crash in CloudWatch Logs.
  • Rebuild the Docker image for the correct CPU architecture.
  • Force a new ECS deployment and confirm the API responds.
Check CloudWatch Logs for the error

The ECS task is crashing immediately after launch. The container logs are forwarded to a CloudWatch log group your Terraform already created.

  • View the recent logs from your ECS tasks by running this command:
aws logs tail /ecs/three-tier-api --since 30m

Before you look at the output, what do you think caused the container to crash?

You should see a message containing exec format error in the output. This is the smoking gun.

What does exec format error mean?

Your Mac has an Apple Silicon chip (M1, M2, M3, or M4), which uses the ARM64 architecture. When you ran docker build without specifying a platform, Docker built an ARM64 image by default.

ECS Fargate runs on X86_64 (Intel/AMD) architecture. When Fargate tries to execute an ARM64 binary on an X86_64 host, the kernel cannot interpret the instructions, and you get exec format error.

Why is this a common DevOps issue?

This is the single most common deployment failure engineers hit when working from Apple Silicon Macs. Everything works locally because the image matches your Mac's architecture, but it breaks the moment you push to a cloud service running on X86_64.

Rebuild the image for X86_64 and push to ECR

The fix is straightforward: rebuild the Docker image with an explicit platform target that matches what Fargate expects.

  • Rebuild the Docker image with the correct platform by running this command:
docker build --platform linux/amd64 -t three-tier-api ./app

What does --platform linux/amd64 do?

The --platform linux/amd64 flag tells Docker to build the image for X86_64 architecture, regardless of what CPU your host machine uses. Docker uses emulation to cross-compile the image.

The build takes a little longer than before because Docker is emulating a different CPU architecture.

  • Get your ECR repository URL by running:
terraform output ecr_repository_url
  • Re-authenticate Docker to ECR by running this command (your previous login token may have expired):
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com
  • You should see Login Succeeded in the output.
  • Tag your newly built image using the ECR repository URL from the terraform output command:
docker tag three-tier-api:latest [[ECR_REPO_URL="your ECR repository URL from terraform output"]]:latest
  • Push the corrected image to ECR by running:
docker push [[ECR_REPO_URL="your ECR repository URL from terraform output"]]:latest

You should see the image layers being pushed. The final line confirms the latest tag was pushed successfully.

Push failed or access denied?

Make sure you re-ran the aws ecr get-login-password command above. ECR login tokens expire after 12 hours.

Double check that the --region in your login command matches us-east-1 (or whichever region you chose in variables.tf).

Help me debug my ECR push failure.

Force a new ECS deployment and verify

The new image is in ECR, but ECS is still running tasks from the old (broken) image. You need to tell ECS to pull the latest image and start a fresh task.

  • Force ECS to deploy a new task with the updated image by running:
aws ecs update-service --cluster three-tier-api-cluster --service three-tier-api-service --force-new-deployment

What does --force-new-deployment do?

ECS caches the task definition and won't automatically pull a new image just because you pushed one with the same tag. The --force-new-deployment flag tells ECS to stop the current task and start a brand new one, which triggers a fresh image pull from ECR.

The new task takes 1-2 minutes to start. ECS needs to pull the image, start the container, and pass the ALB health check.

  • Wait about 90 seconds, then get your ALB DNS name by running:
terraform output alb_dns_name

Before you run the next command, do you think the request will succeed this time? What changed between now and your last attempt?

  • Test your API by curling the ALB DNS name:
curl http://[[ALB_DNS="your ALB DNS name from terraform output"]]

You should see the JSON response from your containerized API:

{"tier": "application", "status": "healthy", "message": "3-tier API running on ECS Fargate", "timestamp": "..."}

Still getting a 503?

The new task may still be starting. Wait another 60 seconds and try curl again.

If it persists, check that the new task is running by looking at the ECS service events:

aws ecs describe-services --cluster three-tier-api-cluster --services three-tier-api-service --query "services[0].events[:5]"

Look for a message saying the task reached a steady state. If you see repeated task failures, re-check the logs:

aws logs tail /ecs/three-tier-api --since 5m

Help me debug my ECS service that still returns 503.

Your 3-tier API is live on the internet. Traffic flows from the browser to the ALB in public subnets, then to your container in private subnets. The entire stack is defined in Terraform and can be torn down with a single command.

Secret mission

Add Health Checks and Container Insights

Your API is running, but can the ALB tell if it is actually healthy? Right now, the load balancer pings your root endpoint and hopes for the best. A production deployment needs a dedicated health check and real-time metrics. In this secret mission, you will add both.

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. This project uses AWS resources that incur ongoing costs even when idle.

Cost warning

The NAT Gateway costs approximately $0.045/hour and the Application Load Balancer costs approximately $0.0225/hour. Together with Fargate compute, these resources accumulate approximately $2-3/day if left running.

Unless you are actively building on this project, tear down all resources now. You can always redeploy with a single terraform apply later.

Resources you used:

  • NAT Gateway (three-tier-api-nat) with Elastic IP.
  • Application Load Balancer (three-tier-api-alb) with target group and listener.
  • ECS Fargate cluster, service, and task definition (three-tier-api-cluster).
  • ECR repository (three-tier-api-api) with container image.
  • VPC (three-tier-api-vpc) with public/private subnets, route tables, and internet gateway.
  • CloudWatch Log Group (/ecs/three-tier-api) and Container Insights metrics.
  • IAM role (three-tier-api-ecs-exec-role) for ECS task execution.
  • Local project files on your Desktop (three-tier-api folder).

Keep everything running

No action needed. Choose this if you are still actively building on this infrastructure or plan to use it within the next few hours.

  • Your ALB URL continues to serve traffic from your Fargate container.
  • Costs accumulate at approximately $2-3/day from the NAT Gateway and ALB alone.
  • Monitor costs in the AWS Billing console under Bills to avoid surprises.

Pause - I'll come back to this later

Fargate does not have a true pause feature. The closest option is scaling the ECS service to zero tasks so you stop paying for compute, but the NAT Gateway and ALB still incur charges.

  • Scale the service to zero tasks by running:
aws ecs update-service --cluster three-tier-api-cluster --service three-tier-api-service --desired-count 0
  • This stops Fargate compute charges but the NAT Gateway and ALB continue to cost approximately $1.60/day.
  • To resume later, set desired count back to 1 or run terraform apply to restore the full configuration.

Full teardown saves more

If you will be away for more than a day, a full terraform destroy is more cost-effective. You can redeploy everything with terraform apply in a few minutes.

Delete - I don't want to use this again

Remove all AWS resources and local project files to stop all charges immediately.

  • In your terminal, make sure you are inside the three-tier-api folder on your Desktop.
  • Destroy all AWS resources by running:
terraform destroy
  • Terraform will display a plan of all resources to be destroyed. Type yes and press Enter to confirm.
  • Wait for Terraform to finish. It will print "Destroy complete! Resources: X destroyed." when done.
  • Verify resources are gone by checking the AWS VPC console. The three-tier-api-vpc should no longer appear.
  • Remove local project files by running:
cd ~/Desktop
rm -rf three-tier-api

Confirm in the AWS console

Check the ECS, ECR, and VPC pages in the AWS console to confirm no resources with the three-tier-api prefix remain. If any do, they may have failed to delete. Re-run terraform destroy or delete them manually from the console.

That's a Wrap!

That's a Wrap!

Nice work! You just deployed a production-grade, network-isolated API to AWS using nothing but Terraform and Docker.

You've learned how to:

  • Build a Node.js API and containerize it with Docker.
  • Write Terraform configuration from scratch to provision a production-grade VPC with public/private subnet isolation.
  • Deploy an ECS Fargate service behind an Application Load Balancer with security group enforcement.
  • Diagnose and fix a real-world platform architecture mismatch (ARM64 vs X86_64) using CloudWatch Logs.
  • Tear down all infrastructure cleanly with terraform destroy.
  • Secret Mission: Added health checks and Container Insights for production observability.

Ready to quiz yourself?