CI/CD Pipeline with Auto Rollback
Build a rolling deployment pipeline on ECS Fargate with automatic rollback.
Introduction
30 Second Summary
When you update an app on your phone, you rarely think about what happens if the new version is broken. The best systems handle failures automatically, reverting to the previous working version without anyone lifting a finger.
In this project, you will build a CI/CD pipeline on AWS that deploys a containerized Python API with this same safety net. Every code push triggers an automated build and deployment, with automatic rollback when something goes wrong.
What You'll Build
A production-grade CI/CD pipeline that deploys a Python Flask API to ECS Fargate via CodePipeline, with rolling deployments and automatic rollback powered by the deployment circuit breaker.
By the end of this project, you'll have:
- A fully automated CI/CD pipeline that builds a Docker image and deploys it to ECS Fargate on every push to GitHub.
- A rolling deployment configuration with a deployment circuit breaker that detects health check failures and automatically reverts to the last working version.
- A proven automatic rollback, tested by intentionally deploying a broken version and watching the system recover on its own.
- Secret Mission: Build a CloudWatch dashboard that visualizes deployment health metrics in real time.
Do I need any prior experience?
This project assumes you have an AWS account, a GitHub account, and Terraform installed on your machine. You should have basic familiarity with AWS services and Terraform syntax.
Step 1 walks you through installing the remaining tools (AWS CLI, Docker Desktop, and Git) if you don't have them already.
Install and Configure Tools
Before you can provision infrastructure or deploy containers, your local machine needs three tools that tie everything together.
The AWS CLI lets you talk to AWS from the terminal. Docker Desktop builds and runs container images locally. Git tracks your code changes and pushes them to GitHub, which triggers the pipeline later.
In this step, get ready to:
- Install AWS CLI, Docker Desktop, and Git.
- Configure the AWS CLI with your credentials and default region.
- Verify connectivity to AWS and confirm Docker is running.
Install AWS CLI, Docker Desktop, and Git
Each of these tools has its own installer. You will check whether each is already on your machine, and install any that are missing.
Check the AWS CLI
- Open your terminal and check whether the AWS CLI is installed by running:
aws --version
✔️ I see a version number
The AWS CLI is already installed. Move on to the Docker Desktop check below.
ⓧ Command not found
- Download and install the AWS CLI from the official page:
- https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html
- Follow the instructions for your operating system (macOS, Windows, or Linux).
- After the installer finishes, close and reopen your terminal.
- Run the version check again to confirm:
aws --version
You should now see a version string starting with aws-cli/2.
Check Docker Desktop
- Check whether Docker is installed by running:
docker --version
✔️ I see a version number
Docker is installed. Make sure Docker Desktop is running (look for the whale icon in your system tray or menu bar), then move on to the Git check below.
ⓧ Command not found
- Download Docker Desktop from the official page:
- https://docs.docker.com/get-started/introduction/get-docker-desktop/
- Run the installer and follow the prompts for your operating system.
- Once installed, launch Docker Desktop. It may take a minute to finish starting up.
- Close and reopen your terminal, then run the version check again:
docker --version
You should now see a version string starting with Docker version.
Check Git
- Check whether Git is installed by running:
git --version
✔️ I see a version number
Git is ready. Move on to configuring the AWS CLI.
ⓧ Command not found
- Download Git from the official page:
- https://git-scm.com/install/
- Choose your operating system and follow the installation instructions.
- On Windows, the installer will ask several configuration questions. Accept the defaults for each prompt.
- Close and reopen your terminal, then verify:
git --version
You should see a version number like git version 2.x.x.
Still seeing command not found?
If any of the three tools still report command not found after installing, close your terminal completely and open a fresh window. New PATH entries only take effect in new terminal sessions.
help me troubleshoot why my terminal can't find a tool I just installed
Configure AWS CLI with your credentials
The AWS CLI needs your access keys and a default region so it knows which account to act on and where to create resources.
- Start the configuration wizard by running:
aws configure
- The wizard prompts you for four values. Enter them one at a time:
- AWS Access Key ID: paste your access key ID.
- AWS Secret Access Key: paste your secret access key.
- Default region name: enter us-east-1.
- Default output format: enter json.
Where do I find my access keys?
In the AWS Console, go to IAM and select your user. Open the Security credentials tab and create an access key if you do not already have one.
Copy the Access Key ID and Secret Access Key immediately. AWS only shows the secret once.
Use a dedicated IAM user
Avoid using your root account credentials. Create an IAM user with administrator access for this project, and use that user's access keys instead.
Verify connectivity and Docker status
With all three tools installed and the CLI configured, confirm that everything is actually working end to end.
- Verify your AWS identity by running:
aws sts get-caller-identity
What does this command do?
This command asks AWS to confirm who you are. It returns your AWS account ID, IAM user ARN, and user ID. If it succeeds, your credentials and network connection are working.
- You should see a JSON response containing your Account, UserId, and Arn fields.
- Now confirm Docker is running by running:
docker info
- You should see Docker Engine details including the server version, storage driver, and number of containers. This confirms Docker Desktop is running and ready to build images.
Seeing an error from docker info?
If you see a message about the Docker daemon not running, open Docker Desktop manually. It can take 30-60 seconds to start up. Wait for the whale icon to stop animating, then try the command again.
help me troubleshoot Docker Desktop not starting
Your local environment is fully set up. Next, you will create the Flask application and Dockerfile that form the deployable unit for your CI/CD pipeline.
Create the Flask Application and Dockerfile
Your tools are installed and connected to AWS. Now you need application code for the pipeline to build and deploy.
In this step, you will create a Python Flask API with a health check endpoint, package it into a Docker container, and write the build specification that tells AWS CodeBuild how to build and push your image. These four files form the foundation that your entire CI/CD pipeline will operate on.
In this step, get ready to:
- Create a Flask API with a root endpoint and a health check endpoint.
- Package the Flask app into a Docker container with a Dockerfile.
- Write a CodeBuild build specification that builds, tags, and pushes Docker images to ECR.
Create the project directory and Flask application
Every CI/CD pipeline needs a source code repository to watch for changes. You will create a project folder, initialize it as a Git repository, and write the Flask API that will run on ECS Fargate.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir rolling-deploy-pipeline
cd rolling-deploy-pipeline
- Initialize a Git repository by running:
git init
Why initialize Git now?
CodePipeline watches your GitHub repository for new commits. Initializing Git now means every file you create is ready to be tracked and pushed later.
- Press the Windows key to open your search bar.
- Type Notepad++ and press Enter to open it.
- In Notepad++, go to File > New to create a new file.
- Paste the following code into the new file:
from flask import Flask, jsonify
import os
app = Flask(__name__)
@app.route("/")
def home():
return jsonify({
"service": "rolling-deploy-demo",
"version": "1.0.0",
"status": "running"
})
@app.route("/health")
def health():
return jsonify({"status": "healthy"}), 200
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8080))
app.run(host="0.0.0.0", port=port)
What does this code do?
This Flask API has two endpoints that serve different purposes in the deployment pipeline:
- The / root endpoint returns JSON with the service name and version. This is your application's main response.
- The /health endpoint returns a 200 status code. The Application Load Balancer will hit this endpoint repeatedly to confirm your app is alive.
- The if __name__ block lets you run the app locally for testing, but in production the container uses gunicorn instead.
- Save the file by pressing Ctrl+S.
- In the Save dialog, navigate to your rolling-deploy-pipeline folder on your Desktop.
- Name the file app.py and click Save.
- Back in your terminal, confirm the file was saved in the right place by running:
ls
You should see app.py listed in the output.
Don't see app.py?
- Make sure you saved the file inside the rolling-deploy-pipeline folder on your Desktop, not in a different location.
- Check that the filename is exactly app.py with no extra extension like .txt. In Notepad++, select All types (*.*) in the Save as type dropdown to prevent this.
- Confirm your terminal is in the correct folder by running pwd. It should show the path ending in rolling-deploy-pipeline.
help me find my app.py file in the right project folder
✔️ Awesome, I've got everything!
Great. Make sure app.py is saved and move on to the next file.
ⓧ I'd like to double check the full code
from flask import Flask, jsonify
import os
app = Flask(__name__)
@app.route("/")
def home():
return jsonify({
"service": "rolling-deploy-demo",
"version": "1.0.0",
"status": "running"
})
@app.route("/health")
def health():
return jsonify({"status": "healthy"}), 200
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8080))
app.run(host="0.0.0.0", port=port)
Create the Dockerfile and requirements file
Your Flask app needs two more files to become a deployable container. The requirements file pins your Python dependencies, and the Dockerfile tells Docker how to build the image.
- In Notepad++, go to File > New to create a new file.
- Paste the following into the new file:
flask==3.1.1
gunicorn==23.0.0
- Save the file as requirements.txt in your rolling-deploy-pipeline folder.
What are these dependencies?
- flask==3.1.1 is the web framework that handles HTTP requests and routing.
- gunicorn==23.0.0 is a production-grade WSGI server that runs your Flask app with multiple worker processes. It replaces Flask's built-in development server.
- In Notepad++, go to File > New to create another new file.
- Paste the following code into the new file:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
What does this Dockerfile do?
- FROM python:3.12-slim uses a lightweight Python base image to keep the container small.
- COPY requirements.txt and RUN pip install installs dependencies in a separate layer. Docker caches this layer so rebuilds are fast when only your app code changes.
- EXPOSE 8080 documents the port your app listens on. The ECS task definition and ALB will use this same port.
- The CMD runs gunicorn with 2 workers, binding to all interfaces on port 8080.
- Save the file as Dockerfile (no extension) in your rolling-deploy-pipeline folder.
- In your terminal, run ls to confirm you now see app.py, Dockerfile, and requirements.txt.
File saved with a .txt extension?
Notepad++ sometimes appends .txt to filenames. When saving, change the Save as type dropdown to All types (*.*) before saving. This ensures the file is saved with the exact name you typed.
If you already saved with the wrong name, rename the file in your terminal:
mv Dockerfile.txt Dockerfile
help me fix my Dockerfile naming in Notepad++
✔️ Awesome, I've got everything!
Great. Confirm both files are saved in your rolling-deploy-pipeline folder.
ⓧ I'd like to double check the full code
Here is requirements.txt:
flask==3.1.1
gunicorn==23.0.0
Here is Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
Create the CodeBuild build specification
AWS CodeBuild needs a buildspec.yml file that tells it exactly what commands to run during a build. This file is the bridge between your source code and the container image that gets deployed to ECS.
- In Notepad++, go to File > New to create a new file.
- Paste the following build specification:
version: 0.2
phases:
pre_build:
commands:
- echo Logging in to Amazon ECR...
- aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
- REPOSITORY_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME
- COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
- IMAGE_TAG=${COMMIT_HASH:=latest}
build:
commands:
- echo Building the Docker image...
- docker build -t $REPOSITORY_URI:latest .
- docker tag $REPOSITORY_URI:latest $REPOSITORY_URI:$IMAGE_TAG
post_build:
commands:
- echo Pushing the Docker image...
- docker push $REPOSITORY_URI:latest
- docker push $REPOSITORY_URI:$IMAGE_TAG
- echo Writing image definitions file...
- printf '[{"name":"app","imageUri":"%s"}]' $REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json
artifacts:
files:
- imagedefinitions.json
What does each phase do?
- pre_build logs into your ECR registry and sets up the image tag using the first 7 characters of the Git commit hash. This gives every build a unique, traceable tag.
- build runs docker build using your Dockerfile and tags the image with both latest and the commit hash.
- post_build pushes both tags to ECR, then writes imagedefinitions.json. This JSON file tells ECS which container image to deploy and which container name to use.
The environment variables ($AWS_ACCOUNT_ID, $AWS_DEFAULT_REGION, $IMAGE_REPO_NAME) are injected by CodeBuild from the Terraform configuration you will write in a later step.
- Save the file as buildspec.yml in your rolling-deploy-pipeline folder.
- In your terminal, verify all four files exist by running:
ls
You should see app.py, buildspec.yml, Dockerfile, and requirements.txt in the output.
Missing one or more files?
- Run pwd to confirm you are in the rolling-deploy-pipeline directory on your Desktop.
- If a file has a .txt extension appended, rename it with mv filename.txt filename.
- If a file is missing entirely, check whether it was saved to a different folder. Look in your Downloads or Documents folder.
help me find my missing project files
✔️ Awesome, I've got everything!
Great. All four files are in place and ready for the pipeline.
ⓧ I'd like to double check the full code
version: 0.2
phases:
pre_build:
commands:
- echo Logging in to Amazon ECR...
- aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
- REPOSITORY_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME
- COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
- IMAGE_TAG=${COMMIT_HASH:=latest}
build:
commands:
- echo Building the Docker image...
- docker build -t $REPOSITORY_URI:latest .
- docker tag $REPOSITORY_URI:latest $REPOSITORY_URI:$IMAGE_TAG
post_build:
commands:
- echo Pushing the Docker image...
- docker push $REPOSITORY_URI:latest
- docker push $REPOSITORY_URI:$IMAGE_TAG
- echo Writing image definitions file...
- printf '[{"name":"app","imageUri":"%s"}]' $REPOSITORY_URI:$IMAGE_TAG > imagedefinitions.json
artifacts:
files:
- imagedefinitions.json
Your application code is ready. Next up, you will write the Terraform configuration that provisions the AWS networking and container registry infrastructure to host this app.
Write Terraform for Networking and ECR
Your Flask application and Docker configuration are ready. Now you need somewhere to run them.
In this step, you will use Terraform to define the networking layer and container registry that your application needs. Terraform lets you describe your entire AWS infrastructure in code files, then create everything in one command.
In this step, get ready to:
- Configure the Terraform provider and project variables.
- Define a VPC with public subnets across two availability zones.
- Create an ECR repository to store your Docker images.
Set up the Terraform provider and variables
Every Terraform project starts with a provider configuration that tells Terraform which cloud platform to use and how to authenticate.
- Create a new file called providers.tf in your project directory and add the following code:
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
What does this code do?
- The terraform block pins the minimum Terraform version to 1.0 and locks the AWS provider to any version in the 5.x range.
- The provider "aws" block reads the region from a variable. Terraform will use the credentials you configured with aws configure in Step 1.
- Save providers.tf.
Seeing red squiggles in your editor?
Your editor may not recognize .tf files by default. This is purely cosmetic and will not affect Terraform. You can install a Terraform/HCL extension if you want syntax highlighting.
help me set up HCL syntax highlighting in my editor
Next, you need a variables file so you can reuse values like the region and project name across all your Terraform files.
- Create a new file called variables.tf and add the following code:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "rolling-deploy"
}
variable "github_repo" {
description = "GitHub repository in format owner/repo"
type = string
}
variable "github_branch" {
description = "GitHub branch to track"
type = string
default = "main"
}
What does this code do?
- aws_region defaults to us-east-1 so all resources land in the same region as your CLI configuration.
- project_name is a prefix used in every resource name. This keeps your resources easy to find in the AWS Console.
- github_repo has no default. Terraform will prompt you for this value when you apply later.
- Save variables.tf.
- Confirm both files exist by running:
ls *.tf
- You should see providers.tf and variables.tf in the output.
✔️ 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 providers.tf:
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
And here is variables.tf:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name prefix for all resources"
type = string
default = "rolling-deploy"
}
variable "github_repo" {
description = "GitHub repository in format owner/repo"
type = string
}
variable "github_branch" {
description = "GitHub branch to track"
type = string
default = "main"
}
Define the VPC and networking layer
Your ECS tasks need a network to run in. A VPC (Virtual Private Cloud) is an isolated network inside AWS where you control the IP ranges, subnets, and routing.
You will create two public subnets in different availability zones. This gives the load balancer redundancy across data centers.
- Create a new file called network.tf and add the VPC and subnet configuration:
# Look up available AZs in the region
data "aws_availability_zones" "available" {
state = "available"
}
# Create the VPC with DNS enabled
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"
}
}
# First public subnet in AZ 1
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-1"
}
}
# Second public subnet in AZ 2
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = data.aws_availability_zones.available.names[1]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-2"
}
}
What does this code do?
- The data "aws_availability_zones" block dynamically fetches which availability zones are available in your region. This means the code works in any region without hardcoding zone names.
- The VPC uses a 10.0.0.0/16 CIDR block, giving you 65,536 IP addresses to work with.
- Each subnet sits in a different availability zone. If one data center has an issue, your application stays up in the other.
- map_public_ip_on_launch = true gives each Fargate task a public IP so it can pull images from ECR and respond to traffic.
Why public subnets instead of private?
In production, you would place ECS tasks in private subnets behind a NAT Gateway. NAT Gateways cost approximately $0.045 per hour, which adds up during a learning session.
Public subnets with assign_public_ip = true on the Fargate tasks achieve the same connectivity at zero extra cost. The security group you will add later restricts access so only the load balancer can reach your tasks.
Subnets alone cannot reach the internet. You need an internet gateway and a route table to connect them.
- Add the internet gateway and routing configuration to the bottom of network.tf:
# Internet gateway allows traffic in and out of the VPC
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
# Route table sends all traffic to the internet 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"
}
}
# Associate both subnets with the public route table
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
What does this code do?
- The internet gateway is the door between your VPC and the public internet.
- The route table contains one rule: send all traffic (0.0.0.0/0) through the internet gateway.
- The route table associations attach this routing rule to both subnets. Without them, the subnets would have no route to the internet.
- Save network.tf.
- Confirm the file exists by running:
ls *.tf
- You should see network.tf, providers.tf, and variables.tf in the output.
Missing a file?
Make sure you saved the file with the .tf extension, not .tf.txt. Some editors like Notepad++ can silently add a second extension.
Check that you are running ls in your project directory and not a parent folder.
why isn't my .tf file showing up when I run ls?
✔️ Awesome, I've got everything!
Great. Double check you have saved network.tf before moving on.
ⓧ I'd like to double check the full code
Here is the complete network.tf file:
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
}
}
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-1"
}
}
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = data.aws_availability_zones.available.names[1]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-2"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-public-rt"
}
}
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
Create the ECR repository
ECR (Elastic Container Registry) is AWS's Docker image registry. When CodeBuild builds your Docker image, it pushes the result to ECR. Then ECS pulls from ECR when it launches new tasks.
- Create a new file called ecr.tf and add the following code:
# Container registry for storing Docker images
resource "aws_ecr_repository" "app" {
name = "${var.project_name}-app"
image_tag_mutability = "MUTABLE"
force_delete = true
image_scanning_configuration {
scan_on_push = false
}
tags = {
Name = "${var.project_name}-ecr"
}
}
What does this code do?
- image_tag_mutability = "MUTABLE" allows you to overwrite the latest tag on each build. This is essential for rolling deployments where each push updates the same tag.
- force_delete = true lets Terraform destroy the repository even when it contains images. This makes cleanup easy at the end of the project.
- scan_on_push = false disables vulnerability scanning. This keeps things simple for a learning project.
- Save ecr.tf.
- Verify all four Terraform files exist by running:
ls *.tf
- You should see ecr.tf, network.tf, providers.tf, and variables.tf in the output.
Why does ECS need an initial image?
When Terraform creates the ECS service, it immediately tries to pull the Docker image from ECR. If the repository is empty, the tasks will fail to start.
You will handle this in the deployment step by pushing an initial image to ECR before ECS needs it. The ECR repository just needs to exist first.
Seeing a file with a double extension?
If you see something like ecr.tf.txt, your editor added an extra extension. Rename the file to remove the .txt portion. In Notepad++, make sure you select **All types** in the Save As dialog.
help me rename my Terraform files to remove the extra .txt extension
✔️ Awesome, I've got everything!
Great. Double check you have saved ecr.tf before moving on.
ⓧ I'd like to double check the full code
Here is the complete ecr.tf file:
resource "aws_ecr_repository" "app" {
name = "${var.project_name}-app"
image_tag_mutability = "MUTABLE"
force_delete = true
image_scanning_configuration {
scan_on_push = false
}
tags = {
Name = "${var.project_name}-ecr"
}
}
Your networking foundation and container registry are defined. Next up, you will write the Terraform for the load balancer and ECS cluster that actually run your application.
Write Terraform for ECS, ALB, and IAM
Your networking and container registry are ready. Now you need the compute layer that actually runs your application and routes traffic to it.
In this step, you will create the Application Load Balancer that distributes traffic, the ECS service that runs your containers, and the IAM roles that grant each service the permissions it needs.
In this step, get ready to:
- Create an Application Load Balancer with health-check-based routing.
- Configure an ECS Fargate service with a deployment circuit breaker for automatic rollback.
- Define IAM roles for ECS, CodeBuild, and CodePipeline.
Create the Application Load Balancer
The ALB sits in front of your ECS tasks and distributes incoming HTTP requests across healthy containers. It also performs health checks on the /health endpoint you built earlier.
- Create a new file called alb.tf in your project directory and paste the following code:
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for ALB"
vpc_id = aws_vpc.main.id
# Allow HTTP traffic from anywhere
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# Allow all outbound traffic
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-alb-sg"
}
}
# The load balancer itself, spanning both public subnets
resource "aws_lb" "main" {
name = "${var.project_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_1.id, aws_subnet.public_2.id]
tags = {
Name = "${var.project_name}-alb"
}
}
# Target group tells the ALB where to send traffic and how to health-check
resource "aws_lb_target_group" "app" {
name = "${var.project_name}-tg"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/health"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
interval = 15
timeout = 5
matcher = "200"
}
deregistration_delay = 30
tags = {
Name = "${var.project_name}-tg"
}
}
# Listener accepts traffic on port 80 and forwards to 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.app.arn
}
}
What does this code do?
This file creates the full traffic routing layer for your application.
- The security group allows inbound HTTP on port 80 from anywhere, so users can reach your API.
- The ALB spans both public subnets, giving it high availability across two availability zones.
- The target group defines port 8080 (your Flask app's port) and checks /health every 15 seconds. Three consecutive failures mark a target as unhealthy.
- The listener connects port 80 on the ALB to the target group, completing the routing chain.
- Save alb.tf.
Why a 30-second deregistration delay?
During a rolling deployment, old tasks are removed and new tasks take their place. The deregistration delay gives in-flight requests time to complete before the ALB stops sending traffic to a draining target. 30 seconds is fast enough for a learning project while still being safe.
✔️ Awesome, I've got everything!
Great. Double check you have saved alb.tf in your project directory.
ⓧ I'd like to double check the full code
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for ALB"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-alb-sg"
}
}
resource "aws_lb" "main" {
name = "${var.project_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_1.id, aws_subnet.public_2.id]
tags = {
Name = "${var.project_name}-alb"
}
}
resource "aws_lb_target_group" "app" {
name = "${var.project_name}-tg"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/health"
protocol = "HTTP"
healthy_threshold = 2
unhealthy_threshold = 3
interval = 15
timeout = 5
matcher = "200"
}
deregistration_delay = 30
tags = {
Name = "${var.project_name}-tg"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
Create the ECS cluster and task infrastructure
ECS Fargate runs your containers without requiring you to manage servers. You need a cluster to group your services, a security group to control network access, and a log group to capture container output.
- Create a new file called ecs.tf in your project directory and paste the following code:
resource "aws_ecs_cluster" "main" {
name = "${var.project_name}-cluster"
tags = {
Name = "${var.project_name}-cluster"
}
}
# Only allow traffic from the ALB on port 8080
resource "aws_security_group" "ecs" {
name = "${var.project_name}-ecs-sg"
description = "Security group for ECS tasks"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8080
to_port = 8080
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"
}
}
# Container logs stored here for debugging
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${var.project_name}"
retention_in_days = 7
tags = {
Name = "${var.project_name}-logs"
}
}
What does this code do?
- The ECS cluster is a logical grouping for your Fargate services.
- The ECS security group only allows inbound traffic on port 8080 from the ALB security group. No other traffic can reach your containers directly.
- The CloudWatch log group stores container stdout/stderr with a 7-day retention period, keeping costs minimal.
Now add the task definition that describes your container's configuration.
- Below the log group resource in ecs.tf, add the following task definition:
resource "aws_ecs_task_definition" "app" {
family = "${var.project_name}-task"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_execution.arn
container_definitions = jsonencode([
{
name = "app"
image = "${aws_ecr_repository.app.repository_url}:latest"
essential = true
portMappings = [
{
containerPort = 8080
protocol = "tcp"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "ecs"
}
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval = 15
timeout = 5
retries = 3
startPeriod = 60
}
}
])
tags = {
Name = "${var.project_name}-task"
}
}
What does the task definition do?
- The cpu = "256" and memory = "512" set the smallest Fargate configuration (0.25 vCPU, 0.5 GB RAM) to minimize costs.
- The container pulls the latest image from your ECR repository and exposes port 8080.
- The healthCheck block uses curl to hit /health inside the container every 15 seconds. If it fails 3 times, ECS marks the task as unhealthy.
- The startPeriod = 60 gives the container 60 seconds to boot before health checks start counting failures.
Finally, add the ECS service. This is where you configure the rolling deployment strategy and circuit breaker.
- Below the task definition in ecs.tf, add the ECS service resource:
resource "aws_ecs_service" "app" {
name = "${var.project_name}-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 2
launch_type = "FARGATE"
# Rolling deployment: keep all existing tasks running while deploying new ones
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
health_check_grace_period_seconds = 60
# Circuit breaker: auto-rollback if new tasks fail to stabilize
deployment_circuit_breaker {
enable = true
rollback = true
}
# CloudWatch alarm integration: rollback if app-level health degrades
alarms {
alarm_names = [aws_cloudwatch_metric_alarm.unhealthy_hosts.alarm_name]
enable = true
rollback = true
}
network_configuration {
subnets = [aws_subnet.public_1.id, aws_subnet.public_2.id]
security_groups = [aws_security_group.ecs.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "app"
container_port = 8080
}
depends_on = [aws_lb_listener.http]
# Pipeline updates the task definition directly, so ignore drift
lifecycle {
ignore_changes = [task_definition]
}
tags = {
Name = "${var.project_name}-service"
}
}
How does the rolling deployment with circuit breaker work?
This is the core of your automatic rollback setup. Two mechanisms work together to protect your service.
- With deployment_minimum_healthy_percent = 100 and deployment_maximum_percent = 200, ECS keeps all 2 existing tasks running while launching 2 new tasks alongside them. Traffic only shifts once new tasks pass health checks.
- The deployment_circuit_breaker monitors whether new tasks reach a steady state. If tasks keep failing to start or pass health checks, ECS stops launching more and rolls back to the last working deployment.
- The alarms block adds a second safety layer. If the CloudWatch alarm detects unhealthy targets in the ALB, ECS treats the deployment as failed and rolls back. You will create this alarm in the next step.
If you use the deployment circuit breaker and the CloudWatch alarms to detect failures, either one can initiate a deployment failure.
- Save ecs.tf.
Forward references are fine in Terraform
The alarms block references aws_cloudwatch_metric_alarm.unhealthy_hosts which you have not created yet. Terraform resolves all references at terraform apply time, not when you write the file. You will create cloudwatch.tf in the next step.
Confused by the lifecycle block?
The ignore_changes = [task_definition] line tells Terraform not to revert the task definition on subsequent terraform apply runs. Without this, Terraform would reset your ECS service back to the original task definition every time you run it, undoing deployments made by CodePipeline.
help me understand the lifecycle ignore_changes block in my ECS Terraform configuration
✔️ Awesome, I've got everything!
Great. Double check you have saved ecs.tf with all three resource blocks.
ⓧ I'd like to double check the full code
resource "aws_ecs_cluster" "main" {
name = "${var.project_name}-cluster"
tags = {
Name = "${var.project_name}-cluster"
}
}
resource "aws_security_group" "ecs" {
name = "${var.project_name}-ecs-sg"
description = "Security group for ECS tasks"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8080
to_port = 8080
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"
}
}
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${var.project_name}"
retention_in_days = 7
tags = {
Name = "${var.project_name}-logs"
}
}
resource "aws_ecs_task_definition" "app" {
family = "${var.project_name}-task"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_execution.arn
container_definitions = jsonencode([
{
name = "app"
image = "${aws_ecr_repository.app.repository_url}:latest"
essential = true
portMappings = [
{
containerPort = 8080
protocol = "tcp"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "ecs"
}
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval = 15
timeout = 5
retries = 3
startPeriod = 60
}
}
])
tags = {
Name = "${var.project_name}-task"
}
}
resource "aws_ecs_service" "app" {
name = "${var.project_name}-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 2
launch_type = "FARGATE"
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
health_check_grace_period_seconds = 60
deployment_circuit_breaker {
enable = true
rollback = true
}
alarms {
alarm_names = [aws_cloudwatch_metric_alarm.unhealthy_hosts.alarm_name]
enable = true
rollback = true
}
network_configuration {
subnets = [aws_subnet.public_1.id, aws_subnet.public_2.id]
security_groups = [aws_security_group.ecs.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "app"
container_port = 8080
}
depends_on = [aws_lb_listener.http]
lifecycle {
ignore_changes = [task_definition]
}
tags = {
Name = "${var.project_name}-service"
}
}
Create the IAM roles and policies
Every AWS service in your pipeline needs explicit permission to interact with other services. You need three roles: one for ECS to pull container images, one for CodeBuild to push images to ECR, and one for CodePipeline to orchestrate the entire flow.
- Create a new file called iam.tf in your project directory and paste the following code:
# ECS needs permission to pull images from ECR and write logs
resource "aws_iam_role" "ecs_execution" {
name = "${var.project_name}-ecs-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.project_name}-ecs-execution-role"
}
}
resource "aws_iam_role_policy_attachment" "ecs_execution" {
role = aws_iam_role.ecs_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# CodeBuild needs permission to push images to ECR and read/write artifacts
resource "aws_iam_role" "codebuild" {
name = "${var.project_name}-codebuild-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "codebuild.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.project_name}-codebuild-role"
}
}
resource "aws_iam_role_policy" "codebuild" {
name = "${var.project_name}-codebuild-policy"
role = aws_iam_role.codebuild.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:CompleteLayerUpload",
"ecr:GetAuthorizationToken",
"ecr:InitiateLayerUpload",
"ecr:PutImage",
"ecr:UploadLayerPart"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject"
]
Resource = "${aws_s3_bucket.pipeline_artifacts.arn}/*"
}
]
})
}
# CodePipeline needs permission to orchestrate all stages
resource "aws_iam_role" "codepipeline" {
name = "${var.project_name}-codepipeline-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "codepipeline.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.project_name}-codepipeline-role"
}
}
resource "aws_iam_role_policy" "codepipeline" {
name = "${var.project_name}-codepipeline-policy"
role = aws_iam_role.codepipeline.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:GetBucketVersioning",
"s3:PutObject"
]
Resource = [
aws_s3_bucket.pipeline_artifacts.arn,
"${aws_s3_bucket.pipeline_artifacts.arn}/*"
]
},
{
Effect = "Allow"
Action = [
"codestar-connections:UseConnection"
]
Resource = aws_codestarconnections_connection.github.arn
},
{
Effect = "Allow"
Action = [
"codebuild:BatchGetBuilds",
"codebuild:StartBuild"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:DescribeTasks",
"ecs:ListTasks",
"ecs:RegisterTaskDefinition",
"ecs:UpdateService"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"iam:PassRole"
]
Resource = aws_iam_role.ecs_execution.arn
}
]
})
}
What do these roles do?
- The ECS execution role uses the managed AmazonECSTaskExecutionRolePolicy policy. This gives ECS permission to pull images from ECR and push logs to CloudWatch.
- The CodeBuild role allows pushing Docker images to ECR, writing build logs, and reading/writing pipeline artifacts from S3.
- The CodePipeline role grants permission to use the GitHub connection, trigger CodeBuild, update ECS services, and pass the execution role to new task definitions.
The S3 bucket and CodeStar connection references in this file point to resources you will create in the next step. Terraform resolves them all at apply time.
- Save iam.tf.
- Confirm all three new files exist by running:
ls *.tf
You should see alb.tf, ecr.tf, ecs.tf, iam.tf, network.tf, providers.tf, and variables.tf listed in the output.
Missing a file?
Make sure you created each file in the same directory as your existing providers.tf and network.tf. All .tf files must live in the same folder for Terraform to process them as one configuration.
help me figure out why my Terraform files are not in the right directory
✔️ Awesome, I've got everything!
Great. Double check you have saved iam.tf in your project directory alongside the other Terraform files.
ⓧ I'd like to double check the full code
resource "aws_iam_role" "ecs_execution" {
name = "${var.project_name}-ecs-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.project_name}-ecs-execution-role"
}
}
resource "aws_iam_role_policy_attachment" "ecs_execution" {
role = aws_iam_role.ecs_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role" "codebuild" {
name = "${var.project_name}-codebuild-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "codebuild.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.project_name}-codebuild-role"
}
}
resource "aws_iam_role_policy" "codebuild" {
name = "${var.project_name}-codebuild-policy"
role = aws_iam_role.codebuild.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:CompleteLayerUpload",
"ecr:GetAuthorizationToken",
"ecr:InitiateLayerUpload",
"ecr:PutImage",
"ecr:UploadLayerPart"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject"
]
Resource = "${aws_s3_bucket.pipeline_artifacts.arn}/*"
}
]
})
}
resource "aws_iam_role" "codepipeline" {
name = "${var.project_name}-codepipeline-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "codepipeline.amazonaws.com"
}
}
]
})
tags = {
Name = "${var.project_name}-codepipeline-role"
}
}
resource "aws_iam_role_policy" "codepipeline" {
name = "${var.project_name}-codepipeline-policy"
role = aws_iam_role.codepipeline.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:GetBucketVersioning",
"s3:PutObject"
]
Resource = [
aws_s3_bucket.pipeline_artifacts.arn,
"${aws_s3_bucket.pipeline_artifacts.arn}/*"
]
},
{
Effect = "Allow"
Action = [
"codestar-connections:UseConnection"
]
Resource = aws_codestarconnections_connection.github.arn
},
{
Effect = "Allow"
Action = [
"codebuild:BatchGetBuilds",
"codebuild:StartBuild"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:DescribeTasks",
"ecs:ListTasks",
"ecs:RegisterTaskDefinition",
"ecs:UpdateService"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"iam:PassRole"
]
Resource = aws_iam_role.ecs_execution.arn
}
]
})
}
Your compute layer, load balancer, and permissions are defined. In the next step, you will add the CI/CD pipeline resources and the CloudWatch alarm that ties everything together.
Write Terraform for the CI/CD Pipeline
In the previous step, you defined the ECS cluster, load balancer, and IAM roles that will run your containers. Now you need the automation layer that ties everything together.
In this step, you will create the CI/CD pipeline infrastructure. CodeBuild will compile your Docker image, CodePipeline will orchestrate the Source, Build, and Deploy stages, and a CloudWatch alarm will monitor deployment health so ECS knows when to roll back.
In this step, get ready to:
- Create a CodeBuild project for Docker image builds.
- Create a CodePipeline with Source, Build, and Deploy stages.
- Create a CloudWatch alarm to monitor deployment health.
Create the CodeBuild project
CodeBuild needs a Linux environment with Docker support to build your container images. It also needs environment variables so the buildspec.yml can log into ECR and push the built image.
- Create a new file called codebuild.tf in your project folder and paste the following code:
resource "aws_codebuild_project" "app" {
name = "${var.project_name}-build"
description = "Build Docker image and push to ECR"
build_timeout = 10
service_role = aws_iam_role.codebuild.arn
artifacts {
type = "CODEPIPELINE"
}
environment {
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0"
type = "LINUX_CONTAINER"
privileged_mode = true
image_pull_credentials_type = "CODEBUILD"
environment_variable {
name = "AWS_DEFAULT_REGION"
value = var.aws_region
}
environment_variable {
name = "AWS_ACCOUNT_ID"
value = data.aws_caller_identity.current.account_id
}
environment_variable {
name = "IMAGE_REPO_NAME"
value = aws_ecr_repository.app.name
}
}
source {
type = "CODEPIPELINE"
}
tags = {
Name = "${var.project_name}-build"
}
}
data "aws_caller_identity" "current" {}
What does this code do?
- privileged_mode = true gives the build container access to the Docker daemon, which is required for building Docker images inside CodeBuild.
- The three environment variables (AWS_DEFAULT_REGION, AWS_ACCOUNT_ID, IMAGE_REPO_NAME) are the same ones your buildspec.yml references to log into ECR and push the image.
- Both artifacts and source use type CODEPIPELINE because CodePipeline will feed source code in and collect the imagedefinitions.json artifact out.
- data "aws_caller_identity" "current" {} fetches your AWS account ID at plan time so resources can reference it dynamically.
- Save codebuild.tf.
Not sure about the image name?
The image aws/codebuild/amazonlinux2-x86_64-standard:5.0 is an AWS-managed build image that includes Docker. Make sure you typed it exactly as shown, including the version tag.
Help me understand the CodeBuild environment settings
Create the pipeline infrastructure
CodePipeline needs supporting resources before you can define the pipeline itself. It requires an S3 bucket to store artifacts between stages and a CodeStar Connection to authenticate with GitHub.
- Create a new file called codepipeline.tf and paste the supporting resources first:
resource "aws_s3_bucket" "pipeline_artifacts" {
bucket = "${var.project_name}-artifacts-${data.aws_caller_identity.current.account_id}"
force_destroy = true
tags = {
Name = "${var.project_name}-artifacts"
}
}
resource "aws_s3_bucket_public_access_block" "pipeline_artifacts" {
bucket = aws_s3_bucket.pipeline_artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_codestarconnections_connection" "github" {
name = "${var.project_name}-github"
provider_type = "GitHub"
tags = {
Name = "${var.project_name}-github-connection"
}
}
What does this code do?
- The S3 bucket stores pipeline artifacts (source code zip, build outputs) as they pass between stages. The bucket name includes your account ID to ensure global uniqueness.
- The public access block locks down the bucket so artifacts are never accidentally exposed.
- The CodeStar Connection handles OAuth authentication with GitHub. Terraform creates it in a PENDING state. You will complete the authorization manually in the next step.
- Now add the CodePipeline resource below the CodeStar connection in the same file:
resource "aws_codepipeline" "app" {
name = "${var.project_name}-pipeline"
role_arn = aws_iam_role.codepipeline.arn
artifact_store {
location = aws_s3_bucket.pipeline_artifacts.id
type = "S3"
}
stage {
name = "Source"
action {
name = "Source"
category = "Source"
owner = "AWS"
provider = "CodeStarSourceConnection"
version = "1"
output_artifacts = ["source_output"]
configuration = {
ConnectionArn = aws_codestarconnections_connection.github.arn
FullRepositoryId = var.github_repo
BranchName = var.github_branch
}
}
}
stage {
name = "Build"
action {
name = "Build"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
version = "1"
input_artifacts = ["source_output"]
output_artifacts = ["build_output"]
configuration = {
ProjectName = aws_codebuild_project.app.name
}
}
}
stage {
name = "Deploy"
action {
name = "Deploy"
category = "Deploy"
owner = "AWS"
provider = "ECS"
version = "1"
input_artifacts = ["build_output"]
configuration = {
ClusterName = aws_ecs_cluster.main.name
ServiceName = aws_ecs_service.app.name
FileName = "imagedefinitions.json"
}
}
}
tags = {
Name = "${var.project_name}-pipeline"
}
}
What does this code do?
This defines a V1 CodePipeline with three stages that run in sequence:
- Source pulls your code from GitHub using the CodeStar Connection. It outputs a source_output artifact.
- Build takes source_output, runs your buildspec.yml in CodeBuild, and produces a build_output artifact containing imagedefinitions.json.
- Deploy reads imagedefinitions.json from the build output and tells ECS to update the service with the new image.
- Save codepipeline.tf.
Seeing a reference to an undeclared resource?
The pipeline references resources in other files (aws_iam_role.codepipeline, aws_ecs_cluster.main, etc.). These already exist in your iam.tf and ecs.tf files from the previous step. If you see errors, make sure those files are saved in the same directory.
Help me debug a Terraform reference error in my pipeline file
✔️ Awesome, I've got everything!
Great. Double check you have saved codepipeline.tf before moving on.
ⓧ I'd like to double check the full code
resource "aws_s3_bucket" "pipeline_artifacts" {
bucket = "${var.project_name}-artifacts-${data.aws_caller_identity.current.account_id}"
force_destroy = true
tags = {
Name = "${var.project_name}-artifacts"
}
}
resource "aws_s3_bucket_public_access_block" "pipeline_artifacts" {
bucket = aws_s3_bucket.pipeline_artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_codestarconnections_connection" "github" {
name = "${var.project_name}-github"
provider_type = "GitHub"
tags = {
Name = "${var.project_name}-github-connection"
}
}
resource "aws_codepipeline" "app" {
name = "${var.project_name}-pipeline"
role_arn = aws_iam_role.codepipeline.arn
artifact_store {
location = aws_s3_bucket.pipeline_artifacts.id
type = "S3"
}
stage {
name = "Source"
action {
name = "Source"
category = "Source"
owner = "AWS"
provider = "CodeStarSourceConnection"
version = "1"
output_artifacts = ["source_output"]
configuration = {
ConnectionArn = aws_codestarconnections_connection.github.arn
FullRepositoryId = var.github_repo
BranchName = var.github_branch
}
}
}
stage {
name = "Build"
action {
name = "Build"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
version = "1"
input_artifacts = ["source_output"]
output_artifacts = ["build_output"]
configuration = {
ProjectName = aws_codebuild_project.app.name
}
}
}
stage {
name = "Deploy"
action {
name = "Deploy"
category = "Deploy"
owner = "AWS"
provider = "ECS"
version = "1"
input_artifacts = ["build_output"]
configuration = {
ClusterName = aws_ecs_cluster.main.name
ServiceName = aws_ecs_service.app.name
FileName = "imagedefinitions.json"
}
}
}
tags = {
Name = "${var.project_name}-pipeline"
}
}
Create the CloudWatch alarm and outputs
Your ECS service already references a CloudWatch alarm in its alarms block. Now you need to create that alarm so Terraform can resolve the reference. The alarm watches the ALB target group for unhealthy hosts.
- Create a new file called cloudwatch.tf and paste the following:
resource "aws_cloudwatch_metric_alarm" "unhealthy_hosts" {
alarm_name = "${var.project_name}-unhealthy-hosts"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "UnHealthyHostCount"
namespace = "AWS/ApplicationELB"
period = 60
statistic = "Average"
threshold = 0
alarm_description = "Alarm when unhealthy hosts detected in target group"
treat_missing_data = "notBreaching"
dimensions = {
TargetGroup = aws_lb_target_group.app.arn_suffix
LoadBalancer = aws_lb.main.arn_suffix
}
tags = {
Name = "${var.project_name}-unhealthy-hosts-alarm"
}
}
What does this alarm do?
- It monitors the UnHealthyHostCount metric from your ALB target group. If any task fails health checks, this count goes above 0.
- evaluation_periods = 2 and period = 60 means the alarm triggers after 2 consecutive minutes of unhealthy hosts.
- treat_missing_data = "notBreaching" prevents false alarms when no data is reported (for example, during initial setup before tasks register).
- When this alarm fires during a deployment, ECS detects it via the alarms block you configured in ecs.tf and automatically rolls back to the previous working version.
- Save cloudwatch.tf.
Finally, create an outputs file so Terraform prints useful resource identifiers after deployment.
- Create a new file called outputs.tf and paste the following:
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
output "ecr_repository_url" {
description = "URL of the ECR repository"
value = aws_ecr_repository.app.repository_url
}
output "pipeline_name" {
description = "Name of the CodePipeline"
value = aws_codepipeline.app.name
}
output "ecs_cluster_name" {
description = "Name of the ECS cluster"
value = aws_ecs_cluster.main.name
}
output "ecs_service_name" {
description = "Name of the ECS service"
value = aws_ecs_service.app.name
}
What does this code do?
Outputs print key values to your terminal after terraform apply completes. The alb_dns_name output is especially important because that is the URL you will use to access your running application.
- Save outputs.tf.
- Confirm all your Terraform files exist by running:
ls *.tf
- You should see alb.tf, cloudwatch.tf, codebuild.tf, codepipeline.tf, ecr.tf, ecs.tf, iam.tf, network.tf, outputs.tf, providers.tf, and variables.tf listed in the output.
Missing a file?
Make sure you are in the correct project directory. All .tf files must live in the same folder for Terraform to see them as one configuration.
Help me figure out why a Terraform file is missing from my project
Your entire Terraform configuration is now complete. You have networking, compute, a CI/CD pipeline, and deployment health monitoring all defined as code. Next up, you will deploy everything to AWS and connect your GitHub repository.
Deploy Infrastructure and Connect GitHub
Your Terraform configuration is complete. Every resource your pipeline needs is defined across your .tf files.
Now it is time to bring it all to life. In this step, you will provision the entire infrastructure with a single command, authorize the GitHub connection so CodePipeline can pull your code, and push your app to trigger the first automated deployment.
In this step, get ready to:
- Deploy all AWS resources with terraform apply.
- Authorize the CodeStar Connection to GitHub.
- Push code and verify the full pipeline runs end to end.
Run terraform init and terraform apply
Before Terraform can create resources, it needs to download the AWS provider plugin. After that, a single terraform apply provisions everything.
Terraform also needs the name of your GitHub repository. You will create one now so you can pass it in during the apply.
- Open GitHub in your browser and create a new empty repository (no README, no .gitignore).
- Name it rolling-deploy-demo or any name you prefer.
- Note the full repository path in the format your-username/rolling-deploy-demo.
Why create the repo now?
The github_repo variable in variables.tf has no default value. Terraform will ask you for it during terraform apply. Having the repo ready means you can provide the exact path right away.
- In your terminal, make sure you are inside your project directory (the folder containing your .tf files).
- Initialize Terraform by running this command:
terraform init
What does this command do?
Terraform downloads the AWS provider plugin (specified as ~> 5.0 in providers.tf) and sets up local state tracking. You should see the message "Terraform has been successfully initialized!" when it finishes.
- Deploy all resources by running:
terraform apply
- When Terraform prompts for var.github_repo, enter your repository path in the format your-username/rolling-deploy-demo.
- Review the plan output showing all the resources Terraform will create.
- Type yes when prompted to confirm.
What should I see?
Terraform will show a plan with approximately 25-30 resources to create. The apply takes 3-5 minutes. At the end, you will see the outputs including alb_dns_name, ecr_repository_url, and pipeline_name.
The CodeStar connection will be in PENDING status. This is expected. You will complete the authorization in the next substep.
- Copy the alb_dns_name output value and save it somewhere. You will need it later to verify your app.
Seeing an error during apply?
If you see an error about invalid credentials, confirm that aws configure was completed in Step 1 and your region is set to us-east-1.
If Terraform complains about a missing variable, make sure you entered the github_repo value in the format owner/repo (not the full URL).
Help me debug my terraform apply error
Complete the GitHub authorization for the CodeStar Connection
Terraform created the CodeStar Connection, but it cannot complete the OAuth handshake automatically. You need to authorize it manually in the AWS Console. This is a one-time step.
- Open the AWS Developer Tools console at https://console.aws.amazon.com/codesuite/settings/connections.
- You should see a connection named rolling-deploy-github with status Pending.
- Click on the connection name to select it.
- Click Update pending connection.
- A window opens asking you to authorize the AWS Connector for GitHub. Click Install a new app if this is your first time, or select an existing installation.
- Authorize AWS to access your GitHub account. Select the repositories you want to grant access to (or choose all repositories).
- Back in the AWS console, you should see the connection ID appear. Click Connect.
What is a CodeStar Connection?
A CodeStar Connection is an AWS resource that links your GitHub account to AWS services like CodePipeline. Once authorized, CodePipeline can detect pushes to your repository and trigger builds automatically.
- Verify the connection status is now Available in the connections list.
Connection still showing Pending?
Make sure you completed the GitHub authorization step and clicked Connect at the end. If the page refreshed without changing status, try selecting the connection again and repeating the update flow.
Help me fix my CodeStar connection
Push code to GitHub and verify the pipeline
With the connection authorized, CodePipeline is ready to react to pushes. Now you will push your application code to the GitHub repository and watch the full pipeline execute.
- Add the GitHub remote to your local Git repository by running:
git remote add origin https://github.com/[[GITHUB_REPO="your-username/rolling-deploy-demo"]].git
- Stage all files, commit, and push to GitHub by running these commands:
git add .
git commit -m "Initial deployment"
git push -u origin main
What happens after pushing?
CodePipeline detects the push via the CodeStar Connection and triggers automatically. It pulls your source code, sends it to CodeBuild which builds the Docker image and pushes it to ECR, then deploys the new image to your ECS service using the rolling update strategy.
- Open the CodePipeline console at https://console.aws.amazon.com/codesuite/codepipeline/pipelines.
- Click on rolling-deploy-pipeline to watch the stages progress.
- Wait for all three stages (Source, Build, Deploy) to show a green checkmark. The full pipeline typically takes 4-6 minutes.
Pipeline stuck on Source stage?
If the Source stage fails, check that your CodeStar Connection status is Available (not Pending). Also confirm that the repository name you passed to terraform apply matches the actual GitHub repository you pushed to.
If the Build stage fails, click on the Details link to view the CodeBuild logs. Common issues include Docker build errors from a missing requirements.txt or Dockerfile in your repository root.
Help me debug my CodePipeline failure
- Once all stages are green, verify your app is running by opening your ALB DNS URL in a browser. Paste the alb_dns_name value you saved earlier into your browser address bar (using http://, not https).
- You should see a JSON response like:
{"service":"rolling-deploy-demo","version":"1.0.0","status":"running"}
- You can also verify the health endpoint by adding /health to the URL. It should return {"status":"healthy"}.
What is running in ECS right now?
Your ECS service is running 2 Fargate tasks behind the Application Load Balancer. Each task runs your Flask app container on port 8080. The ALB distributes traffic across both tasks and uses the /health endpoint to check that each task is healthy.
Your pipeline is live. Every push to GitHub now triggers an automated build and deployment to ECS. Next up, you will test what happens when a deployment goes wrong and watch the automatic rollback in action.
Test Rolling Deployment and Automatic Rollback
Your pipeline is deployed and serving traffic. But how do you know the safety net actually works?
In this step, you will intentionally deploy a broken version of your app and watch the deployment circuit breaker detect the failure and automatically roll back to the last healthy version. This is the same chaos-engineering approach that production teams use to validate their rollback mechanisms.
In this step, get ready to:
- Deploy a broken health endpoint to trigger the circuit breaker.
- Monitor the ECS service as it detects the failure and rolls back.
- Confirm the rollback succeeded and restore the healthy code.
Break the health endpoint and push to GitHub
The ALB health check hits /health every 15 seconds and expects a 200 response. If your app returns anything else, the target is marked unhealthy. You are going to exploit this by making the health endpoint return a 500 error.
- Open app.py in your text editor.
- Find the health function. It currently looks like this:
@app.route("/health")
def health():
return jsonify({"status": "healthy"}), 200
- Replace the return line so the function returns a 500 status code instead:
@app.route("/health")
def health():
return jsonify({"status": "unhealthy"}), 500
Why 500?
A 500 Internal Server Error tells the ALB that something is fundamentally wrong with the application. The target group health check requires a 200 response (configured via the matcher in your Terraform). Any other status code marks the target as unhealthy after 3 consecutive failures.
- Save app.py.
- Commit and push the broken code to GitHub by running these commands:
git add app.py
git commit -m "Break health endpoint to test rollback"
git push
- You should see the push complete successfully with output showing objects written to your remote repository.
Push rejected or authentication error?
Make sure you are on the main branch (or whichever branch your pipeline tracks). Run git branch to confirm.
If you see a credential error, re-authenticate with GitHub using your personal access token or SSH key.
Help me debug my git push issue.
Monitor the failed deployment in the AWS Console
The push triggers your pipeline automatically. You can now watch the entire failure-and-rollback sequence unfold in real time across multiple AWS services.
- Open the AWS Console and navigate to CodePipeline.
- Click on your pipeline (rolling-deploy-pipeline).
- Watch the Source stage detect your push and pass the code to the Build stage.
- Wait for the Build stage to complete. This takes 2-3 minutes as CodeBuild builds and pushes the broken Docker image to ECR.
- Once the Deploy stage begins, navigate to Amazon ECS in the console.
- Click on your cluster (rolling-deploy-cluster).
- Click on the service (rolling-deploy-service).
- Select the Events tab to watch the deployment progress.
What happens during a rolling deployment failure?
ECS launches new tasks with the broken image alongside your existing healthy tasks. Because deployment_minimum_healthy_percent is set to 100, ECS never removes the old tasks until new ones pass health checks.
The new tasks start up and begin failing the ALB health check (returning 500 instead of 200). After 3 consecutive failures (unhealthy_threshold = 3 at 15-second intervals), the ALB marks them unhealthy.
The circuit breaker monitors these launch attempts. For 2 desired tasks, ECS allows up to 4 launch attempts before marking the deployment as FAILED and triggering the rollback.
- In the Events tab, you should see messages about new tasks being started and then tasks failing health checks.
- Wait 3-5 minutes for the circuit breaker to activate. You will see an event indicating the deployment has failed and is rolling back.
Check the Deployments tab too
While waiting, click the Deployments and events tab on your ECS service. You will see two deployments listed: the PRIMARY (old healthy version) and the ACTIVE (the new broken one attempting to roll out). Once rollback triggers, the ACTIVE deployment disappears.
Pipeline not triggering?
Confirm your CodeStar connection status is Available (not Pending). Navigate to Developer Tools > Settings > Connections in the AWS Console to check.
Also verify you pushed to the correct branch. Your pipeline is configured to track the branch defined in your github_branch variable (default: main).
Help me figure out why my CodePipeline is not triggering after a push.
Verify the rollback and restore healthy code
Once the circuit breaker rolls back, your service should be running the previous healthy task definition. The old tasks were never removed, so your app kept serving traffic throughout the entire failed deployment.
- In the ECS service Deployments and events tab, confirm only one deployment is listed (the healthy PRIMARY) with 2 running tasks.
- Open a new browser tab and visit your ALB DNS URL to confirm it still returns a healthy response:
curl http://[[ALB_DNS="your ALB DNS name from terraform output"]]
- You should see the JSON response with "status": "running" from your root endpoint. The app is alive and well despite the failed deployment.
Why is the app still healthy?
This is the power of deployment_minimum_healthy_percent = 100. ECS guarantees that 100% of your desired tasks remain healthy at all times during a rolling update. The broken new tasks were launched alongside the old ones, but the old ones were never terminated.
Combined with the circuit breaker, ECS detected the failure and stopped trying to replace healthy tasks with broken ones. Zero downtime, zero manual intervention.
- Navigate to CloudWatch > Alarms in the AWS Console.
- Find your alarm (rolling-deploy-unhealthy-hosts). It should show a recent state change to In alarm (or have recently returned to OK after the rollback completed).
How the CloudWatch alarm and circuit breaker work together
Your ECS service has two rollback triggers. The deployment circuit breaker catches tasks that fail to start or pass health checks. The CloudWatch alarm catches application-level issues where tasks are running but returning errors.
If you use the deployment circuit breaker and the Amazon CloudWatch alarms to detect failures, either one can initiate a deployment failure. This dual-layer approach is what production teams rely on for comprehensive deployment safety.
Now restore the healthy code so your pipeline returns to a clean state.
- Open app.py and find the broken health function:
@app.route("/health")
def health():
return jsonify({"status": "unhealthy"}), 500
- Change it back to return a 200 status with the healthy message:
@app.route("/health")
def health():
return jsonify({"status": "healthy"}), 200
- Save app.py.
- Commit and push the fix by running these commands:
git add app.py
git commit -m "Restore healthy endpoint after rollback test"
git push
- Wait for the pipeline to complete again (2-3 minutes for build, then deploy).
- Verify in the ECS console that the service now shows a successful deployment with 2 healthy running tasks.
Rollback not happening?
The circuit breaker can take 3-5 minutes to detect enough failures. Check that your health_check_grace_period_seconds is set to 60 in your ECS service configuration. If it is too high, ECS waits longer before counting health check failures.
Also verify that the broken image actually deployed. In ECR, check that a new image tag appeared after the build. If CodeBuild failed, the broken image never reached ECS.
Help me understand why the ECS circuit breaker is not triggering a rollback.
You just proved that your infrastructure handles failure gracefully. A broken deployment rolled out, health checks caught it, the circuit breaker fired, and ECS reverted to the last good version. All without a single second of downtime. That is production-grade deployment safety.
Secret mission
Build a CloudWatch Deployment Health Dashboard
SRE teams don't watch deployments with their fingers crossed. They build dashboards that show health metrics in real time. In this secret mission, you'll create a CloudWatch dashboard with Terraform that visualizes target group health, 5XX errors, task counts, and alarm status. Then you'll trigger a broken deployment and watch the dashboard light up.
Clean Up Your Resources
Clean Up Your Resources
This project provisions AWS resources that charge per-second or per-hour while running. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Cost warning
ECS Fargate tasks and the Application Load Balancer charge continuously while provisioned. Leaving resources running overnight or over a weekend can accumulate $1-3 in charges.
If you are done experimenting, choose Pause or Delete below to stop charges immediately.
Resources you used:
- ECS Fargate cluster and service (charged per-second for vCPU and memory).
- Application Load Balancer (approximately $0.02/hour while provisioned).
- ECR repository (minimal storage costs for Docker images).
- CodePipeline V1 pipeline (free for 1 active pipeline per month).
- CodeBuild project (free for 100 build minutes per month on general1.small).
- S3 artifact bucket.
- VPC, subnets, internet gateway, and route tables.
- CloudWatch alarm and log group.
- IAM roles and policies.
- CodeStar Connection to GitHub.
- Local project files on your Desktop.
- GitHub repository.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with deployments.
- Your ECS Fargate tasks, ALB, and other resources will continue running and charging.
- Expect approximately $1-2/day in charges while everything is provisioned.
- AWS CLI, Docker Desktop, and Git remain installed on your machine for other projects.
Pause - I'll come back to this later
Shut down running tasks to reduce costs while keeping your infrastructure in place. You can scale back up later with a single Terraform apply.
- Open ecs.tf in Notepad++.
- In the aws_ecs_service resource, change desired_count = 2 to desired_count = 0.
- Save the file and run the following command to apply the change:
terraform apply -auto-approve
What stays running?
The ALB and VPC resources remain provisioned and will still incur small charges (approximately $0.02/hour for the ALB). To stop all charges completely, use the Delete tab instead.
- To resume later, change desired_count back to 2 and run terraform apply again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This stops all AWS charges immediately.
- Navigate to your project directory in the terminal and run the following command to destroy all AWS resources:
terraform destroy
- Terraform will show you a plan of everything it will delete. Type yes and press Enter to confirm.
- Wait for the destroy to complete. This may take 3-5 minutes.
Verify in the AWS Console
After the destroy completes, open the AWS Console and check that no resources remain. Look for the ECR repository, S3 artifact bucket, CloudWatch alarms, ECS cluster, and ALB. All should be gone.
- Delete your GitHub repository by going to your repository page, clicking Settings, scrolling to the bottom, and clicking Delete this repository.
- Delete the local project files by running the following commands:
macOS
cd ~/Desktop
rm -rf rolling-deploy-pipeline
Windows
cd ~\Desktop
rmdir /s /q rolling-deploy-pipeline
Optionally, uninstall the tools installed in Step 1 if you no longer need them for other projects.
- Uninstall AWS CLI (macOS):
sudo rm -rf /usr/local/aws-cli
sudo rm /usr/local/bin/aws
- Uninstall AWS CLI (Windows): Open Settings, select Apps, then Apps & features. Find AWS Command Line Interface and click Uninstall.
- Uninstall Docker Desktop (macOS): Open Docker Desktop, click the Troubleshoot icon in the top-right, then select Uninstall.
- Uninstall Docker Desktop (Windows): Open Settings, select Apps, then Apps & features. Find Docker Desktop and click Uninstall.
Nice Work!
Nice Work!
You just built a production-grade CI/CD pipeline with automatic rollback on AWS. That is a serious DevOps skill that cloud engineers use daily to ship code safely to production.
You've learned how to:
- Provision a full AWS infrastructure with Terraform including a VPC, Application Load Balancer, ECS Fargate, ECR, CodePipeline, CodeBuild, and CloudWatch.
- Configure ECS rolling deployments with a deployment circuit breaker and CloudWatch alarm-based monitoring for dual-layer automatic rollback.
- Prove the rollback works by deploying a broken version and watching ECS automatically revert to the last healthy deployment.
- Build a CloudWatch dashboard for real-time deployment health monitoring.
Ready to quiz yourself?