CI/CD Pipeline with Docker and AWS ECR

Build a CI/CD pipeline that auto-pushes Docker images to AWS ECR via GitHub Actions.

Introduction

30 Second Summary

Every time a developer pushes new code, someone has to build it, package it, and get it running on a server. At most companies, that "someone" is an automated pipeline that handles the entire process in minutes.

In this project, you will containerize a Flask API with Docker and provision a private container registry with Terraform. You will also create a CI/CD pipeline using GitHub Actions that automatically builds and pushes your app on every code push.

What You'll Build

Push code to GitHub and watch your pipeline kick off automatically, building your Docker image and pushing it to your private AWS container registry within minutes.

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

  • A CI/CD pipeline that automatically builds your Docker image and pushes it to AWS ECR every time you push code.
  • Cloud infrastructure provisioned entirely with Terraform, created and destroyed with a single command.
  • Secure credential management through GitHub Secrets so your AWS access keys never touch your code.
  • Secret Mission: Add automated tests to your pipeline so broken code never reaches your registry.

Are there any prerequisites?

This project assumes you already have Docker Desktop, Git, a GitHub account, and an AWS account. You will install everything else in Step 1.

Before We Start

Before you touch any tools or write any code, take a moment to lock in what you're about to build and why it matters.

Build and Containerize Your Flask API

Your DevOps toolkit is set up and your devops-pipeline repo is ready for code. Before you can automate builds and deployments, you need a working application to deploy.

Docker packages your app into a container that runs identically on your machine, inside a CI pipeline, and in production. In this step, you'll create a simple Flask API and containerize it with Docker.

In this step, get ready to:
  • Create a Flask API with a home endpoint and a health-check endpoint.
  • Package the API into a Docker container using a Dockerfile.
  • Run the container locally and verify it responds with JSON.
Create your Flask application

Your Flask API will have two endpoints. A home route returns a welcome message, and a health-check route reports whether the app is running.

  • In VS Code's Explorer sidebar, right-click in the file area.
  • Select New File.
  • Name the file requirements.txt and press Enter.
  • Paste the following content into requirements.txt:
flask==3.1.3
  • Save requirements.txt.
  • Create another new file called app.py using the same right-click method.
  • Paste the following code into app.py:
from flask import Flask, jsonify

# Create the Flask application
app = Flask(__name__)


# Home endpoint returns a welcome message and version number
@app.route("/")
def home():
    return jsonify({"message": "Hello from the DevOps Pipeline!", "version": "1.0.0"})


# Health check endpoint reports the app is running
@app.route("/health")
def health():
    return jsonify({"status": "healthy"})


# Start the server on all network interfaces at port 5000
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this code do?

  • app = Flask(__name__) creates your Flask application instance.
  • The / route returns a JSON welcome message with a version number. This is what you would expand into your real API.
  • The /health route returns {"status": "healthy"}. Monitoring tools and load balancers use health endpoints like this to check whether your app is alive.
  • host="0.0.0.0" tells Flask to listen on all network interfaces. This is essential inside a Docker container so traffic from outside the container can reach the app.
  • Save app.py.
  • Check VS Code's Explorer sidebar. You should see both requirements.txt and app.py listed in your devops-pipeline folder.

Don't see both files?

  • Make sure you created both files directly inside the devops-pipeline folder, not inside a subfolder.
  • Check that you saved each file after pasting the content. Unsaved files show a dot on their tab in VS Code.

Help me find my files in VS Code.

✔️ 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 requirements.txt:

flask==3.1.3

Here is app.py:

from flask import Flask, jsonify

app = Flask(__name__)


@app.route("/")
def home():
    return jsonify({"message": "Hello from the DevOps Pipeline!", "version": "1.0.0"})


@app.route("/health")
def health():
    return jsonify({"status": "healthy"})


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

