CI/CD Pipeline with GitHub Actions
Automate testing and Docker image builds with a GitHub Actions pipeline.
Introduction
30 Second Summary
Every developer has experienced the "it works on my machine" moment. You push code, something breaks, and nobody can figure out why because everyone's environment is slightly different.
In this project, you will build a complete CI/CD pipeline using GitHub Actions that automatically tests your Python code and packages it into a Docker container every time you push.
What You'll Build
A fully automated CI/CD pipeline that runs tests, builds a Docker image, and publishes it to GitHub Container Registry every time you push code to GitHub.
By the end of this project, you'll have:
- A Python Flask web application with automated tests that validate your endpoints.
- A Dockerfile that packages your app into a portable container image.
- A GitHub Actions workflow that automatically runs tests and publishes your Docker image on every push.
- Secret Mission: Add multi-version matrix testing across Python 3.12 and 3.13 for production-grade confidence.
Are there any prerequisites?
You need basic command line familiarity and a GitHub account. The project uses Docker inside Ubuntu WSL, VS Code, and Git. All tools are confirmed available in your environment.
Before We Start
Before We Start...
Create the Project Repository
Every CI/CD pipeline starts with a code repository. GitHub stores your code and provides the infrastructure to run automated workflows every time you push a change.
In this step, you will create a public repository on GitHub, clone it into your WSL environment, and install the Python packages your project needs.
In this step, get ready to:
- Create a public GitHub repository called hello-cicd.
- Clone the repository into your WSL environment and open it in VS Code.
- Set up a Python virtual environment with Flask and pytest installed.
Create a new public repository on GitHub
A public repository lets GitHub Actions run your workflows for free with unlimited minutes.
- Open your browser and navigate to github.com.
- In the upper-right corner, click the + menu, then click New repository.
- Type hello-cicd in the Repository name field.
- Select Public for the repository visibility.
- Check the box labelled Add a README file.
- Click Create repository.
Why public?
GitHub Actions is free and unlimited on public repositories. A public repository also makes your CI/CD pipeline visible as a portfolio piece.
Don't see the New repository option?
Make sure you are signed in to your GitHub account. The + menu only appears when you are logged in.
help me find the New repository button on GitHub
Clone the repository into WSL and open it in VS Code
Cloning downloads a copy of your repository so you can work on it locally inside your Ubuntu WSL environment.
- Open your Ubuntu terminal by pressing the Windows key, typing Ubuntu, and pressing Enter.
- Move to your home directory by running this command:
cd ~
- Clone your new repository by running this command:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/hello-cicd.git
- Move into the cloned repository by running:
cd hello-cicd
- Open the repository in VS Code by running:
code .
What does code . do?
The code . command opens VS Code connected to your current WSL directory. The dot (.) means "this folder."
- Confirm the repository cloned successfully by running:
ls
You should see README.md listed in the output.
Seeing 'repository not found'?
Double-check that you typed your GitHub username correctly in the clone URL. The repository name must be exactly hello-cicd (all lowercase with a hyphen).
Make sure the repository is set to Public on GitHub. Private repositories require additional authentication configuration.
help me fix a git clone error in WSL
Create a Python virtual environment and install dependencies
A virtual environment keeps your project's Python packages isolated from the rest of your system. This prevents version conflicts between projects.
- Create a virtual environment inside your hello-cicd folder by running:
python3 -m venv venv
What does this command do?
python3 -m venv venv tells Python to create a virtual environment in a folder called venv. The first venv is the Python module name. The second venv is the folder it creates.
- Activate the virtual environment by running:
source venv/bin/activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
- Install Flask and pytest by running:
pip install flask==3.1.3 pytest==8.3.4
Why pin exact versions?
Pinning versions (flask==3.1.3) ensures everyone running this project gets the same behaviour. Without pins, a future update could break your tests or app.
- Verify the packages installed correctly by running:
pip show flask pytest
You should see version information for both packages, confirming flask at 3.1.3 and pytest at 8.3.4.
Seeing 'python3: command not found'?
Ubuntu WSL should have Python 3 pre-installed. If the command fails, update your package list and install Python by running:
sudo apt update && sudo apt install python3 python3-venv -y
Then retry the python3 -m venv venv command.
help me install Python 3 in my Ubuntu WSL environment
Your repository is cloned, your environment is ready, and your packages are installed. Next up, you will build the Flask app and write tests for it.
Build the Flask App and Tests
Your development environment is ready with Flask and pytest installed. Now it's time to write the application code that your pipeline will eventually automate.
A CI/CD pipeline is only useful if there's something to test and deploy. In this step, you'll build a minimal Flask web app with two endpoints, then write automated tests that verify they work correctly.
In this step, get ready to:
- Create the Flask application with a health check endpoint and a greeting endpoint.
- Write automated tests for both endpoints using pytest.
- Run the tests locally to confirm everything passes.
Create the Flask application file
Flask is a lightweight web framework that lets you define URL endpoints as Python functions. You'll create two endpoints that return JSON responses.
- In VS Code, create a new file called app.py in the root of your hello-cicd folder.
- Paste the following code into app.py:
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify({"status": "healthy"})
@app.route("/greeting/<name>")
def greeting(name):
return jsonify({"message": f"Hello, {name}!"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
What does this code do?
- from flask import Flask, jsonify imports Flask and a helper that turns Python dictionaries into JSON responses.
- app = Flask(__name__) creates the Flask application instance.
- /health is a health check endpoint that returns {"status": "healthy"}. CI/CD pipelines often hit an endpoint like this to confirm the app is running.
- /greeting/<name> is a dynamic endpoint. The <name> part captures whatever value is in the URL and passes it to the function as an argument.
- if __name__ == "__main__" lets you run the app directly with python app.py for local testing.
- Save app.py.
- You won't run the app directly yet. The next substep writes tests that exercise both endpoints automatically.
Seeing a red underline on the import?
Make sure your Python virtual environment is selected as the interpreter in VS Code. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and type Python: Select Interpreter. Choose the interpreter path that points to the virtual environment you created in Step 1.
help me fix the import error in app.py
✔️ Awesome, I've got everything!
Great. Double check you've saved app.py before moving on.
ⓧ I'd like to double check the full code
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/health")
def health():
return jsonify({"status": "healthy"})
@app.route("/greeting/<name>")
def greeting(name):
return jsonify({"message": f"Hello, {name}!"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Create the test file
Automated tests are what make a CI/CD pipeline meaningful. Without them, there is nothing for the pipeline to verify before deploying.
- Create a new file called test_app.py in the root of your hello-cicd folder.
- Paste the following code into test_app.py:
from app import app
def test_health():
client = app.test_client()
response = client.get("/health")
assert response.status_code == 200
assert response.get_json() == {"status": "healthy"}
def test_greeting():
client = app.test_client()
response = client.get("/greeting/World")
assert response.status_code == 200
assert response.get_json() == {"message": "Hello, World!"}
What does this code do?
- from app import app imports the Flask app you just created so the tests can interact with it.
- app.test_client() creates a fake HTTP client. It sends requests to your app without needing a running server.
- test_health() sends a GET request to /health and checks that it returns a 200 status code with the expected JSON body.
- test_greeting() sends a GET request to /greeting/World and verifies the response contains Hello, World! in the JSON.
- Save test_app.py.
Red underline on from app import app?
This can happen if VS Code hasn't detected app.py yet. Make sure both files are saved and that they are in the same folder. The red underline often disappears after saving both files.
help me fix the import in test_app.py
✔️ Awesome, I've got everything!
Great. Double check you've saved test_app.py before moving on.
ⓧ I'd like to double check the full code
from app import app
def test_health():
client = app.test_client()
response = client.get("/health")
assert response.status_code == 200
assert response.get_json() == {"status": "healthy"}
def test_greeting():
client = app.test_client()
response = client.get("/greeting/World")
assert response.status_code == 200
assert response.get_json() == {"message": "Hello, World!"}
Run the tests locally to confirm they pass
Before you push code to GitHub, you should always verify tests pass on your own machine first. This catches typos and import errors before they hit the pipeline.
- Run the test suite by running this command in your terminal:
pytest test_app.py -v
- You should see output showing both tests with PASSED next to them, similar to:
test_app.py::test_health PASSED
test_app.py::test_greeting PASSED
What does the -v flag do?
The -v flag stands for verbose. It tells pytest to display each test on its own line with a PASSED or FAILED label instead of just showing dots. This is the same command your GitHub Actions workflow will use later.
Seeing a FAILED test or ModuleNotFoundError?
- If you see ModuleNotFoundError: No module named 'flask', your virtual environment is not activated. Activate it and try again.
- If you see ModuleNotFoundError: No module named 'app', make sure you are running the command from inside your hello-cicd folder where both app.py and test_app.py live.
- If a test shows FAILED, check for typos in the endpoint route strings or the expected JSON values.
help me debug my failing pytest tests
Both tests are green. Your Flask app is working and verified. Next up, you'll containerize this app with Docker so it can run anywhere.
Containerize with Docker
Your Flask app runs perfectly on your machine. But what happens when you want to run it somewhere else, like a cloud server or a teammate's laptop? The answer is packaging your app into a container that includes everything it needs to run.
Docker lets you do exactly this. In this step, you'll create a Dockerfile that packages your Flask app into a portable image anyone can run.
In this step, get ready to:
- Create a requirements.txt file listing your Python dependencies.
- Write a Dockerfile to package your app into a container image.
- Build and run the Docker image locally to verify it works.
Create the requirements file
Docker builds your app in a fresh environment with nothing pre-installed. You need a requirements.txt file so Docker knows which Python packages to install inside the container.
- Create a new file called requirements.txt in your hello-cicd folder and add the following contents:
flask==3.1.3
pytest==8.3.4
What does this file do?
Each line pins a package to an exact version. flask==3.1.3 ensures your container uses the same Flask version you developed with. pytest==8.3.4 pins your test framework for reproducible CI runs later.
- Save requirements.txt.
- Confirm the file exists by running:
cat requirements.txt
You should see both flask==3.1.3 and pytest==8.3.4 printed in your terminal.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
flask==3.1.3
pytest==8.3.4
Write the Dockerfile
A Dockerfile is a recipe that tells Docker how to build your image. Each line is an instruction: start from a base image, copy files in, install dependencies, and define how to run your app.
- Create a new file called Dockerfile (no extension) in your hello-cicd folder and add the following contents:
FROM python:3.13-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?
- FROM python:3.13-slim starts from a lightweight Python 3.13 image. The slim variant keeps the image small by excluding dev tools you don't need.
- WORKDIR /app sets the working directory inside the container. All following commands run from here.
- COPY requirements.txt . and RUN pip install install dependencies first. Docker caches this layer, so rebuilds are fast if only your code changes.
- COPY app.py . copies your application code into the container.
- EXPOSE 5000 documents which port the app listens on.
- CMD ["python", "app.py"] defines the command that runs when the container starts.
- Save Dockerfile.
File named Dockerfile.txt by accident?
Some editors add a .txt extension automatically. Make sure the file is named exactly Dockerfile with no extension. Run ls in your terminal to confirm. If you see Dockerfile.txt, rename it with:
mv Dockerfile.txt Dockerfile
help me rename my Dockerfile
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ 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 app.py .
EXPOSE 5000
CMD ["python", "app.py"]
Build and run the Docker image locally
Now you'll turn your Dockerfile into a runnable image and start a container from it. This proves your app works inside Docker before you automate the process with GitHub Actions in the next step.
- Build your Docker image by running this command:
docker build -t hello-cicd .
What does this command do?
docker build reads your Dockerfile and executes each instruction to create an image. The -t hello-cicd flag names (tags) the image so you can reference it later. The . at the end tells Docker to use the current directory as the build context.
The build takes about 30 seconds. You should see each step complete with a green checkmark or a DONE marker.
Seeing 'Cannot connect to the Docker daemon'?
This means Docker isn't running. Start the Docker service in WSL by running:
sudo service docker start
Then try the build command again. Help me start Docker in WSL
- Run your container by running this command:
docker run -p 5000:5000 hello-cicd
What does this command do?
docker run creates and 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 Flask app from your browser or terminal.
You should see Flask's startup output, including a line that says it's serving on http://0.0.0.0:5000. This means your container is running.
- Open a second terminal in VS Code by clicking the + icon in the terminal panel.
- Test your containerized app by running this command:
curl http://localhost:5000/health
You should see {"status":"healthy"} printed in your terminal. This proves your Flask app is running inside the Docker container and responding to requests.
- Switch back to your first terminal and press Ctrl+C to stop the container.
Seeing 'port is already allocated'?
This means something else is already using port 5000. If your Flask app is still running from the previous step, stop it first with Ctrl+C in that terminal. Then try the docker run command again.
Help me fix the port conflict
Your app is now containerized and portable. Next, you'll create a GitHub Actions workflow that builds this image automatically every time you push code.
Create the GitHub Actions Workflow
You have a working Flask app, tests that pass, and a Docker image that runs locally. The next challenge is automation. Right now, you have to remember to run tests and build the image by hand every time you change code.
GitHub Actions solves this by running a workflow every time you push code. In this step, you will write a YAML workflow file that automatically tests your app and pushes a fresh Docker image to GitHub Container Registry.
In this step, get ready to:
- Create a workflow YAML file with a test job.
- Add a build-and-push job for Docker images.
- Push to GitHub and verify the pipeline runs.
Create the workflow file with a test job
GitHub Actions looks for workflow files inside a special .github/workflows directory in your repository. Each .yml file in that folder defines a separate pipeline.
- Create the required directory structure by running this command in your terminal:
mkdir -p .github/workflows
- Confirm the directory exists by running:
ls .github
You should see workflows in the output.
- In VS Code's Explorer sidebar, expand the .github/workflows folder.
- Right-click inside the workflows folder and click New File.
- Name the file ci.yml and press Enter.
- Paste the following YAML into ci.yml to define the workflow header and test job:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest test_app.py -v
What does this YAML do?
- name: gives the workflow a human-readable name that appears in the GitHub Actions UI.
- on: defines the trigger. This workflow runs on every push to main and on every pull request targeting main.
- env: sets environment variables available to all jobs. REGISTRY points to ghcr.io and IMAGE_NAME uses your GitHub username and repo name automatically.
- The test job checks out your code, installs Python 3.13, installs dependencies from requirements.txt, and runs pytest against your test file.
- Save ci.yml.
You will not see a visible change yet since the workflow only runs on GitHub after you push. The next chunk adds the Docker job.
YAML not looking right?
YAML is whitespace-sensitive. Make sure you are using spaces (not tabs) for indentation. Each level is 2 spaces. If VS Code shows red squiggly lines, check that no tab characters snuck in.
Help me fix YAML indentation errors in my ci.yml file.
Add the build-and-push job
The test job validates your code. Now you need a second job that builds a Docker image and pushes it to ghcr.io so your container is always up to date with the latest code.
- In ci.yml, place your cursor at the end of the last line (run: pytest test_app.py -v) and press Enter twice to create a blank line.
- Paste the following YAML below the test job, at the same indentation level as test: (two spaces from the left margin):
build-and-push:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Log in to the Container registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
What does the build-and-push job do?
- needs: test means this job waits for the test job to pass before it starts. If tests fail, the image never gets built.
- if: github.event_name == 'push' skips the build on pull requests. You only want to publish an image when code lands on main.
- permissions: grants the job the minimum access it needs. packages: write allows pushing to GitHub Container Registry.
- docker/login-action@v4 authenticates with ghcr.io using the built-in GITHUB_TOKEN secret. No extra credentials needed.
- docker/metadata-action@v6 generates tags and labels for the image automatically based on your branch and commit.
- docker/build-push-action@v7 builds the Dockerfile and pushes the resulting image to the registry.
- Save ci.yml.
Seeing indentation errors?
The build-and-push: line must be at the same level as test: (exactly 2 spaces from the left edge). If it is indented more or less, YAML treats it as a different structure.
Help me align my build-and-push job correctly in ci.yml.
✔️ Awesome, I've got everything!
Great. Make sure you have saved ci.yml before moving on.
ⓧ I'd like to double check the full code
Here is the complete .github/workflows/ci.yml file. Compare yours against it:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest test_app.py -v
build-and-push:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Log in to the Container registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
Push your code and watch the workflow run
Your workflow file is ready. The moment you push it to GitHub, Actions picks it up and starts running the pipeline automatically.
- Stage all your project files by running:
git add .
- Commit the changes by running:
git commit -m "Add CI/CD pipeline"
- Push the commit to GitHub by running:
git push
What happens after you push?
GitHub detects the new workflow file in .github/workflows/ and immediately triggers the CI/CD Pipeline workflow. The test job runs first. If it passes, the build-and-push job starts and publishes your Docker image.
- Open your hello-cicd repository on GitHub in your browser.
- Under your repository name, click Actions.
- In the left sidebar, click CI/CD Pipeline.
- Click the workflow run named Add CI/CD pipeline (matching your commit message).
- Watch both jobs complete. Click test or build-and-push to expand the logs for each job.
You should see green checkmarks next to both jobs, meaning your tests passed and the Docker image was pushed to ghcr.io successfully.
Workflow not appearing in the Actions tab?
Make sure the Actions tab is visible under your repository name. If it is not, Actions may be disabled. Go to Settings then Actions then General and select Allow all actions and reusable workflows.
Also confirm your file is at the exact path .github/workflows/ci.yml. A typo in the directory name (like .github/workflow/ without the s) will prevent GitHub from detecting it.
Help me figure out why my GitHub Actions workflow is not running.
Your pipeline is live. Every push to main now triggers automated tests and a fresh Docker image build. Next up, you will make a real code change and watch the full cycle run end-to-end.
Verify Your Pipeline End-to-End
You have a working pipeline that tests your code, builds a Docker image, and pushes it to GitHub Container Registry. But you set it all up in a single push. The real proof is whether the automation triggers again when you make a new change.
In this step, you will make a code change, push it, and watch the entire CI/CD loop fire automatically. Then you will pull the published image and run it locally to confirm the full cycle works end-to-end.
In this step, get ready to:
- Add a new endpoint to your Flask app and push the change.
- Verify the new Docker image appears in GitHub Packages.
- Pull and run the published image from GitHub Container Registry.
Add a new endpoint and push your code
Every push to main triggers your workflow automatically. To prove this, you will add a simple version endpoint to your Flask app, push the change, and watch GitHub Actions pick it up.
- In VS Code, open app.py and find the greeting function.
- Below the greeting function, add the following new endpoint:
@app.route("/version")
def version():
return jsonify({"version": "1.0.0"})
What does this code do?
This adds a /version route that returns a JSON object with a version number. It is a small, harmless change that gives you a new endpoint to test after the image is rebuilt.
- Save app.py.
- Switch back to your WSL terminal and commit your change by running these commands:
git add app.py
git commit -m "Add version endpoint"
git push
What happens when you push?
The on: push trigger in your workflow file detects the new commit on main. GitHub Actions spins up a runner, runs your tests, builds a fresh Docker image, and pushes it to ghcr.io.
- Switch to your browser and open your hello-cicd repository on GitHub.
- Click the Actions tab.
- You should see a new workflow run in progress (or recently completed) with your commit message "Add version endpoint".
- Click on the run to watch the test and build-and-push jobs complete.
Don't see a new workflow run?
Make sure your push went through by running git log --oneline -1 in your terminal. The latest commit should say "Add version endpoint". If it does, refresh the Actions tab. Workflow runs can take a few seconds to appear.
help me figure out why my GitHub Actions workflow isn't triggering on push
Verify the image in GitHub Packages
Once the workflow completes, the build-and-push job publishes a new Docker image to your repository's Packages section.
- On your repository's main page, look at the right-hand sidebar.
- Click Packages in the sidebar.
- You should see a package named hello-cicd listed.
- Click on the package to see its tags and versions.
- You should see a main tag with a recent timestamp matching your latest push.
What are image tags?
The docker/metadata-action in your workflow automatically generates tags based on the branch name. Since you pushed to main, the image gets tagged main. This makes it easy to pull the latest version from any specific branch.
Don't see Packages in the sidebar?
Make sure the workflow run completed successfully (both jobs show green checkmarks in the Actions tab). If the build-and-push job failed, click into it to see the error logs. A common cause is a permissions issue. Double check that your workflow includes permissions: packages: write under the build-and-push job.
help me troubleshoot why my Docker image didn't appear in GitHub Packages
Pull and run the published image
The final proof is pulling the image from GitHub Container Registry and running it locally. This confirms the image your pipeline built actually works.
- In your WSL terminal, pull the published image by running this command:
docker pull ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/hello-cicd:main
Where does the image name come from?
Your workflow sets IMAGE_NAME: ${{ github.repository }}, which resolves to your GitHub username and repository name (e.g. yourname/hello-cicd). The :main tag matches the branch you pushed to.
- Run the pulled image by running this command:
docker run -d -p 5001:5000 ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/hello-cicd:main
What does this command do?
The -d flag runs the container in the background. The -p 5001:5000 flag maps port 5001 on your machine to port 5000 inside the container (where Flask is listening). You are using port 5001 to avoid conflicts with any local Flask process.
- Test the running container by hitting your new endpoint:
curl http://localhost:5001/version
- You should see the following response:
{"version":"1.0.0"}
- Also test the health endpoint to confirm the full app is working:
curl http://localhost:5001/health
- You should see {"status":"healthy"}.
- Stop the container when you are done by running:
docker stop $(docker ps -q --filter ancestor=ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/hello-cicd:main)
Getting "connection refused" from curl?
Make sure the container is actually running by running docker ps. You should see a container with your image name and port 5001->5000 in the PORTS column.
If docker ps shows nothing, the container may have crashed on startup. Run docker logs $(docker ps -aq --latest) to check the output for errors.
help me figure out why my Docker container isn't responding on localhost
You just completed the full DevOps loop. Code change, automatic tests, automatic build, automatic publish, and a working container pulled from the registry. Next up, the Secret Mission challenges you to add multi-version testing for production-grade confidence.
Secret mission
Multi-Version Matrix Testing
Your pipeline currently tests against a single Python version. In production, teams test across multiple versions to catch compatibility issues early. Extend your workflow with a matrix strategy and a staging branch to prove your app works everywhere before it ships.
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 GitHub Actions on a public repository and GitHub Container Registry, both of which are currently free. There are no ongoing costs to worry about.
Resources you used:
- GitHub repository hello-cicd (with main and staging branches).
- Docker images published to GitHub Container Registry (ghcr.io).
- Local project files and Python virtual environment in WSL.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your CI/CD pipeline or adding new features.
- Your GitHub repository stays public and the Actions workflow triggers on every push.
- Your Docker images remain available on GitHub Container Registry at no cost.
- Your local project files stay in your WSL environment.
Pause - I'll come back to this later
Disable the workflow to stop it running on future pushes. Your code and images stay intact.
- Go to your hello-cicd repository on GitHub.
- Click the Actions tab.
- Click CI/CD Pipeline in the left sidebar.
- Click the ... menu (top right of the workflow list) and select Disable workflow.
- When you return, re-enable it from the same menu.
Delete - I don't want to use this again
Remove all project resources completely. This cannot be undone.
Delete the Docker images from GitHub Container Registry:
- Go to your hello-cicd repository on GitHub.
- Click Packages in the right sidebar of the repository page.
- Click the hello-cicd package.
- Click Package settings.
- Scroll to the bottom and click Delete this package. Confirm the deletion.
Delete the GitHub repository:
- Go to your hello-cicd repository on GitHub.
- Click Settings in the top navigation.
- Scroll to the bottom to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Delete local project files:
- Remove the project folder by running this command in your WSL terminal:
rm -rf ~/Desktop/hello-cicd
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see hello-cicd in the output.
Nice Work!
Nice Work!
You just built a complete CI/CD pipeline from scratch. Every time you push code, your pipeline automatically tests it, builds a Docker image, and publishes it to a container registry.
You've learned how to:
- Build a Python Flask web app with automated pytest tests and containerize it with Docker.
- Create a GitHub Actions workflow that automatically runs tests on every push to your repository.
- Automatically build and publish Docker images to GitHub Container Registry as part of your CI/CD pipeline.
- Added multi-version matrix testing across Python 3.12 and 3.13 with a staging branch for production-grade confidence.
Ready to quiz yourself?