Automatically Build and Deploy Your Python Application with CI/CD

What is Continuous Integration/Continuous Delivery?

When you finish a new version of your Python application, how do you build and deploy it? Do you change the version number, upload the new files to production, and be done with it? There is a better way to do this, called continuous integration and continuous delivery.

Instead of manually performing all the tedious steps to get your software to production, you can automate them. Luckily, you don’t have to build this automation yourself. Instead, you can use one of the many Continuous Integration/Continuous Delivery (CI/CD) systems that are available.

Continuous integration means that every change you push is automatically checked: the code is installed, linted and tested. Continuous delivery goes one step further: when all checks pass, the result is automatically packaged and shipped, ready to run in production. Together, they take the human errors out of releasing software. Nobody forgets to run the tests, and nobody uploads the wrong files.

In this article, we’ll take the small web service from the article on containerizing your Python project and create a complete CI/CD pipeline for it with GitHub Actions. Every push to GitHub runs the tests, and every successful push to the main branch builds a Docker image and publishes it. It takes one YAML file of about 60 lines.


Requirements

To set up a good CI/CD pipeline, we need to have some other practices in place first:

  • We must use a version control system like Git.
  • Our application should have automated tests (unit tests, integration tests).
  • We should have at least a development, test, and production location for our software.
  • We need a CI/CD system.

Setting up CI/CD may seem like a daunting adventure, but it really isn’t. In fact, you can allow yourself some shortcuts when starting out. To get started quickly:

  • Our test and development location will be our own PC.
  • We’ll start with just one unit test.
  • We’ll use GitHub to host our code, and the GitHub Container Registry to host our finished application as a Docker image.

You’ll need uv and Git on your computer, and a GitHub account. You don’t need Docker locally, because GitHub builds the image for us.


Which CI/CD system to choose from?

Several vendors offer CI/CD systems. A well-known open-source system I’ve used in the past is Jenkins. It’s been around for a long time. The downside: it requires knowledge and time to set up and run. GitLab has its own CI/CD built in, and there are many hosted services as well.

If your code is on GitHub, though, the obvious choice is GitHub Actions. It’s built in, there’s nothing to install or connect, and public repositories can use it for free. Private repositories get a monthly allowance of free minutes. Your pipeline is a YAML file that lives in your repository, so it’s versioned together with your code.


The application

For this pipeline, we’ll use the Flask web service we built in the Docker article. It has the following features:

  • It’s a Python project managed with uv, with its settings in pyproject.toml and the exact versions of all dependencies in a uv.lock file.
  • It includes a Dockerfile that builds a small, non-root image running the app with Gunicorn.
  • In this article, we give it a unit test that runs with pytest.

The finished project, including the pipeline from this article, is in the companion repository eriky/docker-python-example on GitHub. You can use the same pipeline for your own project. Just adapt the commands to your project’s layout.


Step 1. Get the project and add a test

If you followed the Docker article, you already have the project: a folder called my-webservice with app.py, pyproject.toml, uv.lock, a Dockerfile and a .dockerignore. If you didn’t, go through that article first. It takes about fifteen minutes, and the pipeline builds on the Dockerfile you create there. This is the app:

from flask import Flask

app = Flask(__name__)


@app.route('/')
def hello_world():
    return 'Hello, World!'Code language: Python (python)

A pipeline is only as good as the tests it runs, so let’s add one. First, add pytest as a development dependency. Development dependencies are installed when you work on the project, but the Dockerfile leaves them out of the image:

$ uv add --dev pytestCode language: Python (python)

Then create tests/test_app.py. Flask’s test client sends a request to the app without starting a server:

from app import app


def test_hello_world():
    client = app.test_client()
    response = client.get("/")
    assert response.status_code == 200
    assert response.text == "Hello, World!"Code language: Python (python)

Because app.py lives in the project root and the test in a tests folder, tell pytest where to find it. Add this to the end of pyproject.toml:

[tool.pytest.ini_options]
pythonpath = ["."]Code language: Python (python)

Let’s check that everything works locally before we automate it:

$ uv run pytest
============================= test session starts ==============================
platform darwin -- Python 3.15.2, pytest-9.2.0, pluggy-1.7.0
rootdir: /Users/erik/my-webservice
configfile: pyproject.toml
collected 1 item

tests/test_app.py .                                                      [100%]

============================== 1 passed in 0.22s ===============================Code language: Python (python)

The pipeline uses the Dockerfile from the Docker article as it is. Its uv sync --locked line matters here: the --locked option makes uv stop with an error if uv.lock doesn’t match pyproject.toml. That’s exactly what you want in an automated build: install the versions you tested with, or fail loudly.


Step 2. Put the code on GitHub

Create a new, empty repository on GitHub called my-webservice. Then commit your code and push it, replacing your-name with your GitHub username. If your project isn’t a Git repository yet, run git init first:

$ git add .
$ git commit -m "First version"
$ git branch -M main
$ git remote add origin https://github.com/your-name/my-webservice.git
$ git push -u origin mainCode language: Python (python)

Make sure uv.lock is part of the commit. The pipeline depends on it.