A Dockerfile is a recipe that tells Docker exactly how to package your app. It specifies the base image, copies your code, installs dependencies, and defines how to start the application.

  • Create a new file called Dockerfile (no file extension) in your devops-pipeline folder.
  • Paste the following into Dockerfile:
# Use a lightweight Python base image
FROM python:3.13-slim

# Set the working directory inside the container
WORKDIR /app

# Copy and install dependencies first (for Docker layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the rest of the application code
COPY . .

# Document the port the app listens on
EXPOSE 5000

# Run the Flask application
CMD ["python", "app.py"]

What does this Dockerfile do?

  • FROM python:3.13-slim starts from an official Python image that's small and production-ready.
  • COPY requirements.txt and RUN pip install are done before copying the rest of your code. Docker caches each layer, so dependencies only reinstall when requirements.txt changes.
  • EXPOSE 5000 documents which port the app listens on. It does not publish the port. You do that with -p when running the container.
  • CMD ["python", "app.py"] tells Docker what command to run when the container starts.
  • Save Dockerfile.
  • Confirm all three files appear in VS Code's Explorer sidebar: app.py, Dockerfile, and requirements.txt.

Dockerfile has a file extension?

The file must be named exactly Dockerfile with no extension. If VS Code added .txt or another extension, right-click the file, select Rename, and remove the extension.

Help me name my Dockerfile correctly.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before building.

ⓧ I'd like to double check the full code

FROM python:3.13-slim

WORKDIR /app

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

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]
Build and run your container

Your Dockerfile is the recipe. Now you'll use it to build a Docker image and run it as a container on your machine.

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

What does this command do?

  • docker build reads your Dockerfile and creates an image from it.
  • -t devops-pipeline-app tags (names) the image so you can reference it later.
  • The . at the end tells Docker to look for the Dockerfile in the current directory.

You'll see Docker download the Python base image and install your dependencies. This takes a minute or two on the first run.

Build failing?

  • Make sure Docker Desktop is running. The Docker daemon must be active for builds to work.
  • Check that your terminal is in the devops-pipeline folder where your Dockerfile lives.
  • Confirm your Dockerfile has no file extension. It must be named exactly Dockerfile.

Help me fix my Docker build.

Before you run the container, what do you think you'll see when you visit http://localhost:5000/health in your browser?

  • Start your container by running this command:
docker run -p 5000:5000 devops-pipeline-app

Why does this take over my terminal?

The docker run command starts the container in the foreground. Your terminal will show Flask log messages and stay occupied while the container runs. This is normal.

The -p 5000:5000 flag maps port 5000 on your machine to port 5000 inside the container. This is how your browser reaches the Flask app running inside Docker.

When you're ready to stop the container, press Ctrl+C in the terminal.

  • Open your browser.
  • Visit http://localhost:5000/health.

You should see {"status":"healthy"} displayed in the browser. Your Flask API is running inside a Docker container.

  • Now visit http://localhost:5000/ to see the home endpoint.

You should see a JSON response with a welcome message and version number.

Page not loading in the browser?

  • Check that your terminal is still showing Flask log messages. If the container stopped, run the docker run command again.
  • Make sure the URL starts with http:// not https://. Your local Flask server does not use HTTPS.
  • If you see a "port already in use" error when starting the container, another process is using port 5000. Stop it or change the mapping to -p 5001:5000 and visit port 5001 instead.

Help me get my Docker container responding.

Your Flask API is containerized and responding. Next up, you'll provision a cloud container registry on AWS using Terraform so your Docker image has a permanent home.

Provision AWS ECR with Terraform

In the last step, you built a Docker image and ran your Flask API in a container. Your app works locally, but the image is stuck on your machine.

To share your image with a CI/CD pipeline running on someone else's servers, you need a container registry in the cloud. Real DevOps teams don't click through consoles to create cloud resources. They define infrastructure as code so every resource is repeatable, version-controlled, and auditable. In this step, you'll use Terraform to provision an AWS ECR repository for your Docker images.

