Deploy a Containerized App with CI/CD

Build a CI/CD pipeline that auto-deploys a Docker app to AWS ECS Fargate.

Introduction

30 Second Summary

Every time a developer finishes writing code, that code needs to travel from their laptop to a server on the internet where real users can access it. Doing this manually is slow, error-prone, and a bottleneck that most companies solve with automation.

In this project, you will build a fully automated pipeline that takes a Python web app, packages it into a Docker container, and deploys it to AWS every time you push code to GitHub.

What You'll Build

A complete DevOps pipeline that containerizes a Flask web application, stores the image in Amazon ECR, runs it on ECS Fargate, and automates the entire build-push-deploy cycle with GitHub Actions.

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

  • A Dockerized Python Flask app running inside a container you built from scratch.
  • An AWS ECS Fargate service hosting your app with a public IP address anyone can visit.
  • A GitHub Actions CI/CD workflow that automatically builds, pushes, and deploys on every git push.
  • Secret Mission: Add a health check endpoint and configure ECS to automatically recover unhealthy containers.

Are there any prerequisites?

This project assumes you have an AWS account with the AWS CLI configured, a GitHub account, Git installed, and VS Code installed. You do not need Docker installed yet. Step 1 walks you through that.

Build Your Flask App and Dockerfile

Docker Desktop is installed and ready to go. Now you need something to containerize.

In this step, you'll write a Python Flask web app, declare its dependencies, and create a Dockerfile that packages everything into a portable container image. Then you'll build and run that image locally to confirm it works before pushing it to the cloud.

In this step, get ready to:
  • Create the project folder and file structure.
  • Write a Flask app and Dockerfile.
  • Build and test the Docker container locally.
Create the project folder and files

Every containerized app starts with a folder containing its source code and a Dockerfile. You'll create a dedicated project folder on your Desktop so it's easy to find.

  • Open a terminal in VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir devops-pipeline
cd devops-pipeline
  • Create the three files your project needs by running:
touch app.py requirements.txt Dockerfile
  • Confirm the files exist by running:
ls

You should see app.py, requirements.txt, and Dockerfile listed in the output.

  • Open this folder in VS Code by running:
code .

What does code . do?

The code . command opens the current folder in VS Code. The dot (.) means "this directory." You should see your three empty files appear in the VS Code Explorer sidebar on the left.

Not seeing your files in VS Code?

Make sure VS Code opened the devops-pipeline folder, not your entire Desktop. Check the Explorer sidebar. You should see app.py, requirements.txt, and Dockerfile listed.

If VS Code opened a different folder, click File in the top menu, then Open Folder, and navigate to Desktop > devops-pipeline.

Help me open my project folder in VS Code.

Write your Flask app and Dockerfile

Now you'll fill in the three files. The app is a simple web page served by Flask. The Dockerfile tells Docker how to package the app into a container image.

  • In the VS Code Explorer sidebar, click app.py to open it in the editor.
  • Paste the following code into app.py:
from flask import Flask
import os

app = Flask(__name__)

@app.route("/")
def home():
    version = os.environ.get("APP_VERSION", "1.0.0")
    return f"""
    <html>
    <head><title>DevOps Pipeline App</title></head>
    <body style="font-family: Arial; text-align: center; margin-top: 50px; background-color: #1a1a2e; color: #eaeaea;">
        <h1>Hello DevOps! 🚀</h1>
        <p>Version: {version}</p>
        <p>This app was deployed automatically via CI/CD pipeline.</p>
        <p>Running on AWS ECS Fargate.</p>
    </body>
    </html>
    """

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this code do?

This is a minimal Flask web application that serves a single HTML page.

  • Flask is a lightweight Python web framework. It handles incoming HTTP requests and returns responses.
  • @app.route("/") tells Flask to run the home() function when someone visits the root URL.
  • os.environ.get("APP_VERSION", "1.0.0") reads a version number from environment variables, defaulting to 1.0.0 if none is set.
  • host="0.0.0.0" makes the app listen on all network interfaces, which is required for Docker containers to receive traffic from outside.
  • Save app.py by pressing Ctrl+S.

You won't see any output yet. The Dockerfile you write next will package this into a runnable container.

✔️ Awesome, I've got everything!

Great. Make sure you've saved app.py before moving on.

ⓧ I'd like to double check the full code

from flask import Flask
import os

