CI/CD Pipeline with GitHub Actions
Build an automated pipeline that tests, scans, and ships a Docker image to GHCR.
Introduction
30 Second Summary
Every time a developer pushes code, someone has to make sure it works, check it for security holes, package it up, and ship it somewhere safe. Doing all of that manually is slow, error-prone, and honestly a bit boring.
In this project, you will build an automated pipeline that handles all of those steps for you. Every push to your main branch triggers tests, a security scan, a Docker image build, and a publish to a container registry.
What You'll Build
A fully automated CI/CD pipeline using GitHub Actions, Docker, and Trivy that takes a Python Flask API from code push to a production-ready, security-scanned container image on GitHub Container Registry.
By the end of this project, you'll have:
- A Python Flask REST API with health check and version endpoints, containerized using a multi-stage Dockerfile.
- A GitHub Actions workflow that automatically runs tests, scans for vulnerabilities, builds an optimized Docker image, and publishes it on every push to main.
- A live container image on GHCR tagged with the exact git commit SHA for production traceability.
- Secret Mission: Add a manual deployment gate with environment protection rules and a Discord notification on successful builds.
Are there any prerequisites?
You should have basic programming knowledge and a GitHub account. You will also need Python, Docker Desktop, and Git installed locally. Step 1 walks you through verifying each of these tools.
Set Up Your Project and Verify Tools
Every CI/CD pipeline starts with a clean project structure. Organizing your code, tests, and workflow files into dedicated directories keeps the pipeline configuration simple and maintainable.
In this step, you will scaffold the project locally, initialize Git, and connect it to a GitHub repository so that your pipeline has somewhere to live.
In this step, get ready to:
- Create the devops-pipeline directory with app/, tests/, and .github/workflows/ subdirectories.
- Initialize a Git repository and add a .gitignore file.
- Create a public GitHub repository and link your local project to it.
Create the project structure locally
Your pipeline project needs three distinct areas: one for application code, one for tests, and one for GitHub Actions workflow files.
- In Antigravity IDE, open a terminal 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 subdirectories for your app code, tests, and GitHub Actions workflows by running:
mkdir -p app tests .github/workflows
- Confirm the directories were created by running:
ls
You should see app and tests listed in the output.
Where is .github?
The .github folder is a hidden directory (it starts with a dot). It will not show up in a basic ls output, but it exists. You can verify by running ls -a to see hidden files.
- Initialize a Git repository in this folder by running:
git init
You should see a message saying Initialized empty Git repository followed by the path to your folder.
Seeing 'command not found'?
If git init fails, Git may not be in your terminal's PATH. Close and reopen the terminal in Antigravity IDE, then try again.
Why is git init not working in my terminal inside Antigravity IDE?
- Open your devops-pipeline folder in Antigravity IDE by clicking File in the top menu bar.
- Click Open Folder.
- Select the devops-pipeline folder on your Desktop, then click Open.
You should see the app and tests folders in the Explorer pane on the left.
- Create the .gitignore file by right-clicking in the Explorer pane and selecting New File.
- Name the file .gitignore and press Enter.
- Paste the following content into the file:
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
*.egg-info/
dist/
build/
.pytest_cache/
.coverage
htmlcov/
What does .gitignore do?
The .gitignore file tells Git which files and folders to exclude from version control. These are things like compiled Python bytecode (__pycache__/), virtual environments (venv/), and test artifacts (.pytest_cache/).
Keeping these out of your repository means your pipeline only processes the source code that actually matters.
- Save .gitignore by pressing Cmd+S (macOS) or Ctrl+S (Windows).
File not appearing in Explorer?
Files starting with a dot are hidden by default on some systems. In the Explorer pane, check that hidden files are visible. You can also confirm it exists by running ls -a in the terminal.
Help me show hidden files in Antigravity IDE
✔️ Awesome, I've got everything!
Great. Double check you have saved .gitignore before moving on.
ⓧ I'd like to double check the full code
Here is the complete .gitignore file for comparison:
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
*.egg-info/
dist/
build/
.pytest_cache/
.coverage
htmlcov/
Create a new public GitHub repository
Your local project needs a remote home on GitHub. This is where GitHub Actions will detect your pushes and trigger the pipeline.
- Open your browser and go to github.com.
- Click the + icon in the top-right corner of the page.
- Select New repository.
- Enter devops-pipeline as the Repository name.
- Select Public for the visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why leave everything unchecked?
You already have a local Git repo with a .gitignore. If GitHub creates its own files, you will have to deal with merge conflicts on your very first push. Starting with an empty remote keeps things clean.
After creating the repository, GitHub shows a quick setup page with your repository URL.
- Switch back to your terminal in Antigravity IDE.
- Connect your local repository to GitHub by running this command:
git remote add origin [[REPO_URL="https://github.com/your-username/devops-pipeline.git"]]
What does this command do?
The git remote add origin command creates a named link (called origin) between your local repository and the remote one on GitHub. Every time you push code, Git knows where to send it.
- Verify the connection by running:
git remote -v
You should see two lines showing your GitHub repository URL, one for fetch and one for push.
Seeing 'fatal: remote origin already exists'?
This means a remote named origin was already added. Remove it first by running git remote remove origin, then repeat the git remote add command with the correct URL.
Help me fix the remote origin error
Your project skeleton is ready and connected to GitHub. Next up, you will write the Flask API and tests that your pipeline will build and ship.
Build the Flask API and Tests
Your project structure is ready and connected to GitHub. Now it's time to build the actual application that your pipeline will test, scan, and deploy.
In this step, you'll create a simple Python REST API with two endpoints, write automated tests to verify those endpoints work, and document everything in a professional README.
In this step, get ready to:
- Create a Flask REST API with health check and version endpoints.
- Write a pytest test suite that verifies both endpoints.
- Document the project with a professional README.
Create the Flask application
Every CI/CD pipeline needs something to build and deploy. You'll create a minimal Flask API with two endpoints that report the app's health and version.
- In your devops-pipeline project, create a new file called main.py inside the app/ folder.
- Paste the following code into app/main.py:
from flask import Flask, jsonify
app = Flask(__name__)
VERSION = "1.0.0"
@app.route("/health")
def health():
return jsonify({"status": "healthy"}), 200
@app.route("/version")
def version():
return jsonify({"version": VERSION}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
What does this code do?
This file defines your entire Flask application.
- VERSION = "1.0.0" stores your app's version as a constant so it can be returned by the API and referenced in tests.
- The /health endpoint returns a JSON status check. Monitoring tools and load balancers use endpoints like this to confirm your app is alive.
- The /version endpoint returns the current app version. This is useful for confirming which version of your code is deployed.
- The if __name__ == "__main__" block lets you run the app directly with Python, but in production you'll use a WSGI server instead.
- Save app/main.py.
Next, your app needs its dependencies listed so they can be installed consistently across environments.
- Create a file called requirements.txt in the root of your devops-pipeline folder.
- Paste the following into requirements.txt:
Flask==3.1.3
pytest==9.1.1
gunicorn==23.0.0
What are these packages?
- Flask==3.1.3 is the web framework for your API.
- pytest==9.1.1 is the testing framework you'll use to verify your endpoints work correctly.
- gunicorn==23.0.0 is a production-grade WSGI server. You won't use it locally, but your Docker container will use it to serve the app in production.
Pinning exact versions ensures every environment (your machine, CI, production) uses the same packages. This prevents "it works on my machine" bugs.
- Save requirements.txt.
- Install the dependencies locally by running this command in your terminal:
pip install -r requirements.txt
- You should see output showing each package being downloaded and installed, ending with a "Successfully installed" message.
Install failing?
- Make sure you're running the command from inside your devops-pipeline directory where requirements.txt lives.
- If you see a permissions error, try pip install --user -r requirements.txt instead.
- help me fix my pip install error
Now verify the app actually runs.
- Start the Flask development server by running:
python -m flask --app app.main run
- You should see output similar to:
* Serving Flask app 'app.main'
* Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
- Open a browser and navigate to http://localhost:5000/health.
- You should see the JSON response: {"status":"healthy"}.
- Return to your terminal and press Ctrl+C to stop the development server.
Not seeing the JSON response?
- Make sure you're visiting http://localhost:5000/health (not just localhost:5000). The root path has no route defined.
- If port 5000 is already in use, you'll see an OSError in the terminal. Stop whatever process is using that port and try again.
- help me debug my Flask app not responding
✔️ Awesome, I've got everything!
Great. Make sure you've saved app/main.py and requirements.txt, and that the server stopped after you pressed Ctrl+C.
ⓧ I'd like to double check the full code
from flask import Flask, jsonify
app = Flask(__name__)
VERSION = "1.0.0"
@app.route("/health")
def health():
return jsonify({"status": "healthy"}), 200
@app.route("/version")
def version():
return jsonify({"version": VERSION}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Flask==3.1.3
pytest==9.1.1
gunicorn==23.0.0
Write the test suite
Automated tests are the first gate in any CI/CD pipeline. If tests fail, nothing else runs. You'll write tests using pytest and Flask's built-in test client.
- Create a new file called test_app.py inside the tests/ folder.
- Paste the following code into tests/test_app.py:
import pytest
from app.main import app
@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as client:
yield client
def test_health_endpoint(client):
response = client.get("/health")
assert response.status_code == 200
data = response.get_json()
assert data["status"] == "healthy"
def test_version_endpoint(client):
response = client.get("/version")
assert response.status_code == 200
data = response.get_json()
assert "version" in data
assert data["version"] == "1.0.0"
What does this test code do?
- The @pytest.fixture creates a reusable test client. Every test function that accepts client as a parameter automatically gets a fresh Flask test client.
- app.config["TESTING"] = True puts Flask into test mode, which disables error catching so test failures show the real error.
- Each test function sends a GET request to an endpoint and asserts the response has the correct status code and JSON data.
- These tests verify the exact behavior your pipeline will check on every push. If someone breaks the health endpoint, the pipeline catches it before deployment.
- Save tests/test_app.py.
- Run the tests locally by running:
pytest tests/
- You should see output showing 2 tests passed, similar to:
========================= 2 passed in 0.15s =========================
Tests failing?
- Make sure you're running pytest tests/ from the devops-pipeline root directory, not from inside tests/.
- If you see ModuleNotFoundError: No module named 'app', confirm you're in the devops-pipeline directory. Python needs to find the app/ package from the project root.
- help me fix my pytest errors
✔️ Awesome, I've got everything!
Great. Make sure you've saved tests/test_app.py.
ⓧ I'd like to double check the full code
import pytest
from app.main import app
@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as client:
yield client
def test_health_endpoint(client):
response = client.get("/health")
assert response.status_code == 200
data = response.get_json()
assert data["status"] == "healthy"
def test_version_endpoint(client):
response = client.get("/version")
assert response.status_code == 200
data = response.get_json()
assert "version" in data
assert data["version"] == "1.0.0"
Create the README
A good README is essential for any professional project. It tells other developers (and future you) how the project works, how to run it locally, and what the pipeline does.
- Create a file called README.md in the root of your devops-pipeline folder.
- Paste the following into README.md:
# DevOps Pipeline
Automated CI/CD pipeline using GitHub Actions, Docker, and Trivy security scanning.
## Pipeline Architecture
```
Push to main
|
v
[Run Tests] --> [Build Image] --> [Trivy Scan] --> [Push to GHCR]
| |
v v
pytest suite ghcr.io/username/devops-pipeline:sha
```
## Local Development
```bash
# Install dependencies
pip install -r requirements.txt
# Run the app
python -m flask --app app.main run
# Run tests
pytest tests/
# Build Docker image
docker build -t devops-pipeline:local .
# Run container
docker run -p 5000:5000 devops-pipeline:local
```
## Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| /health | GET | Health check |
| /version | GET | App version |
## Technologies
- Python / Flask
- Docker (multi-stage build)
- GitHub Actions (CI/CD)
- Trivy (vulnerability scanning)
- GitHub Container Registry (image hosting)
What does this README include?
- A text-based pipeline architecture diagram showing the flow from code push to published container image.
- Local development commands so anyone cloning the repo can get started immediately.
- An endpoint reference table documenting your API's routes.
- A technologies section listing the tools your pipeline uses. You'll add more details (like a workflow status badge) in a later step.
- Save README.md.
- Confirm all your files exist by running:
ls
- You should see app, tests, requirements.txt, README.md, and .gitignore in the output.
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files.
ⓧ I'd like to double check the full code
# DevOps Pipeline
Automated CI/CD pipeline using GitHub Actions, Docker, and Trivy security scanning.
## Pipeline Architecture
```
Push to main
|
v
[Run Tests] --> [Build Image] --> [Trivy Scan] --> [Push to GHCR]
| |
v v
pytest suite ghcr.io/username/devops-pipeline:sha
```
## Local Development
```bash
# Install dependencies
pip install -r requirements.txt
# Run the app
python -m flask --app app.main run
# Run tests
pytest tests/
# Build Docker image
docker build -t devops-pipeline:local .
# Run container
docker run -p 5000:5000 devops-pipeline:local
```
## Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| /health | GET | Health check |
| /version | GET | App version |
## Technologies
- Python / Flask
- Docker (multi-stage build)
- GitHub Actions (CI/CD)
- Trivy (vulnerability scanning)
- GitHub Container Registry (image hosting)
Your Flask API is built, tested, and documented. Next up, you'll containerize it with a multi-stage Dockerfile that produces a slim, production-ready image.
Containerize with a Multi-Stage Dockerfile
Your Flask API and tests work great on your machine, but what about running them on a server or in a CI pipeline? Docker solves this by packaging your app, its dependencies, and its runtime into a single portable image.
In this step, you will write a multi-stage Dockerfile that runs tests during the build and produces a slim production image. Multi-stage builds let you throw away build-time tools and keep only what the app needs to run, shrinking the final image dramatically.
In this step, get ready to:
- Write a multi-stage Dockerfile with build and production stages.
- Build and run the Docker image locally.
- Validate the image size is production-slim.
Write the multi-stage Dockerfile
A multi-stage Dockerfile uses multiple FROM statements to separate concerns. The first stage installs everything and runs tests. The second stage copies only what is needed into a clean, minimal image.
- In the Explorer sidebar of Antigravity, right-click inside your devops-pipeline folder and select New File.
- Name the file Dockerfile (no file extension).
- Add the builder stage by pasting the following code into your new file:
# Stage 1: Build and test
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN pytest tests/
What does this stage do?
This is the builder stage. It starts from the python:3.12-slim base image and sets /app as the working directory.
It copies requirements.txt first and installs dependencies. This ordering lets Docker cache the dependency layer, so rebuilds only re-install when requirements.txt changes. Then it copies all project files and runs the test suite. If any test fails, the build stops here.
- Below the builder stage, add the production stage:
# Stage 2: Production
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app.main:app"]
What does this stage do?
This production stage starts fresh from the same python:3.12-slim base. It only copies app/ into the image. The test files, source control files, and build artifacts from stage 1 are discarded.
The CMD runs gunicorn instead of Flask's development server. Gunicorn is a production-grade WSGI server that handles concurrent requests safely.
Why multi-stage instead of a single stage?
A single-stage Dockerfile that includes test dependencies, source files, and build tools can easily reach 800MB or more. Multi-stage builds produce a final image containing only the runtime dependencies and your application code.
This smaller surface area means faster pulls, less storage cost, and fewer packages for attackers to exploit.
- Save Dockerfile.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
# Stage 1: Build and test
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN pytest tests/
# Stage 2: Production
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app.main:app"]
Build and test the Docker image locally
With the Dockerfile written, you can now build the image. Docker will execute both stages, running your tests along the way.
- Make sure Docker Desktop is running.
- Build the Docker image by running this command in your terminal:
docker build -t devops-pipeline:local .
What does this command do?
- docker build reads your Dockerfile and builds an image.
- -t devops-pipeline:local tags the image with the name devops-pipeline and the tag local.
- . tells Docker to use the current directory as the build context.
- Wait for the build to complete. You should see your pytest tests pass as part of the builder stage, followed by a success message.
Build failing at the pytest step?
- Make sure your tests pass locally first by running pytest tests/ in your terminal.
- Confirm that requirements.txt includes pytest==9.1.1. Without it, the builder stage cannot find the pytest command.
- Check that Dockerfile is in the root of devops-pipeline, not inside a subfolder.
Why is my Docker build failing at the test step?
- Run a container from your new image by running this command:
docker run -p 5000:5000 devops-pipeline:local
What does this command do?
docker run starts a container from your image. The -p 5000:5000 flag maps port 5000 on your machine to port 5000 inside the container, so you can reach the app from your browser.
- Open a browser and visit http://localhost:5000/health.
- You should see {"status":"healthy"} in the browser. This confirms your containerized app is serving requests through gunicorn.
- Return to your terminal and press Ctrl+C to stop the container.
Not seeing a response at localhost:5000?
- Make sure Docker Desktop is running. Without it, the docker run command fails silently.
- Check that nothing else is using port 5000. If Flask is still running from Step 2, stop it first.
- Confirm your terminal shows gunicorn startup output, not an error traceback.
Why can't I reach my containerized app at localhost:5000?
Validate the image size
One of the key benefits of multi-stage builds is producing a smaller final image. Let's confirm that your image is significantly smaller than a naive build would be.
- Check the size of your Docker image by running:
docker images devops-pipeline:local
- Look at the SIZE column in the output. Your image should be well under 200MB.
- A naive single-stage Dockerfile with all development dependencies would be 800MB or more. Your multi-stage build produces a production image that is roughly 4x smaller.
Why does image size matter?
Smaller images pull faster in CI/CD pipelines, reducing your build-to-deploy cycle. They also use less container registry storage and present a smaller attack surface because fewer packages are installed.
Image larger than expected?
- Make sure your production stage uses python:3.12-slim as the base, not python:3.12 (the full image).
- Confirm you are only copying app/ into the production stage, not the entire project root.
- Check that --no-cache-dir is present on both pip install lines. Without it, pip stores cached wheels that bloat the image.
Why is my Docker image larger than 200MB?
Your app is now packaged in a production-ready container image. Next up, you will create a GitHub Actions workflow that automates building, scanning, and publishing this image on every push.
Create the GitHub Actions CI/CD Pipeline
Your Docker image is built, tested, and optimized. But right now, building and pushing that image is a manual process that depends on you remembering every command in the right order.
In this step, you'll create a GitHub Actions workflow that automates the entire pipeline. Every push to main will trigger tests, a security scan, and an image push to GitHub Container Registry without you lifting a finger.
In this step, get ready to:
- Write a GitHub Actions workflow YAML file with triggers and job configuration.
- Add test and security scanning steps using Trivy.
- Add conditional build and push steps that only run on main.
Write the workflow YAML
A CI/CD pipeline needs a configuration file that tells the automation platform what to do on each trigger. GitHub Actions reads workflow files from a special .github/workflows/ directory in your repository.
- In your devops-pipeline project, create a new file called ci-cd.yml inside the .github/workflows/ directory.
- Add the pipeline header and job definition by pasting the following code into the file:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-scan-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
What does this code do?
This sets up the skeleton of your CI/CD pipeline.
- The on: block defines when the pipeline runs. It triggers on pushes to main and on pull requests targeting main.
- The env: block sets reusable variables. REGISTRY points to ghcr.io (GitHub's container registry) and IMAGE_NAME automatically uses your GitHub repository name.
- The permissions block grants the job read access to your code and write access to publish packages.
You won't see a visible change yet. The next substep adds the actual pipeline steps below the steps: key.
Add the test and scan steps
The first half of your pipeline handles quality gates. It checks out your code, runs your test suite, builds a Docker image, and then scans that image for known vulnerabilities before anything gets published.
- In ci-cd.yml, add the following directly below the steps: key (indented 6 spaces to sit inside the steps array):
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/
- name: Build Docker image for scanning
run: docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
format: "table"
exit-code: "1"
ignore-unfixed: true
vuln-type: "os,library"
severity: "CRITICAL,HIGH"
What does this code do?
These steps form the testing and security scanning stage of your pipeline.
- actions/checkout@v6 clones your repository code onto the runner.
- actions/setup-python@v5 installs Python 3.12 on the runner so your tests can execute.
- The docker build step tags the image with the commit SHA (${{ github.sha }}) so every build is traceable to a specific commit.
- Trivy scans the built image for CRITICAL and HIGH severity vulnerabilities. The exit-code: "1" setting means the pipeline fails if vulnerabilities are found. This is your security gate.
The ignore-unfixed: true option ensures the pipeline only fails on vulnerabilities that have an available patch. Failing on unfixed issues would block your pipeline with no actionable fix.
You won't see a change yet. The next substep adds the final push steps that publish your image.
Add the build and push steps
The final stage of your pipeline only runs on pushes to main. This prevents pull request builds from publishing unreviewed images to your registry.
- In ci-cd.yml, add the following below the Trivy scanner step (keep the same 6-space indentation):
- name: Log in to the Container registry
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
What does this code do?
These three steps handle authentication, tagging, and publishing your Docker image.
- The if: condition on each step ensures they only run when code is pushed directly to main. Pull requests run tests and scans but never publish.
- docker/login-action authenticates with GHCR using the built-in GITHUB_TOKEN secret. No manual token setup required.
- docker/metadata-action generates proper image tags and OCI labels from your repository metadata.
- docker/build-push-action builds the final image and pushes it to GHCR in one step.
The actions are pinned to full commit SHAs instead of version tags. This is a security best practice that prevents supply-chain attacks where a compromised tag could point to malicious code.
- Save .github/workflows/ci-cd.yml.
- Confirm the file exists by running:
ls .github/workflows/
You should see ci-cd.yml in the output.
Don't see the file?
- Make sure you created the file inside .github/workflows/ (note the dot at the start of .github).
- Check that you saved the file with the exact name ci-cd.yml (not .yaml or a different spelling).
- Confirm you're running the ls command from the devops-pipeline root directory.
Still stuck? Help me find my ci-cd.yml workflow file
Why pin actions to commit SHAs?
Version tags like v3 are mutable. A compromised maintainer could push malicious code under an existing tag. Pinning to a full SHA (like 65b78e6e13532edd9afa3aa52ac7964289d1a9c1) guarantees you always run the exact code you reviewed.
GitHub's own documentation uses this pattern for their publishing workflows. It adds traceability and protects your pipeline from upstream tampering.
✔️ Awesome, I've got everything!
Great. Double check you've saved .github/workflows/ci-cd.yml and you're ready for the next step.
ⓧ I'd like to double check the full code
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-scan-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/
- name: Build Docker image for scanning
run: docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
format: "table"
exit-code: "1"
ignore-unfixed: true
vuln-type: "os,library"
severity: "CRITICAL,HIGH"
- name: Log in to the Container registry
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
Your pipeline is defined and ready to go. In the next step, you'll push everything to GitHub and watch the pipeline run for the first time.
Push, Verify, and Document the Pipeline
Your entire pipeline is assembled locally. The Flask API, the Dockerfile, the tests, and the workflow YAML are all ready to go.
Now comes the moment of truth. You will push everything to GitHub and watch your automation take over. If everything is wired correctly, your pipeline will run tests, scan for vulnerabilities, build the image, and publish it to GitHub Container Registry without you lifting a finger.
In this step, get ready to:
- Commit and push all project files to GitHub.
- Watch the CI/CD pipeline execute on GitHub Actions.
- Pull and run your published container image from GHCR.
Commit and push everything to GitHub
Git tracks changes in snapshots called commits. Each commit bundles a set of file changes with a message describing what you did.
- Stage all your project files for commit by running this command:
git add .
- Create your first commit with a descriptive message by running:
git commit -m "feat: add CI/CD pipeline with Docker and Trivy scanning"
What does this commit message mean?
The feat: prefix is a convention from Conventional Commits. It signals that this commit adds a new feature. Teams use this pattern to auto-generate changelogs and version bumps.
- Push your commit to the remote main branch by running:
git push -u origin main
- You should see output showing objects being written to github.com and the branch main being set up to track the remote.
Push rejected or authentication failed?
If you see an authentication error, make sure you are signed in to GitHub via the terminal. You may need to use a personal access token or SSH key depending on your setup.
If the push is rejected because the remote contains work you do not have locally (like a default README), run git pull --rebase origin main first, then push again.
Help me troubleshoot my git push
Watch the pipeline run
Pushing to main triggers your workflow automatically. GitHub Actions picks up the YAML file and starts executing every step you defined.
- Open your browser and go to your devops-pipeline repository on GitHub.
- Click the Actions tab at the top of the repository page.
- You should see a workflow run in progress (or recently completed) named CI/CD Pipeline.
- Click on the workflow run to expand it and watch each step execute.
What should I see?
Each step in the workflow shows a green checkmark as it completes. The full pipeline typically finishes in 2-4 minutes. You will see output logs for pytest, the Docker build, the Trivy scan results, and the GHCR push.
If Trivy finds CRITICAL or HIGH vulnerabilities with available fixes, the pipeline will fail at that step. This is the security gate working as intended.
- Return to the main page of your repository by clicking the repository name at the top.
- Verify you see a green checkmark next to your commit message. This confirms the entire pipeline passed.
- Click Packages in the right sidebar of your repository page to see your published container image.
What is the Packages section?
GitHub Packages is where GHCR stores your container images. Your image appears here tagged with both main and the commit SHA.
The SHA tag is what professionals use for production traceability. You can run git show {sha} to see exactly which code is in any deployed image.
Pipeline failed at the Trivy scan step?
This means Trivy found CRITICAL or HIGH vulnerabilities with available fixes in your base image. Check the Trivy output log for the specific CVEs listed.
If the vulnerabilities are in the base image rather than your code, you can temporarily set exit-code to "0" in the workflow to allow the build to pass, then revert it once the base image is updated.
Help me understand my Trivy scan failure
Pull and run your published image
The real test of a CI/CD pipeline is whether the published artifact actually works. You will now pull the image that your pipeline built and run it locally to prove the full loop.
- Pull your published image from GHCR by running this command (replace your GitHub username):
docker pull ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/devops-pipeline:main
- Run the pulled image locally by running:
docker run -p 5000:5000 ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/devops-pipeline:main
- Open your browser and go to http://localhost:5000/health to confirm the container is running.
- You should see {"status":"healthy"} in the response. This proves that the image your pipeline built and pushed to GHCR is fully functional.
- Stop the container by pressing Ctrl+C in the terminal.
Seeing 'denied' or 'not found' when pulling?
If the image is not found, double-check your username is lowercase in the pull command. GHCR normalizes repository names to lowercase.
If you see a permission denied error, you may need to authenticate with GHCR first. Run docker login ghcr.io using your GitHub username and a personal access token with read:packages scope. For public repos, pulling should work without authentication.
Help me fix my GHCR pull error
Now add a workflow status badge to your README so anyone visiting your repository can instantly see whether the pipeline is passing.
- Open README.md in your editor.
- Add this line at the very top of the file, above the # DevOps Pipeline heading:

What does this badge do?
This markdown image tag points to a special GitHub URL that returns a live SVG badge. It dynamically shows "passing" (green) or "failing" (red) based on the latest workflow run on your default branch.
- Save README.md.
- Commit and push the badge update by running:
git add README.md
git commit -m "docs: add workflow status badge"
git push
- Refresh your repository page on GitHub. You should see the badge at the top of your README showing a green "passing" status.
You now have a complete, working CI/CD pipeline. Every push to main triggers automated testing, security scanning, and container publishing. Next up, a secret mission to add deployment gates and notifications.
Secret mission
Add Deployment Gate and Notifications
Your pipeline builds, scans, and pushes automatically. But in production, you want a human checkpoint before deployment. Add a manual approval gate using GitHub Environments and wire up a Discord notification so your team knows when a deploy goes through.
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 runs on free GitHub services (public repos get unlimited Actions minutes and free GHCR storage), so there are no ongoing costs.
Resources you used:
- GitHub public repository devops-pipeline (includes GHCR container images, environment settings, and secrets).
- Local devops-pipeline project folder on your Desktop.
- Local Docker images built during development.
- Discord webhook in your Discord server.
Keep everything running
No action needed. Choose this if you plan to keep iterating on your pipeline or want it as a portfolio piece.
- Your repository, container images, and GitHub Actions history persist for free on public repos.
- Your local devops-pipeline folder stays on your Desktop for future edits.
- Docker Desktop and any local images remain on your machine.
- Your Discord webhook remains active and will fire on future pipeline runs.
Pause - I'll come back to this later
Nothing is actively running or costing money. There is no difference between Keep and Pause for this project because all services are free and event-driven.
- Your pipeline only runs when you push to main or open a pull request. No pushes means no Actions usage.
- Your GHCR images sit in storage at no cost on public repos.
- If you want to free up disk space, remove local Docker images by running:
docker rmi devops-pipeline:local
- You can quit Docker Desktop to reclaim memory until you need it again.
Delete - I don't want to use this again
Remove all project resources and start fresh. Deleting the GitHub repository also removes all GHCR images, environments, secrets, and Actions history associated with it.
- Delete the GitHub repository:
- Go to your devops-pipeline repository on GitHub.
- Click Settings in the top navigation bar.
- Scroll down to the Danger Zone section.
- Click Delete this repository.
- Click I want to delete this repository, then click I have read and understand these effects.
- Type the repository name to confirm, then click Delete this repository.
- Delete the local project folder. On macOS/Linux, run:
rm -rf ~/Desktop/devops-pipeline
- On Windows, run:
rmdir /s /q "$HOME\Desktop\devops-pipeline"
- Remove local Docker images by running:
docker rmi devops-pipeline:local
- Delete the Discord webhook from your Discord server by going to Server Settings > Integrations > Webhooks, selecting your webhook, and clicking Delete Webhook.
Nice Work!
Nice Work!
Nice work! You just built a production-grade CI/CD pipeline that takes a Python Flask API from code push to a security-scanned, published container image on GitHub Container Registry.
You've learned how to:
- Set up a complete GitHub Actions CI/CD pipeline that automatically runs tests, scans for vulnerabilities with Trivy, builds optimized Docker images, and pushes them to GHCR on every push to main.
- Create a multi-stage Dockerfile that produces a slim production image and tag it with the commit SHA for full traceability back to source code.
- Integrate container security scanning as a pipeline gate that blocks deployments when critical or high-severity vulnerabilities are detected.
- Secret Mission: Added deployment gates with environment protection rules and Discord notifications for team visibility into successful builds.
Ready to quiz yourself?