In this step, get ready to:
  • Create a Terraform configuration file defining your ECR repository.
  • Run Terraform to provision the repository in AWS.
  • Manually push your Docker image to ECR to verify connectivity.
Create your Terraform configuration

Terraform reads .tf files to understand what cloud resources you want. You'll start by telling Terraform which cloud provider to use, then define the ECR repository resource.

  • Create a new file called main.tf in your devops-pipeline folder (the same folder as your app.py).
  • Add the provider configuration by pasting the following code into main.tf:
# Tell Terraform which cloud provider plugin to download
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

# Configure AWS to use the us-east-1 region
provider "aws" {
  region = "us-east-1"
}

What does this code do?

  • The terraform block tells Terraform to download the AWS provider plugin from HashiCorp. The ~> 6.0 constraint means "any version in the 6.x range."
  • The provider block configures that plugin to create resources in the us-east-1 region, matching your AWS CLI configuration.
  • Save main.tf.
  • Download the AWS provider plugin by running this command in your terminal:
terraform init

You should see Terraform has been successfully initialized! at the bottom of the output.

What does terraform init do?

It reads your required_providers block and downloads the matching plugin into a local .terraform/ folder. You only need to run it once per project, or again if you change providers.

Seeing an error during init?

  • Check that you're running the command from inside your devops-pipeline folder, where main.tf lives.
  • Make sure source is spelled exactly as "hashicorp/aws" with no typos.
  • Confirm your internet connection is active. Terraform needs to reach the HashiCorp registry to download the provider.

Why is terraform init failing for me?

Now that Terraform knows which provider to use, you can define the actual cloud resource you want to create.

  • In main.tf, add the ECR repository resource and output below your existing provider block:
# Create a private container registry for Docker images
resource "aws_ecr_repository" "app" {
  name                 = "devops-pipeline-app"
  image_tag_mutability = "MUTABLE"
  force_delete         = true

  # Automatically scan images for vulnerabilities on push
  image_scanning_configuration {
    scan_on_push = true
  }
}

# Print the registry URL after creation
output "repository_url" {
  value = aws_ecr_repository.app.repository_url
}

What does this code do?

  • The aws_ecr_repository resource creates a private container registry called devops-pipeline-app in your AWS account.
  • scan_on_push = true tells ECR to automatically check every image you push for known security vulnerabilities.
  • force_delete = true lets Terraform delete the repository even if it still has images inside. This makes cleanup easier later.
  • The output block prints the full registry URL after Terraform creates the resource. You'll need this URL to push images.
  • Save main.tf.
  • Preview what Terraform will create by running:
terraform plan

You should see a plan showing one resource marked with a + symbol, ending with Plan: 1 to add, 0 to change, 0 to destroy.

Seeing syntax errors in the plan?

  • Check that every opening brace { has a matching closing brace } in your main.tf.
  • Make sure string values like "devops-pipeline-app" use straight double quotes, not curly quotes.

Why is my Terraform plan showing errors?

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete main.tf file. Compare it with yours line by line.

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

provider "aws" {
  region = "us-east-1"
}

resource "aws_ecr_repository" "app" {
  name                 = "devops-pipeline-app"
  image_tag_mutability = "MUTABLE"
  force_delete         = true

  image_scanning_configuration {
    scan_on_push = true
  }
}

output "repository_url" {
  value = aws_ecr_repository.app.repository_url
}
Create your ECR repository

The plan confirmed Terraform knows what to build. Now you'll tell it to go ahead and create the repository in your AWS account.

  • Create the ECR repository by running:
terraform apply

Terraform will ask you to confirm

Terraform shows the same plan you previewed and asks Do you want to perform these actions? before making any changes. Type yes and press Enter to proceed.

After a few seconds, you should see Apply complete! Resources: 1 added, 0 changed, 0 destroyed. followed by your repository_url output.

  • Find the repository_url in the output. It looks like 123456789012.dkr.ecr.us-east-1.amazonaws.com/devops-pipeline-app.
  • Record the 12-digit number at the start of that URL. This is your AWS account ID: your 12-digit AWS account ID