app = Flask(__name__)

@app.route("/")
def home():
    version = os.environ.get("APP_VERSION", "1.0.0")
    return f"""
    <html>
    <head><title>DevOps Pipeline App</title></head>
    <body style="font-family: Arial; text-align: center; margin-top: 50px; background-color: #1a1a2e; color: #eaeaea;">
        <h1>Hello DevOps! 🚀</h1>
        <p>Version: {version}</p>
        <p>This app was deployed automatically via CI/CD pipeline.</p>
        <p>Running on AWS ECS Fargate.</p>
    </body>
    </html>
    """

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
  • Click requirements.txt in the Explorer sidebar to open it.
  • Paste this single line into requirements.txt:
flask==3.1.1

Why pin the version?

Pinning flask==3.1.1 ensures everyone building this image gets the exact same Flask version. Without a pin, pip install flask might install a newer version that behaves differently.

  • Save requirements.txt by pressing Ctrl+S.
  • Click Dockerfile in the Explorer sidebar to open it.
  • Paste the following into your Dockerfile:
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]

What does this Dockerfile do?

A Dockerfile is a recipe that tells Docker how to build your container image, layer by layer.

  • FROM python:3.12-slim starts from an official Python image. The slim variant is smaller because it excludes development tools you don't need at runtime.
  • WORKDIR /app sets the working directory inside the container.
  • COPY requirements.txt . and RUN pip install install dependencies first. Docker caches this layer, so dependencies only reinstall when requirements.txt changes.
  • COPY app.py . adds your application code.
  • EXPOSE 5000 documents which port the app listens on.
  • CMD ["python", "app.py"] tells Docker what command to run when the container starts.
  • Save Dockerfile by pressing Ctrl+S.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]
Build and test the Docker image locally

With all three files in place, you can build a Docker image and run it as a container. This confirms everything works before you deploy to the cloud.

  • Build your Docker image by running this command in the VS Code terminal:
docker build -t devops-app .

What does this command do?

  • docker build reads your Dockerfile and builds a container image from it.
  • -t devops-app tags (names) the image devops-app so you can reference it later.
  • . tells Docker to look in the current directory for the Dockerfile.

The build takes about a minute the first time as Docker downloads the Python base image. You should see output for each layer being built.

Seeing "Cannot connect to the Docker daemon"?

This means Docker Desktop is not running. Open Docker Desktop from your Start menu or taskbar and wait until it shows "Engine running" in the bottom-left corner. Then try the build command again.

Help me fix my Docker build error.

  • Run your new container by executing:
docker run -p 5000:5000 devops-app

What does -p 5000:5000 mean?

The -p 5000:5000 flag maps port 5000 on your machine to port 5000 inside the container. Without this, traffic from your browser cannot reach the app running inside the container.

  • Open your web browser and go to http://localhost:5000.

You should see a dark-themed page with the heading "Hello DevOps!" and text showing Version 1.0.0.

  • Once you've confirmed the page loads, go back to your terminal and press Ctrl+C to stop the container.

Page won't load in the browser?

Check your terminal. If you see an error like port is already allocated, another process is using port 5000. Stop that process or change the first port number: docker run -p 5001:5000 devops-app and visit http://localhost:5001 instead.

If the terminal shows the Flask server running but the page still doesn't load, make sure you're using http:// (not https://) in your browser URL bar.

Help me get my Docker container responding in the browser.

Your containerized Flask app is working locally. Next up, you'll push this image to AWS so it can run in the cloud with ECS Fargate.

Set Up AWS Infrastructure

Your Docker image is built and tested locally. Now you need somewhere for that image to live in the cloud, and a service to run it.

In this step, you will create the three AWS resources that form the backbone of your deployment pipeline: a container registry to store images, a cluster to run containers, and an IAM user to give GitHub Actions permission to deploy on your behalf.

In this step, get ready to:
  • Create an ECR repository to store your Docker images.
  • Create an ECS cluster with Fargate to run your containers.
  • Create an IAM user with deployment permissions for GitHub Actions.
Create an ECR repository

Amazon ECR (Elastic Container Registry) is a managed Docker registry. It stores your container images so that ECS can pull and run them.

  • Create your ECR repository by running this command in your terminal:
aws ecr create-repository --repository-name devops-app --region us-east-1