Step 3. Create the pipeline

A GitHub Actions pipeline is called a workflow. It’s a YAML file in the .github/workflows/ directory of your repository. Create .github/workflows/ci.yml with this content:

name: CI/CD

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - name: Install uv
        uses: astral-sh/setup-uv@v10.3.3

      - name: Install the project
        run: uv sync --locked

      - name: Lint with Ruff
        run: uvx ruff check

      - name: Run the tests
        run: uv run pytest

  deploy:
    needs: test
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Log in to the GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set up QEMU, to build for other processor types
        uses: docker/setup-qemu-action@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Decide on image tags
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=raw,value=latest
            type=sha

      - name: Build and push the image
        uses: docker/build-push-action@v7
        with:
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}Code language: Python (python)

That’s a lot at once, so let’s go through it.

When the workflow runs

The on section says when the workflow starts: on every push to the main branch, and on every pull request. Running the tests on pull requests means you see whether a change breaks something before you merge it.

The test job: continuous integration

A workflow consists of jobs, and each job runs on a fresh virtual machine, in this case the latest Ubuntu. The test job has these steps:

  • actions/checkout gets a copy of your repository onto the machine.
  • astral-sh/setup-uv installs uv. uv then installs the Python version from your .python-version file by itself, so you don’t need a separate step for Python.
  • uv sync --locked creates a virtual environment with your project and the exact dependencies from uv.lock, including pytest.
  • uvx ruff check runs the Ruff linter, which catches mistakes like unused imports and undefined names. You can leave this step out if you don’t want linting yet.
  • uv run pytest runs the tests. If a test fails, the step fails, and the workflow stops right there.

The deploy job: continuous delivery

The deploy job builds the Docker image and publishes it. Two lines decide when it runs. needs: test means it only starts after the test job has succeeded. if: github.event_name == 'push' skips it for pull requests, so only code that’s actually on main gets published.

The permissions block gives the job’s automatic GITHUB_TOKEN the right to publish packages. That token is all you need to log in to the GitHub Container Registry. There are no passwords to create or store.

The metadata step decides which tags the image gets. We always tag the image as latest. To keep the version history as well, we add a second tag based on the short Git commit hash, like sha-1a2b3c4. That way, you can always find out exactly which commit an image was built from, and roll back to an older image if needed. Finally, docker/build-push-action builds the image from your Dockerfile and pushes it with both tags.

The platforms line builds the image twice: once for regular Intel and AMD processors (linux/amd64) and once for ARM processors (linux/arm64), like the ones in Apple silicon Macs and many cloud servers. Docker picks the right one when you run the image. Building for ARM on GitHub’s Intel machines needs the QEMU emulator, which is what the setup-qemu step installs. It makes the deploy job slower: about 80 seconds instead of 30 for this small app. If you only run your images on regular servers, you can leave out both lines.

Prefer Docker Hub? Create an access token on Docker Hub, store it as a repository secret in GitHub (Settings → Secrets and variables → Actions), and change the login step to use it. Remove the registry line, because Docker Hub is the default, and set username and password to your Docker Hub username and that secret. Then change images to your-name/my-webservice.


Step 4. Run the complete pipeline

You’re all set. Commit the workflow file and push it:

$ git add .github/workflows/ci.yml
$ git commit -m "Add CI/CD pipeline"
$ git pushCode language: Python (python)

This push is itself a push to main, so the pipeline starts right away. Open your repository on GitHub and click the Actions tab. You’ll see the workflow run, with the test job first and the deploy job after it. Click a job to see the output of each step, just as if you had run the commands yourself. If all goes well, both jobs get a green check mark.

To make sure, go to the main page of your repository and look under Packages in the sidebar. Your image is there, with the tags latest and the short commit hash. The package gets the same visibility as your repository: public for a public repository, private for a private one, and you can change that in the package settings. Anyone with access can now run your application:

$ docker run -p 8000:8000 ghcr.io/your-name/my-webservice:latestCode language: Python (python)

Open http://localhost:8000 and you’ll see “Hello, World!”. Because the pipeline builds for both processor types, this works on a regular PC and on an Apple silicon Mac. You can try mine, too: docker run -p 8000:8000 ghcr.io/eriky/docker-python-example:latest.

Now try to break something on purpose. Change the text the test expects, for example to "Hello, CI!", then commit and push. The test job fails, the deploy job doesn’t run, and GitHub sends you an e-mail about the failed run. Your broken code never reaches the registry. That’s the whole point of CI/CD.

Next steps

Once this works, there’s plenty you can add:

  • Release builds: tag your Git repository with a version number, like v1.2.0, and add a tags: ["v*"] trigger under push. The metadata action can then tag the image with that version, which separates release builds from everyday builds.
  • Several Python versions: use a matrix to run your tests on, for example, Python 3.14 and 3.15 at the same time.
  • Actual deployment: add a job that tells your server or cloud platform to pull the new image and restart.
  • Branch protection: in your repository settings, require the test job to pass before a pull request can be merged.

Congratulations, you’ve built a CI/CD pipeline. That wasn’t too hard now, was it?

Related articles

Leave a Comment