Terraform apply failed?

  • If you see an authentication error, check that your AWS credentials are configured. Run aws sts get-caller-identity to confirm your credentials are valid.
  • If you see a permissions error, make sure the IAM user you configured in Step 1 has permissions to create ECR repositories.

Why is my terraform apply failing?

Why infrastructure as code instead of the AWS console?

You could click through the AWS console to create the same ECR repository. But that manual process can't be version-controlled, shared with a team, or reliably repeated.

With Terraform, your infrastructure lives in a .tf file alongside your application code. Anyone can read the file to understand what exists, run terraform apply to recreate it, or run terraform destroy to tear it all down.

Verify your repository and push your image

Your ECR repository exists in AWS. Before you automate anything, you should confirm it works by manually pushing your Docker image to it.

  • Verify the repository was created by running:
aws ecr describe-repositories --repository-names devops-pipeline-app --region us-east-1

You should see JSON output containing your repository details, including the repositoryUri and imageScanningConfiguration confirming scanning is enabled.

  • Sign in to your ECR registry so Docker can push images to it. Run this command:
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com

What does this command do?

This is two commands joined by a pipe (|). The first command, aws ecr get-login-password, fetches a temporary authentication token from AWS. The pipe sends that token directly into docker login so Docker can authenticate with your private registry.

You should see Login Succeeded in your terminal.

Login failed?

  • Double check that you replaced the account ID placeholder with your actual 12-digit AWS account ID from the terraform apply output.
  • Make sure Docker Desktop is running. The docker login command needs the Docker daemon to be active.

Why is my ECR Docker login failing?

  • Tag your local Docker image with the ECR registry URL by running:
docker tag devops-pipeline-app:latest [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/devops-pipeline-app:latest

Why do I need to tag the image?

Docker needs to know where to push an image. By tagging it with your ECR registry URL, you tell Docker "this image belongs in my private AWS registry." The tag doesn't create a new image. It adds a second name pointing to the same image.

Before you push, what do you think will happen? Will the image upload successfully to your ECR repository?

  • Push your image to ECR by running:
docker push [[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]].dkr.ecr.us-east-1.amazonaws.com/devops-pipeline-app:latest

You should see Docker pushing each layer of your image, finishing with a digest: sha256:... line. Your Docker image is now stored in your private AWS container registry.

Push denied or repository not found?

  • Make sure the repository name in the tag exactly matches devops-pipeline-app. A single character difference causes a mismatch.
  • If you see an authorization error, re-run the aws ecr get-login-password login command. ECR login tokens expire after 12 hours.

Why is my Docker push to ECR failing?

Your ECR repository is live and your Docker image is stored in the cloud. You proved the full flow works: build locally, authenticate with AWS, tag, and push. Next up, you'll automate this entire process with a GitHub Actions CI/CD pipeline so every code push triggers a fresh build.

Create Your CI/CD Pipeline

In the last step, you pushed your Docker image to ECR by running a sequence of commands manually in your terminal. That works once, but imagine doing it every time you change a single line of code.

CI/CD pipelines solve this problem by automating the build and deploy process. In this step, you'll create a GitHub Actions workflow that builds your Docker image automatically every time you push code to GitHub.

In this step, get ready to:
  • Create a GitHub Actions workflow file that triggers on every push.
  • Push your code to GitHub and trigger the pipeline.
  • Observe what happens to the built image after the pipeline finishes.
Create your GitHub Actions workflow file

GitHub Actions looks for workflow files inside a special .github/workflows/ directory in your repository. You need to create this directory structure and add a workflow file inside it.

  • In VS Code's Explorer sidebar, click the New File icon (the page icon with a + sign) at the top of the file tree.
  • Type .github/workflows/ci.yml and press Enter.

Why type the full path?

When you include slashes in a new file name, VS Code automatically creates the intermediate directories for you. This saves you from manually creating .github/ and then workflows/ separately.

  • Paste the following workflow configuration into ci.yml:
# Workflow name displayed in GitHub Actions
name: CI/CD Pipeline

# Trigger this workflow on every push to the main branch
on:
  push:
    branches: [main]

jobs:
  build-and-push:
    # Use GitHub's hosted Ubuntu runner
    runs-on: ubuntu-latest

    steps:
      # Pull your repo code into the runner
      - name: Checkout code
        uses: actions/checkout@v6

      # Build the Docker image using your Dockerfile
      - name: Build Docker image
        run: docker build -t devops-pipeline-app .

What does this workflow do?

  • on: push: branches: [main] tells GitHub to run this workflow every time you push code to the main branch.
  • runs-on: ubuntu-latest spins up a fresh Ubuntu virtual machine (called a runner) to execute your job. It exists only for the duration of the run.
  • actions/checkout@v6 clones your repository code into the runner so the build step can access your Dockerfile and source files.
  • docker build -t devops-pipeline-app . builds your Docker image inside the runner, just like you did locally.
  • Save ci.yml.
  • Confirm the file exists by running this command in your terminal:
ls .github/workflows/

You should see ci.yml listed in the output.

Don't see ci.yml?

  • Make sure you typed the full path .github/workflows/ci.yml when creating the file. The leading dot in .github is required.
  • Check that your terminal is in the devops-pipeline folder, not a subfolder.
  • Help me find my ci.yml file

✔️ Awesome, I've got everything!

Great. Double check that you have saved ci.yml.

ⓧ I'd like to double check the full code

Here is the full .github/workflows/ci.yml file for comparison:

name: CI/CD Pipeline

on:
  push:
    branches: [main]

jobs:
  build-and-push:
    runs-on: ubuntu-latest

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

      - name: Build Docker image
        run: docker build -t devops-pipeline-app .
Create a .gitignore and push to GitHub

Your project contains files that should never be committed to Git. Terraform's local state files hold sensitive infrastructure details, and Python cache files are generated artifacts that clutter your repository.

  • In VS Code's Explorer sidebar, click the New File icon at the top of the file tree.
  • Type .gitignore and press Enter.
  • Paste the following into .gitignore:
# Python cache files
__pycache__/
*.pyc
# Terraform local state files
.terraform/
terraform.tfstate
terraform.tfstate.backup

What does this file do?

A .gitignore file tells Git which files and folders to skip when committing. .terraform/ contains downloaded provider plugins (large and regenerated by terraform init), and terraform.tfstate files can contain sensitive resource details you should never push to a public repo.

  • Save .gitignore.

✔️ Awesome, I've got everything!

Great. Double check that you have saved .gitignore.

ⓧ I'd like to double check the full code

Here is the full .gitignore file for comparison:

__pycache__/
*.pyc
.terraform/
terraform.tfstate
terraform.tfstate.backup

Now it is time to commit everything and push to GitHub. This is the push that triggers your pipeline for the first time.

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

You should see Git output showing your files being pushed to the main branch on GitHub.

Push failed or authentication error?

  • If Git asks for credentials, you may need to set up a personal access token (PAT) on GitHub. Go to Settings > Developer settings > Personal access tokens to create one.
  • If you initialized the GitHub repo with a README and see a merge conflict, run git pull --rebase origin main first, then try git push again.
  • Help me push my code to GitHub
Watch the pipeline run

Your push just triggered the GitHub Actions workflow. Time to see it in action.

  • Open your devops-pipeline repository on GitHub in your browser.
  • Click the Actions tab near the top of the page.
  • Click on the workflow run titled with your commit message to see its details.

If the run is still in progress, wait for it to finish. You should see a green checkmark and a Success status appear once it completes.

Seeing a red X instead?

  • Click on the failed job to see the error logs. The most common cause is a YAML indentation error in ci.yml. YAML is whitespace-sensitive, so double check that your indentation matches the code above exactly.
  • Make sure your Dockerfile is in the root of your repository, not inside a subfolder.
  • Help me fix my failed GitHub Actions workflow

The pipeline ran green. Before moving on, think about this: where is the Docker image that the pipeline just built? Is it stored somewhere you can use?

The image was built inside the GitHub Actions runner. A runner is a temporary virtual machine that exists only for the duration of the workflow run. When the job ended, the runner was destroyed along with everything on it, including your Docker image. There is no push step in the workflow, so the image was never sent to ECR or stored anywhere.

  • Confirm this by checking your ECR repository for new images:
aws ecr list-images --repository-name devops-pipeline-app --region us-east-1

You should only see the image you manually pushed in the previous step. No new image from the pipeline run appears.

Why does this matter?

A pipeline that builds but doesn't store the result is like packing a suitcase and leaving it at the airport. Your workflow proves the image builds successfully, which is valuable. But the image needs to land somewhere permanent (your ECR registry) to be useful.

To push to ECR, the pipeline needs AWS credentials. Hardcoding access keys in your workflow file would expose them to anyone who can read your repo. The next step solves both problems at once.

Your pipeline builds, but it builds into the void. In the next step, you'll connect it to ECR with secure credentials so every push sends your Docker image straight to your private container registry.

Secure Your Pipeline and Push to ECR

Your pipeline ran green in the last step. That green checkmark proved GitHub Actions can build your Docker image automatically. But the image was built inside the runner and thrown away when the job ended. It never reached your registry.

To push to ECR, the pipeline needs your AWS credentials. Hardcoding access keys in your workflow file would expose them to anyone who can see your repo. You will fix this the right way using GitHub Secrets and dedicated AWS actions.

In this step, get ready to:
  • Store AWS credentials securely as GitHub Secrets.
  • Update the workflow to authenticate with ECR and push the Docker image.
  • Verify the full pipeline builds and pushes to your private registry.
Store your AWS credentials as GitHub Secrets

GitHub Secrets let you store sensitive values in your repository settings. Your workflow can reference them without ever exposing the actual values in code.

  • Open your devops-pipeline repository on GitHub in your browser.
  • Click Settings in the top navigation bar of your repository.
  • In the left sidebar, click Secrets and variables.
  • Click Actions.
  • Click New repository secret.
  • In the Name field, type AWS_ACCESS_KEY_ID.
  • In the Secret field, paste your AWS access key (the same one you used when running aws configure in Step 1).
  • Click Add secret.

You should see AWS_ACCESS_KEY_ID listed in your repository secrets. Now add the second secret.

  • Click New repository secret again.
  • In the Name field, type AWS_SECRET_ACCESS_KEY.
  • In the Secret field, paste your AWS secret access key.
  • Click Add secret.

You should now see both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY listed. GitHub will never display the actual values again.

Why GitHub Secrets instead of hardcoding?

Your workflow file is committed to Git and visible to anyone with access to the repository. Pasting an AWS access key directly in that file would expose it.

GitHub Secrets are encrypted at rest and only injected into the runner at execution time. Your workflow references them with ${{ secrets.NAME }} syntax, so the actual value never appears in your code.

Update your workflow to authenticate and push to ECR

Your workflow currently builds the image and throws it away. You need three new steps: authenticate with AWS using your secrets, log in to ECR, and build and push the image to your registry.

  • In VS Code, open .github/workflows/ci.yml.
  • Find these two lines at the bottom of the file:
      - name: Build Docker image
        run: docker build -t devops-pipeline-app .
  • Delete those two lines. In their place, add the following code to configure AWS credentials:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

What does this step do?

The aws-actions/configure-aws-credentials@v6 action reads your GitHub Secrets and configures the runner's AWS credentials. Every AWS command that runs after this step (like logging in to ECR) uses these credentials automatically.

  • Directly below the Configure AWS credentials step, add the ECR login and build-and-push steps:
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push Docker image
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          REPOSITORY: devops-pipeline-app
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG .
          docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG

What do these steps do?

  • Login to Amazon ECR uses aws-actions/amazon-ecr-login@v2 to authenticate Docker with your ECR registry. The id: login-ecr gives this step a name so later steps can read its output.
  • Build and push Docker image pulls the registry URL from the login step's output using ${{ steps.login-ecr.outputs.registry }}. It tags the image with ${{ github.sha }}, which is the exact commit hash that triggered the pipeline.

Why tag with the commit SHA?

Tagging each image with the commit SHA gives you full traceability. Every image in your registry maps back to the exact code that produced it. If something breaks in production, you can trace the image tag back to the commit and see exactly what changed.

  • Save ci.yml.

Your workflow now has four steps: checkout, configure credentials, login to ECR, and build-and-push.

✔️ Awesome, I've got everything!

Make sure you have saved ci.yml before moving on.

ⓧ I'd like to double check the full code

Your ci.yml should have these sections in order. Scroll up and confirm each one is present:

  • The workflow header (name: CI/CD Pipeline, trigger on push to main, job config with runs-on: ubuntu-latest).
  • Step 1: Checkout code using actions/checkout@v6.
  • Step 2: Configure AWS credentials using aws-actions/configure-aws-credentials@v6 with your three with: inputs.
  • Step 3: Login to Amazon ECR using aws-actions/amazon-ecr-login@v2 with id: login-ecr.
  • Step 4: Build and push Docker image with three env: variables (REGISTRY, REPOSITORY, IMAGE_TAG) and a run: block with docker build and docker push.

Make sure the old Build Docker image step (the two-line version from Step 4) is completely gone. It has been replaced by the four steps above.

YAML not looking right?

  • YAML is indentation-sensitive. Each - name: line should be indented 6 spaces (aligned with the existing Checkout step).
  • Make sure you deleted the old Build Docker image step entirely. Having both the old and new build steps will cause conflicts.
  • Check that the ${{ }} expressions are copied exactly, including the double curly braces.

Help me fix my ci.yml YAML indentation and structure.

Push your changes and verify the pipeline

Everything is wired up. Your secrets are stored in GitHub, and your workflow knows how to use them. Time to push and let the pipeline do its job.

  • Commit and push your updated workflow by running these commands in your terminal:
git add .
git commit -m "Add ECR push to CI/CD pipeline"
git push
  • Switch back to your GitHub repository in your browser.
  • Click the Actions tab at the top of the repository.
  • Click on the latest workflow run (it will show the commit message you just used).

Watch the workflow progress through each step. This time, it does not just build. It authenticates with AWS, logs in to ECR, and pushes the image to your private registry.

Pipeline showing a red X?

  • Click on the failed job to see which step failed. If the Configure AWS credentials step failed, double check that your secret names are exactly AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in your repository settings.
  • If the Login to Amazon ECR step failed, verify that the IAM user behind your access key has ECR permissions.
  • If the Build and push step failed, confirm that the ECR repository devops-pipeline-app still exists in us-east-1.

Help me debug my failed GitHub Actions pipeline that pushes to ECR.

Once the pipeline finishes with a green checkmark, your image is sitting in ECR. Before you run the verification command, what do you expect to see when you list the images in your ECR repository?

  • Verify the image landed in ECR by running this command in your terminal:
aws ecr list-images --repository-name devops-pipeline-app --region us-east-1

You should see JSON output listing your image. The imageTag matches the commit SHA from your latest push. That tag traces this exact image back to the exact code that produced it.

Not seeing your image?

  • Make sure the pipeline finished with a green checkmark before running the command.
  • Check that your aws configure region matches us-east-1. The repository only exists in that region.

Help me troubleshoot why my ECR image is not showing up.

Your pipeline is fully operational. Every push to main triggers an automatic build and push to your private AWS registry, with zero hardcoded secrets. Next up, you can take on the Secret Mission and add automated tests so broken code never reaches your registry.

Secret mission

Add Automated Testing to Guard Your Pipeline

Your pipeline pushes every build to ECR, even if the code is broken. Add automated pytest tests as a quality gate, then intentionally break your app to prove the tests block the bad build from ever reaching your registry.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses AWS ECR, which provides 500 MB of free private storage for 12 months.

Cost warning

Your ECR repository stores Docker images in AWS. Private repositories get 500 MB of free storage for your first 12 months. After the free tier expires, storage costs $0.10 per GB per month.

A single Flask app image is well under 500 MB, so you will not incur charges while the free tier is active. If you are past your 12-month window, consider pausing or deleting your AWS resources.

Resources you used:

  • AWS ECR repository (devops-pipeline-app) with Docker images stored in us-east-1.
  • GitHub Secrets (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY) stored in your repository settings.
  • Local Docker image (devops-pipeline-app) on your machine.
  • Local project folder (devops-pipeline) on your Desktop.
  • GitHub repository (devops-pipeline).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep pushing code to trigger your pipeline.

  • Your ECR repository is well within the 500 MB free tier. A single small Docker image costs nothing for the first 12 months.
  • Your pipeline continues to build and push images on every push to main.
  • GitHub Secrets remain stored securely in your repository settings and cost nothing.

Pause - I'll come back to this later

Remove cloud resources to stop potential storage charges after the free tier. Keep your local files and GitHub repository so you can rebuild later.

  • In your terminal, navigate to your devops-pipeline folder and run the following command to destroy the ECR repository:
  • Type yes when Terraform prompts for confirmation.
cd ~/Desktop/devops-pipeline
terraform destroy
  • Your local code and GitHub repo stay intact. When you come back, run terraform apply to recreate the ECR repository.
  • Your GitHub Secrets are still in place, so the pipeline will work again as soon as ECR is recreated.
  • The pipeline will fail until you recreate the ECR repo. That is expected.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps in order, since terraform destroy needs your local state file to work.

  • Destroy the ECR repository by running these commands in your terminal:
cd ~/Desktop/devops-pipeline
terraform destroy
  • Type yes when Terraform prompts for confirmation. This deletes the ECR repository and all images stored in it.
  • Delete your GitHub Secrets. In your GitHub repository, click Settings.
  • In the left sidebar under Security, select Secrets and variables, then click Actions.
  • Click the Secrets tab. Delete both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY by clicking the trash icon next to each one.
  • Remove the local Docker image by running:
docker rmi devops-pipeline-app
  • Delete the local project folder. On macOS or Linux, run:
rm -rf ~/Desktop/devops-pipeline
  • On Windows, run:
Remove-Item -Recurse -Force ~\Desktop\devops-pipeline
  • Optionally, delete the GitHub repository. In your repository on GitHub, click Settings.
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Follow the confirmation prompts and type the repository name to confirm deletion.

Keep your workflow history

Even if you delete the ECR repository, your GitHub repository still shows your green pipeline runs in the Actions tab. Consider keeping the repo as a portfolio piece that demonstrates your CI/CD skills to interviewers.

Nice Work!

Nice Work!

Nice work! You just built a complete DevOps pipeline from scratch. Your code flows from a local Flask API to a private container registry on AWS, fully automated and secured.

You've learned how to:

  • Containerized a Flask API with Docker using a production-ready python:3.13-slim base image.
  • Provisioned cloud infrastructure (AWS ECR) using Terraform infrastructure-as-code, making your setup repeatable and version-controlled.
  • Created a CI/CD pipeline with GitHub Actions that automatically builds and pushes Docker images to AWS ECR on every push, with credentials secured through GitHub Secrets.
  • Secret Mission: Completed an optional challenge to guard your pipeline with automated tests.

Ready to quiz yourself?