What does this command do?

  • aws ecr create-repository tells the AWS CLI to create a new container image repository.
  • --repository-name devops-app names the repository devops-app. This is where your Docker images will be pushed.
  • --region us-east-1 creates the repository in the US East (N. Virginia) region.
  • You should see a JSON response containing repositoryUri and repositoryName confirming the repository was created.

What is a container registry?

Think of ECR like GitHub, but for Docker images instead of code. Every time your pipeline builds a new image, it pushes that image to ECR. When ECS needs to run your app, it pulls the latest image from ECR.

Seeing an error?

  • If you see a "repository already exists" error, that means you (or a previous attempt) already created it. You can continue to the next substep.
  • If you see "Unable to locate credentials", make sure your AWS CLI is configured. Run aws configure to set your access key, secret key, and default region.

Having trouble with something else? Help me debug my ECR repository creation error.

Create an ECS cluster in the AWS Console

Amazon ECS (Elastic Container Service) is the orchestrator that runs your containers. A cluster is a logical grouping where your containers will live.

  • Open the ECS console in your browser at https://console.aws.amazon.com/ecs/v2.
  • From the navigation bar at the top, select US East (N. Virginia) as your region.
  • In the left navigation pane, click Clusters.
  • Click Create cluster.
  • Under Cluster configuration, for Cluster name, enter devops-cluster.
  • Leave all other settings as their defaults. The console automatically associates the Fargate capacity provider with your cluster.
  • Click Create.

After a few seconds, you should see your devops-cluster listed with a status of Active.

Why Fargate instead of EC2?

ECS can run containers on EC2 instances (which you manage) or on Fargate (which AWS manages). Fargate is serverless. You do not need to provision, patch, or scale servers.

This keeps the project focused on CI/CD and containerization rather than cluster administration.

Cluster stuck in Provisioning?

  • Wait about 30 seconds and refresh the page. Cluster creation usually completes quickly.
  • Make sure you selected the US East (N. Virginia) region. If you created the cluster in a different region, it will not work with your ECR repository.

Still stuck? Help me debug my ECS cluster creation issue.

Create an IAM user for GitHub Actions

Your GitHub Actions pipeline needs permission to push images to ECR and deploy tasks to ECS. You will create a dedicated IAM user with only the permissions it needs.

  • Open the IAM console at https://console.aws.amazon.com/iam/.
  • In the left navigation pane, click Users.
  • Click Create user.
  • For User name, enter github-actions-deployer.
  • Click Next.

Now you need to attach the permissions policies that allow this user to interact with ECR and ECS.

  • On the Set permissions page, select Attach policies directly.
  • In the search box, type AmazonEC2ContainerRegistryFullAccess and check the box next to it.
  • Clear the search box, then type AmazonECS_FullAccess and check the box next to it.
  • Click Next.
  • Review the summary and click Create user.

What are these policies?

AmazonEC2ContainerRegistryFullAccess lets the user push and pull images from ECR. AmazonECS_FullAccess lets the user register task definitions and update services on ECS.

Together, these give GitHub Actions everything it needs to build, push, and deploy your container.

Now create an access key so GitHub Actions can authenticate as this user.

  • Click View user to go to the user details page (or click github-actions-deployer in the Users list).
  • Select the Security credentials tab.
  • In the Access keys section, click Create access key.
  • On the Access key best practices & alternatives page, select Other and click Next.
  • Skip the description tag and click Create access key.
  • Copy the Access key ID and Secret access key and paste them somewhere safe (a text file or password manager). You will need these in a later step.

Save your secret access key now

This is the only time you can view or download the secret access key. If you navigate away without copying it, you will have to delete the key and create a new one. Do not share these credentials with anyone.

Cannot find the Create access key button?

  • Make sure you are on the Security credentials tab of the user detail page, not the Permissions tab.
  • If the button is greyed out, the user already has two access keys. Delete one first, then try again.

Need more help? Help me create an access key for my IAM user.

Your AWS infrastructure is ready. You have a registry for images, a cluster for containers, and a user with the right permissions. Next up, you will write the task definition that tells ECS exactly how to run your container.

Create the ECS Task Definition and Service

Your Docker image is sitting in ECR, and your ECS cluster is ready and waiting. Now you need to tell ECS exactly how to run your container.

A task definition is the blueprint for your container. It specifies which image to pull, how much CPU and memory to allocate, and which ports to expose. Once the task definition is registered, you create an ECS service that keeps one copy of your container running at all times.

In this step, get ready to:
  • Write the task definition JSON file.
  • Register the task definition with ECS.
  • Create an ECS service and verify your app is publicly accessible.
Write the task definition JSON file

The task definition tells ECS Fargate how to run your container. You store it as a JSON file in your project so your CI/CD pipeline can reference it later.

  • In VS Code, create a new folder called .aws inside your devops-pipeline project folder.
  • Inside .aws, create a new file called task-definition.json.
  • Paste the following JSON into task-definition.json:
{
    "family": "devops-app-task",
    "networkMode": "awsvpc",
    "requiresCompatibilities": ["FARGATE"],
    "cpu": "256",
    "memory": "512",
    "executionRoleArn": "arn:aws:iam::[[AWS_ACCOUNT_ID=\"your 12-digit AWS account ID\"]]:role/ecsTaskExecutionRole",
    "containerDefinitions": [
        {
            "name": "devops-app",
            "image": "[[AWS_ACCOUNT_ID=\"your 12-digit AWS account ID\"]].dkr.ecr.us-east-1.amazonaws.com/devops-app:latest",
            "portMappings": [
                {
                    "containerPort": 5000,
                    "hostPort": 5000,
                    "protocol": "tcp"
                }
            ],
            "essential": true
        }
    ]
}

What does this task definition do?

  • family groups versions of this task definition together under the name devops-app-task.
  • networkMode: awsvpc gives each task its own network interface, which is required for Fargate.
  • cpu: 256 and memory: 512 allocate the smallest Fargate size (0.25 vCPU, 0.5 GB RAM). This keeps costs minimal.
  • executionRoleArn points to the IAM role that lets Fargate pull your image from ECR.
  • containerPort: 5000 exposes the same port your Flask app listens on.
  • Save .aws/task-definition.json.

Not sure what your AWS account ID is?

Run this command to find it:

aws sts get-caller-identity --query Account --output text

Replace both placeholder values in the file with this 12-digit number.

Help me find my AWS account ID and update my task definition file with it.

Register the task definition with ECS

Registering the task definition tells AWS about your container blueprint. After registration, ECS can launch containers based on it.

  • Register the task definition by running this command from your devops-pipeline folder:
aws ecs register-task-definition --cli-input-json file://.aws/task-definition.json

What does this command do?

The --cli-input-json flag tells the AWS CLI to read the entire task definition from your JSON file instead of passing each field as a separate argument. The file:// prefix tells the CLI to read from a local file path.

  • You should see a large JSON response containing your task definition details. Look for "status": "ACTIVE" and "revision": 1 in the output to confirm it registered successfully.

Seeing an error about the execution role?

If you see a message about ecsTaskExecutionRole not existing, you need to create it in the IAM console. Navigate to IAM, click Roles, then Create role. Select Elastic Container Service Task as the trusted entity, attach the AmazonECSTaskExecutionRolePolicy managed policy, and name the role ecsTaskExecutionRole.

Help me create the ecsTaskExecutionRole in IAM so I can register my task definition.

Create an ECS service and verify your app is accessible

An ECS service keeps your task running continuously. If the container crashes, the service automatically restarts it. You need your default VPC subnet and security group IDs to create the service.

  • Get one of your default VPC subnet IDs by running:
aws ec2 describe-subnets --filters "Name=default-for-az,Values=true" --query "Subnets[0].SubnetId" --output text
  • Copy the subnet ID from the output (it looks like subnet-0abc123def456).
  • Get your default VPC security group ID by running:
aws ec2 describe-security-groups --filters "Name=group-name,Values=default" --query "SecurityGroups[0].GroupId" --output text
  • Copy the security group ID from the output (it looks like sg-0abc123def456).

Why do you need subnet and security group IDs?

Fargate tasks use the awsvpc network mode, which gives each task its own elastic network interface. AWS needs to know which subnet to place that interface in and which security group rules to apply for inbound and outbound traffic.

  • Create the ECS service by running this command, replacing the subnet and security group IDs with your own values:
aws ecs create-service --cluster devops-cluster --service-name devops-service --task-definition devops-app-task --desired-count 1 --launch-type FARGATE --network-configuration "awsvpcConfiguration={subnets=[[[SUBNET_ID=\"your subnet ID from above\"]]],securityGroups=[[[SECURITY_GROUP_ID=\"your security group ID from above\"]]],assignPublicIp=ENABLED}"

What does this command do?

  • --cluster devops-cluster tells ECS which cluster to deploy into.
  • --service-name devops-service names your service so you can reference it later.
  • --desired-count 1 runs exactly one copy of your container.
  • --launch-type FARGATE uses serverless compute instead of managing EC2 instances.
  • assignPublicIp=ENABLED gives your task a public IP so you can access it from a browser.
  • Wait about 1-2 minutes for Fargate to start your task.
  • Find the task ARN of your running task by running:
aws ecs list-tasks --cluster devops-cluster --service-name devops-service --query "taskArns[0]" --output text
  • Copy the task ARN from the output, then get the network interface ID by running:
aws ecs describe-tasks --cluster devops-cluster --tasks [[TASK_ARN=\"your task ARN from above\"]] --query "tasks[0].attachments[0].details[?name=='networkInterfaceId'].value" --output text
  • Use the network interface ID to get your task's public IP:
aws ec2 describe-network-interfaces --network-interface-ids [[ENI_ID=\"your network interface ID from above\"]] --query "NetworkInterfaces[0].Association.PublicIp" --output text
  • Open your browser and navigate to http:// followed by your public IP and :5000 (for example, http://3.91.200.45:5000).
  • You should see your "Hello DevOps" page with the rocket emoji, version number, and the message about running on AWS ECS Fargate.

Page not loading in the browser?

The most common cause is your security group not allowing inbound traffic on port 5000. Add an inbound rule to your security group by running:

aws ec2 authorize-security-group-ingress --group-id [[SECURITY_GROUP_ID=\"your security group ID\"]] --protocol tcp --port 5000 --cidr 0.0.0.0/0

If the task status is STOPPED rather than RUNNING, check your task logs in the ECS console for error details. Verify that your ECR image was pushed successfully in the previous step.

Help me troubleshoot my ECS Fargate networking.

✔️ Awesome, I've got everything!

Great. Double check you have saved your .aws/task-definition.json file and confirmed your app is live in the browser.

ⓧ I'd like to double check the full code

Here is the full content of .aws/task-definition.json:

{
    "family": "devops-app-task",
    "networkMode": "awsvpc",
    "requiresCompatibilities": ["FARGATE"],
    "cpu": "256",
    "memory": "512",
    "executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole",
    "containerDefinitions": [
        {
            "name": "devops-app",
            "image": "YOUR_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/devops-app:latest",
            "portMappings": [
                {
                    "containerPort": 5000,
                    "hostPort": 5000,
                    "protocol": "tcp"
                }
            ],
            "essential": true
        }
    ]
}

Your container is live on AWS. Next up, you will automate the entire build-push-deploy cycle with GitHub Actions so you never have to run these commands manually again.

Set Up GitHub Actions CI/CD Pipeline

Your ECS service is live and serving your Flask app from a container on AWS. Right now, updating it means manually building an image, pushing it to ECR, and updating the task definition by hand.

In this step, you will automate the entire deployment process. Every time you push code to GitHub, a GitHub Actions workflow will build your Docker image, push it to ECR, and deploy it to ECS with zero manual intervention.

In this step, get ready to:
  • Push your project to a new GitHub repository.
  • Add AWS credentials as GitHub Secrets.
  • Create the GitHub Actions workflow file that builds, pushes, and deploys automatically.
Push your project to a new GitHub repository

GitHub Actions workflows live inside your repository. To trigger automated deployments, your project code needs to be hosted on GitHub.

  • Switch back to your terminal in VS Code. Make sure you are inside your devops-pipeline folder.
  • Initialize a new Git repository by running this command:
git init -b main
  • Stage and commit all your project files by running these commands:
git add .
git commit -m "Initial commit"

What do these commands do?

The git init -b main command turns your folder into a Git repository with a default branch called main.

The git add . stages every file in the project. Then git commit saves that snapshot as your first commit.

Now you need to create a repository on GitHub to push this code to.

  • Open your browser and go to github.com/new.
  • Enter devops-pipeline as the repository name.
  • Leave the repository set to Public.
  • Do NOT check any boxes under "Initialize this repository with" (no README, no .gitignore, no license).
  • Click Create repository.

Why skip the initialization options?

If you add a README or .gitignore on GitHub, it creates a commit that your local repository does not have. This causes merge conflicts when you try to push. Since you already have files locally, create an empty remote and push your existing code to it.

After creating the repository, GitHub shows a Quick Setup page with the remote URL.

  • Copy the HTTPS URL from the Quick Setup page.
  • Back in your terminal, connect your local repository to GitHub and push by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/devops-pipeline.git"]]
git push -u origin main

What does this do?

The git remote add origin command tells Git where your remote repository lives on GitHub. The git push -u origin main command uploads all your committed files to the main branch on GitHub.

  • Refresh your GitHub repository page in the browser. You should see your project files (app.py, requirements.txt, Dockerfile, and the .aws folder) listed on the repository page.

Authentication error when pushing?

GitHub no longer accepts passwords for Git operations. If prompted for credentials, enter your GitHub username and a Personal Access Token (not your password). You can create a token at github.com/settings/tokens. Select the repo scope.

help me fix a GitHub authentication error when trying to push

Add AWS credentials as GitHub Secrets

Your workflow needs AWS credentials to push images to ECR and deploy to ECS. GitHub Secrets store sensitive values securely so they never appear in your code or logs.

  • On your GitHub repository page, click Settings. If you cannot see the Settings tab, click the dropdown menu and select Settings.
  • In the left sidebar, under the Security section, click Secrets and variables, then click Actions.
  • Click New repository secret.
  • In the Name field, type AWS_ACCESS_KEY_ID.
  • In the Secret field, paste the Access Key ID you saved from the github-actions-deployer IAM user.
  • Click Add secret.
  • Click New repository secret again.
  • In the Name field, type AWS_SECRET_ACCESS_KEY.
  • In the Secret field, paste the Secret Access Key you saved from the github-actions-deployer IAM user.
  • Click Add secret.

You should now see both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY listed on the Actions secrets page.

Why use GitHub Secrets?

Secrets are encrypted at rest and masked in workflow logs. Your workflow references them with ${{ secrets.AWS_ACCESS_KEY_ID }} syntax. This keeps credentials out of your source code while giving the pipeline the access it needs.

Don't see the Settings tab?

Only repository owners can access Settings. Make sure you are signed in to the GitHub account that created the repository.

help me find where to add repository secrets in GitHub

Create the GitHub Actions workflow file

The workflow file tells GitHub Actions exactly what to do when you push code. It defines the steps to build your Docker image, push it to ECR, and deploy it to your ECS service.

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

on:
  push:
    branches:
      - main

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: devops-app
  ECS_SERVICE: devops-service
  ECS_CLUSTER: devops-cluster
  ECS_TASK_DEFINITION: .aws/task-definition.json
  CONTAINER_NAME: devops-app

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

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

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

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

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

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

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

What does this workflow do?

  • Triggers on every push to the main branch.
  • Checks out your code, then authenticates with AWS using your stored secrets.
  • Logs in to ECR, builds your Docker image, and tags it with the commit SHA for traceability.
  • Renders the new image ID into your task definition JSON file.
  • Deploys the updated task definition to your ECS service and waits for the new container to stabilize.

Why pin actions to commit SHAs?

The long hex strings after each uses: line are commit SHAs. Pinning to a specific commit ensures your workflow always uses the exact same version of each action, even if the maintainer pushes breaking changes. This is a security best practice recommended by GitHub.

  • Save deploy.yml.
  • Push the workflow file to GitHub by running these commands:
git add .
git commit -m "Add CI/CD workflow"
git push

This push to main triggers the workflow you just created.

  • In your browser, navigate to your GitHub repository.
  • Click the Actions tab at the top of the repository.
  • You should see a workflow run in progress called Add CI/CD workflow. Click on it to watch the deployment steps.
  • Wait for all steps to complete with green checkmarks. This takes 2-4 minutes as the workflow builds the image, pushes to ECR, and waits for ECS to stabilize.

Workflow failing?

Click the failed step in the Actions log to see the error message. Common causes:

  • Incorrect secret values. Double-check that AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are pasted correctly with no extra spaces.
  • The ECS service name or cluster name in the workflow does not match what you created. Verify devops-service and devops-cluster match your AWS resources.
  • The task definition file path .aws/task-definition.json does not exist in your repository. Confirm the file was committed.

help me debug my GitHub Actions ECS deployment workflow

✔️ Awesome, I've got everything!

Great. Double check you have saved deploy.yml and that your workflow run completed successfully in the Actions tab.

ⓧ I'd like to double check the full code

Here is the complete workflow file for comparison:

name: Deploy to Amazon ECS

on:
  push:
    branches:
      - main

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: devops-app
  ECS_SERVICE: devops-service
  ECS_CLUSTER: devops-cluster
  ECS_TASK_DEFINITION: .aws/task-definition.json
  CONTAINER_NAME: devops-app

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

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

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

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

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

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

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

Your CI/CD pipeline is fully operational. Next up, you will make a code change and watch the entire pipeline deploy it automatically from push to live.

Trigger a Deployment

Your pipeline is live and your app is running on Fargate. The real test of any CI/CD pipeline is what happens when you change your code.

In this step, you'll make a small change to your Flask app, push it to GitHub, and watch the entire pipeline fire automatically. Within minutes, the updated app will be live on AWS with zero manual intervention.

In this step, get ready to:
  • Make a code change to your Flask app.
  • Push the change to GitHub and monitor the pipeline.
  • Verify the updated app is live on your Fargate service.
Update your Flask app

To trigger the pipeline, you need to push a code change to the main branch. Any change will do. You'll update the heading text so the difference is easy to spot in the browser.

  • Switch back to VS Code with your devops-pipeline project open.
  • Open app.py and find this line:
        <h1>Hello DevOps! 🚀</h1>
  • Change the heading text to something different. For example, change it to Hello DevOps Pipeline! or any text you like.
  • Save app.py.

Why any change works

Your workflow triggers on any push to the main branch. It does not check what changed. Even a single character edit causes the full build-push-deploy cycle to run.

Push the change to GitHub

Now you'll commit and push the change. This single push kicks off the entire pipeline automatically.

  • Stage, commit, and push your change by running these commands in the VS Code terminal:
git add .
git commit -m "Update app heading"
git push origin main

What does this do?

These three commands stage all changed files, save them as a commit with a descriptive message, and push the commit to your remote main branch on GitHub. The push event triggers your GitHub Actions workflow.

  • Open your repository on GitHub in your browser.
  • Click the Actions tab at the top of the repository page.
  • In the left sidebar, click Deploy to Amazon ECS to filter to your workflow.
  • Click the most recent workflow run (it should show an orange spinning icon, meaning it's in progress).

You should see the workflow run summary with the Deploy job either running or completed.

What happens during the pipeline run?

The workflow executes five steps in sequence:

  • Checks out your latest code from the main branch.
  • Authenticates with AWS using the secrets you stored in GitHub.
  • Logs into ECR and builds a new Docker image tagged with the commit SHA.
  • Renders the new image ID into your task definition JSON.
  • Deploys the updated task definition to ECS and waits for the service to stabilize.

The entire process typically takes 3-5 minutes. The wait-for-service-stability setting means the workflow won't finish until ECS confirms the new task is running.

  • Wait for the workflow to complete. All steps should show green checkmarks.

Seeing a red X on the workflow?

Click the failed step to expand its logs. Common causes:

  • AWS credentials expired or incorrect. Double-check the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY values in your repository secrets.
  • ECR login failed because the IAM user is missing the AmazonEC2ContainerRegistryFullAccess policy.
  • Service stability timeout. ECS could not start the new task within the default timeout. Check that port 5000 is open in your security group.

Help me troubleshoot my GitHub Actions deployment workflow failure.

Verify the updated app is live

Once the workflow finishes, ECS is running a new task with your updated code. You need to find the task's public IP to confirm the change.

  • Open the ECS console at https://console.aws.amazon.com/ecs/v2.
  • In the left navigation pane, click Clusters.
  • Click devops-cluster.
  • Click the Services tab, then click devops-service.
  • Click the Tasks tab, then click the running task.
  • In the Configuration section, find the Public IP field.
  • Click Open address or copy the IP and paste it into your browser with :5000 appended (e.g. http://3.85.12.34:5000).

You should see your updated heading text displayed on the page. The page still shows the dark background and version number, but now with your new heading.

Why the IP might be different

Each time ECS replaces a task, the new task gets a fresh public IP. This is expected behavior for Fargate tasks. In a production environment, you would place a load balancer in front of the service to provide a stable URL.

Still seeing the old heading?

  • Make sure you are looking at the new task, not a cached page. Try a hard refresh with Ctrl+Shift+R (Windows) or Cmd+Shift+R (macOS).
  • Check the Tasks tab in the ECS console. If you see two tasks, the old one is draining and the new one is running. Use the public IP from the RUNNING task.
  • Confirm the workflow completed successfully in the Actions tab on GitHub. If the deploy step is still in progress, the new task has not replaced the old one yet.

Help me figure out why my ECS service is still showing old content after a deployment.

Secret mission

Add Health Checks and Auto-Recovery

Your app is running on Fargate, but what happens if the container crashes or becomes unresponsive? In production, containers need to report their own health so the orchestrator can automatically replace broken instances. Add a health check endpoint and configure ECS to monitor it.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Your ECS Fargate service incurs ongoing compute charges while a task is running. The ECR repository has minimal storage costs.

Cost warning

Your ECS Fargate task is billed by the second while running. Even at the smallest size (0.25 vCPU, 0.5 GB), leaving it running overnight or over a weekend adds up. If you are done with the project, delete your resources or pause the service to stop charges immediately.

Resources you used:

  • ECS Fargate service (devops-service) running in cluster devops-cluster.
  • ECR repository (devops-app) storing your Docker images.
  • ECS task definition (devops-app-task).
  • IAM user (github-actions-deployer) with ECR and ECS permissions.
  • GitHub repository with your project code and workflow.
  • Local project folder (devops-pipeline/) on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to practice pushing more deployments through the pipeline.

  • Your ECS service continues running and serving traffic.
  • Your GitHub Actions workflow triggers on every push to main.
  • Fargate charges continue while the task is running.

Pause - I'll come back to this later

Shut down the running task to stop Fargate compute charges while keeping all your configuration intact.

  • Set your ECS service desired count to 0 by running this command:
aws ecs update-service --cluster devops-cluster --service devops-service --desired-count 0

What does this do?

Setting desired count to 0 stops all running tasks in the service. Your cluster, task definition, ECR images, and GitHub workflow all remain in place. When you want to resume, set the desired count back to 1.

  • Your ECR repository still stores images (charged at $0.10/GB/month, which is negligible for a single small image).
  • To resume later, run:
aws ecs update-service --cluster devops-cluster --service devops-service --desired-count 1

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps in order.

  • Stop the running task and delete the ECS service by running these commands:
aws ecs update-service --cluster devops-cluster --service devops-service --desired-count 0
aws ecs delete-service --cluster devops-cluster --service devops-service
  • Delete the ECS cluster:
aws ecs delete-cluster --cluster devops-cluster
  • Delete the ECR repository and all stored images:
aws ecr delete-repository --repository-name devops-app --force
  • Deregister the task definition (it does not cost money, but this keeps your account tidy):
aws ecs deregister-task-definition --task-definition devops-app-task:1

What about the task definition revision number?

If you registered multiple revisions during the project, repeat the deregister command for each revision number. Deregistered task definitions are marked INACTIVE and no longer appear in your active list.

  • Delete the IAM user from the AWS Console:
  • Open the IAM console at https://console.aws.amazon.com/iam/.
  • In the navigation pane, choose Users.
  • Select the checkbox next to github-actions-deployer.
  • Click Delete at the top of the page.
  • Type the username to confirm, then click Delete.
  • Delete the GitHub repository by going to your repository's Settings tab, scrolling to the bottom, and choosing Delete this repository.
  • Delete the local project folder:

macOS

rm -rf ~/Desktop/devops-pipeline

Windows

Remove-Item -Recurse -Force ~\Desktop\devops-pipeline

Nice Work!

Nice Work!

You just built a production-grade CI/CD pipeline from scratch! Every time you push code to GitHub, your pipeline automatically builds a Docker image, pushes it to AWS ECR, and deploys it to ECS Fargate with zero manual intervention.

You've learned how to:

  • Containerized a Python Flask app with Docker and pushed the image to AWS ECR (a container registry).
  • Deployed a running container to ECS Fargate (serverless containers) with scoped IAM permissions for secure access.
  • Automated the entire build-push-deploy cycle with a GitHub Actions workflow that triggers on every push to main.
  • Added health checks to your container and configured ECS to automatically recover unhealthy tasks.

Ready to quiz yourself?