Originally published on Medium

The Terminus Edition — Modern CI/CD and Deployment Workflows for LLM-Powered Apps

Context: LLM-Powered Apps Meet Modern Tooling

In Part 3 of the series, we explored how uv can simplify dependency management and improve MLOps for packaged Python applications. Now it’s time to take your project to production.

This part focuses on:

  • Dockerizing your app the modern way
  • Using docker-compose for local development
  • Automating CI/CD with GitHub Actions
  • Linting with ruff and running tests with uv run

We’ll use Terminus as the reference project—a FastAPI app that defines and validates user-defined topic-related terms using LLMs (Instructor + LiteLLM), Wikipedia, and Pydantic guardrails.

Production-Ready Docker Builds with uv: The Two-Stage Advantage

When containerizing a Python application, it’s tempting to throw everything into a single Dockerfile, install some packages, run your code, and call it a day.

Don’t.

Not if you care about:

  • ⚡️ Build speed
  • 🐍 Clean dependency separation
  • 📦 Image size
  • 🔒 Security

This is where the two-stage Docker build pattern shines — and paired with uv, the blazing-fast modern package manager, elevates Python Docker workflows to a new level of performance and simplicity.

Let’s dissect Terminus’ Dockerfile and understand what makes it tick.

uv two-stage docker

Stage 1: Build Stage (a.k.a. “The Builder”)

FROM python:3.13-slim AS base

We start from a slim Python base — minimal but powerful. Here’s where the magic happens:

  • Install uv: uv is installed via pip, giving us a >10x faster resolver than pip or Poetry. It's designed for speed, reliability, and modern packaging workflows.
  • Install Build Dependencies: We temporarily install system packages like build-essential and curl — necessary for compiling Python wheels (think psycopg2, lxml, etc.). These are later purged to avoid bloating the final image.
  • Leverage Layer Caching:
COPY pyproject.toml uv.lock README.md ./

Only metadata is copied first. Why? Because Docker caches layers. If your dependencies haven’t changed, Docker will reuse the layer, dramatically speeding up future builds.

  • Install Dependencies:

RUN uv pip install --system . ...

Dependencies are installed directly into the system Python, not a virtual environment. This avoids virtualenv overhead, speeds up startup times, and is more memory efficient in production.

  • Copy Application Code: Source code is copied last so that frequent edits don’t bust the dependency cache. Clean separation of concerns!

Stage 2: Runtime Stage (a.k.a. “the user”)

FROM python:3.13-slim AS runtime

This stage is purposefully lean and minimal. It contains:

  • Only the runtime Python environment and dependencies
  • Your application source code
  • No compilers, no wheel caches, no source .pyc, no tools you don’t need in production

We copy:

COPY --from=base /usr/local /usr/localCOPY --from=base /app /app

This includes installed packages and the application itself — and nothing more.

We also define:

WORKDIR /appENV PYTHONPATH=/app/src

This ensures Python modules resolve properly when launched via FastAPI, CLI, or background tasks.

Result: A final image that is much smaller (sometimes up to 80%), loads faster, and is easier to secure.

In summary:

  • Smaller Images: no dev tools, no compilers, no cache = smaller size = faster pull times & fewer cloud resources.
  • Faster CI/CD: metadata-first copying + uv's resolver = lightning-fast builds and rebuilds.
  • Cleaner Production: no stray .pyc, no untracked dependencies, just the essentials.
  • Better Security: smaller attack surface; no dev tools left behind.
  • Reproducibility: uv.lock ensures the same dependency versions across machines and builds.
  • Speed: uv + ruff make linting and formatting lightning fast — perfect for CI/CD (see GitHub Actions below).

Tips for Getting It Right:

  • Always copy your lock file and pyproject.toml early to benefit from Docker layer caching.
  • Install your dependencies using uv pip install --system . — no need for venv or Poetry hacks.
  • Strip your build stage with apt-get purge and rm -rf to keep the final image lean.
  • Let Docker Compose or your runtime environment define the CMD, so the base image stays flexible and composable.

You can see this pattern fully implemented in Terminus’ GitHub repo along with the corresponding GitHub Actions workflow.

docker-compose with uv: Reproducibility meets velocity

Now that we’ve built a clean, layered Docker image using a two-stage DockerfileLet’s orchestrate it for local development using docker-compose. This is where uv starts to shine.

docker-compose lets you declaratively define how your app is built, configured, and run — across environments. Combined with uv, it gives us a fast, reproducible, and developer-friendly setup.

Let’s walk through the key pieces and highlight why uv changes the game:

Fast Iteration: Live Reloading with Mounted Volumes

volumes:  - ./src:/app/src

This mounts your local src/ directory directly inside the container. Any code changes you make on your machine are reflected immediately in the container — no rebuilds, no restarts (not meant for production).

Coupled with Uvicorn’s --reload flag, this enables hot-reloading of your FastAPI app during development — a productivity boost that feels native, even inside Docker.

Clean, Locked Environments with uv

Instead of relying on pip and requirements.txt, we use uv inside the container to:

  • Install dependencies from pyproject.toml, keeping the setup declarative and modern.
  • Enforce lockfile-based reproducibility using uv.lock
  • Run tools like ruff, pytest, or uvicorn without globally installing them.

Thanks to uv’s binary-first architecture, dependency resolution is blazingly fast, and the dev container is ready to go in seconds — no virtualenv juggling needed.

Environment Isolation Done Right

env_file:
  - .env
environment:
  - PYTHONPATH=/app/src
  - DATABASE_URL=sqlite:///data/terminus.db

Storing environment-specific settings (like API keys, model providers, and database paths) in a .env file.

uv respects these variables at runtime, and we explicitly set PYTHONPATH to ensure imports like from terminus.models import ... behave identically inside and outside Docker.

Health Checks for Early Failure Detection

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]

This tiny but powerful snippet tells Docker: “My app is alive if /docs responds.” If something breaks — like an LLM outage or a misconfigured .env — you’ll see it in docker ps right away.

Putting It All Together

command:
  - python
  - -m
  - uvicorn
  - src.terminus.app:app
  - --host
  - 0.0.0.0
  - --port
  - "8000"
  - --reload

Here, we use uvicorn to launch the app in development mode with live reload. By using python -m uvicorn, we make this command shell-agnostic and easier to port to other entrypoints (e.g., for Gunicorn in production).

Why uv Makes This Setup Better

comparison table

In short, uv gives you modern dependency management that is:

  • Declarative (single source of truth in pyproject.toml)
  • Reproducible (locked versions across machines)
  • Fast (Rust-powered resolver is wicked quick)
  • Tool-friendly (no pipx, no venv gymnastics)

If you care about speed, reproducibility, and a frictionless dev environment, this pairing of uv with docker-compose is hard to beat.

uv docker-compose

GitHub Actions + uv: Clean CI/CD Pipelines

Your app is structured. Your Docker image is lean. Your development flow is fast.

Now it’s time to level up your continuous integration and delivery (CI/CD) with a GitHub Actions pipeline that’s just as modern as uv.

When done right, your CI should:

  • Lint for correctness and style
  • Run tests to catch regressions (shame on me, Terminus has no test suite yet)
  • Build and publish a Docker image (public or private Hub)
  • Use the same dependency versions as local development

Let’s break down what we’re doing and why uv makes it not only possible but elegant.

uv cicd github actions flow

Lint and Validate (the fast way)

You don’t want broken code, and you want to know why it’s broken as early as possible.

That’s where Ruff comes in — an ultra-fast Python linter and formatter that’s written in Rust and integrates perfectly with uv.

- name: ✨ Lint with Ruff
  run: |
    uv run ruff check src
    uv run ruff format --check src

This runs two critical checks:

  • ruff check: finds bugs, code smells, and style violations.
  • ruff format --check: enforces formatting, CI-friendly (it fails if formatting isn’t followed).

Why this is great:

  • Runs in seconds
  • Uses the exact version of Ruff locked in pyproject.toml
  • Zero global installs — all inside the uv runtime

Test, Reproducibly

Assuming you’ve defined your test suite (e.g., with pytest or httpx for API tests), running it is a breeze:

- name: ✅ Run tests
  run: uv run pytest tests/

Why we love this:

  • Tests run in a clean, ephemeral environment (not your laptop!)
  • Always use the same versions of pytest, httpx, etc.
  • Optional: You can mock LLM calls or run against a test .env file for safe validation

The uv sync secret sauce

Before linting or testing, we install all dependencies:

- name: 📦 Install dependencies
  run: uv sync --dev

This single line:

  • Resolves and installs all production + development dependencies (Ruff, Pytest, etc.)
  • Uses the exact versions from your uv.lock
  • Skips virtualenvs and pip install . nonsense
  • Guarantees your CI runs exactly like your local dev container or machine

Build and Push a Docker Image

In a separate job (build-and-push-docker), we only trigger the Docker build after tests pass — because shipping broken containers helps no one.

We use docker/metadata-action to auto-generate tags like latest and a short Git SHA, and push the image to Docker Hub using your GitHub repository secrets.

images: ${{ secrets.DOCKERHUB_USERNAME }}/terminus
tags: |
  type=sha,format=short
  type=raw,value=latest

Want reproducibility and traceability? Use the SHA-based tag to pin an image to a commit forever.

Why uv Makes GitHub Actions Better

comparison table

With uv, your GitHub Actions become:

  • Faster (no dependency juggling)
  • Reproducible (locked versions, always)
  • Cleaner (no extra YAML for managing Python tools)
  • More secure (no extra tools needed outside the uv runtime)

Publishing to Docker Hub — the Right Way

Once our application is linted, tested, and deemed production-ready, the final piece is building and pushing a Docker image to a registry. In this case: Docker Hub.

But we’re not just pushing any image. We’re pushing a reproducible, lightweight, and well-tagged artifact — built with uv, orchestrated by GitHub Actions.

Authenticate Securely (no credentials in sight)

Instead of hardcoding usernames or passwords (a security anti-pattern), we use GitHub repository secrets:

with:
  username: ${{ secrets.DOCKERHUB_USERNAME }}
  password: ${{ secrets.DOCKERHUB_TOKEN }}

These should be added in your repo under: Settings → Secrets and variables → Actions

You’ll need:

  • DOCKERHUB_USERNAME
  • DOCKERHUB_TOKEN (Docker Hub → Account Settings → Security → New Access Token)

These secrets never appear in logs

They auto-expire if you revoke them

They protect your pipeline from leakage

Auto-Generate Metadata and Tags

Instead of manually tagging your Docker images (error-prone and easy to forget), we use the docker/metadata-action to auto-tag based on Git info:

- uses: docker/metadata-action@v5
  with:
    images: ${{ secrets.DOCKERHUB_USERNAME }}/terminus
    tags: |
      type=sha,format=short        # Tag image with commit SHA (e.g., terminus:abc1234)
      type=raw,value=latest        # Also push as "latest"

This helps:

  • Traceability: Every pushed image is linked to a commit.
  • Convenience: :latest is available for local dev and quick pull.
  • Automation: No human errors in manual tagging.

Build Once, reuse always

Thanks to Docker BuildKit and layer caching via GitHub Actions:

cache-from: type=gha
cache-to: type=gha,mode=max

you get:

  • Faster builds (because unchanged layers are reused)
  • Lower CI costs (fewer image pulls/uploads)
  • Deterministic environments (same code = same image)

The build is based on your two-stage Dockerfile (explained above), which ensures:

  • Minimal attack surface (no dev tools in final image)
  • Fast cold starts (no bloat, clean dependencies)
  • uv is used inside the image too — so the containerized app benefits from the same ultra-fast resolution and install.

Push to Docker Hub

Finally, the docker/build-push-action does the actual build & publish:

- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}

Once this step is completed, your image is:

  • Built with locked uv dependencies
  • Tagged with Git commit + latest
  • Published to Docker Hub, ready to be docker pulled anywhere

This step finalizes the loop of build → validate → ship — with every part driven by uv's speed and consistency and GitHub Actions' automation.

Let’s wrap up this build-and-deploy journey by zooming out and synthesizing everything we’ve accomplished.

What We’ve Built — A Fast, Clean, and Reproducible CI/CD Pipeline with uv

Let’s step back and admire the simplicity and power of the DevOps pipeline we’ve just assembled. What started as a FastAPI app Terminus is now a fully containerized, continuously validated, and registry-published service — all thanks to a few strategic tools and good practices.

Combining all the steps in .github/workflows/ci.yml

name: Terminus CI/CD

on:
  push:
    branches: [main]
    # trigger on tags for versioned releases
    # tags: ['v*.*.*']
  pull_request:
    branches: [main]

permissions:
  contents: read

env:
  PYTHON_VERSION: '3.13'

jobs:
  lint-and-test:
    name: Lint & Test
    runs-on: ubuntu-latest
    steps:
      - name: ⬇️ Checkout Code
        uses: actions/checkout@v4

      - name: 🐍 Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: 📦 Install uv
        uses: astral-sh/setup-uv@v5

      - name: 🧠 Restore uv cache
        id: cache-uv
        uses: actions/cache@v4
        with:
          path: ~/.cache/uv
          key: uv-${{ runner.os }}-${{ hashFiles('pyproject.toml', 'uv.lock') }}
          restore-keys: |
            uv-${{ runner.os }}-

      - name: 📦 Install dependencies (lint)
        run: uv sync --group lint

      - name: ✨ Lint with Ruff
        run: |
          uv run ruff check src
          uv run ruff format --check src
    # Shame, no tests yet
    #   - name: ✅ Run tests
    #     run: uv run pytest tests/
    #     continue-on-error: true  # Make strict if critical

  build-and-push-docker:
    name: Build & Push Docker Image
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: lint-and-test
    permissions:
      contents: read

    steps:
      - name: ⬇️ Checkout Code
        uses: actions/checkout@v4

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

      - name: 🏷️ Docker Metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ secrets.DOCKERHUB_USERNAME }}/terminus
          tags: |
            type=sha,format=short
            type=raw,value=latest

      - name: 🔑 Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: 🏗️ Build and Push Docker Image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Here’s what makes this pipeline shine:

uv: the dependency backbone

  • Lightning-fast resolution and install compared to pip + venv.
  • Lockfile (uv.lock) ensures reproducibility across environments (local, CI, Docker).
  • Single source of truth for runtime and dev dependencies via pyproject.toml.

Whether you’re syncing a new dev machine or building production containers, you run the same thing: uv sync.

Docker, the fast way

  • Two-stage build keeps dev tools and build dependencies out of the final image.
  • Uses uv to install directly into system Python, skipping venv overhead.
  • The final image is minimal, fast, and portable — optimized for cloud environments or edge deployment.

A clean image = smaller surface = faster startup = happier users.

GitHub Actions, but smarter

  • Separate stages for linting, testing, and deployment improve clarity and feedback loops.
  • Linting with ruff, testing with pytest, and building with docker — all run via uv, ensuring dev/prod parity.
  • Docker Hub publishing happens only when tests pass and only on the main branch — the golden rule of CI.

Bonus: Caching is used everywhere (Python deps, Docker layers), slashing build times in half.

This entire loop is automated, reproducible, and frictionless. As long as your code is clean and your tests pass, your FastAPI service gets linted, tested, containerized, and published — all without touching your laptop.

Closing Thoughts

In the Python world, where dependency hell, slow builds, and bloated containers are all too familiar, this stack — uv, Docker multi-stage builds, ruff, and GitHub Actions offer a modern antidote.

Whether you’re working on internal tools, ML APIs, or full-fledged SaaS backends, the benefits are clear:

  • Faster iteration
  • Safer builds
  • Simpler team onboarding
  • Reproducibility across the board

What’s next?

Is that it? Not quite! We can further enhance our local and global CI/CD workflow. In the next installment, we’ll explore how to integrate tools like uv (via its CLI or external CLIs like Poethepoet) and nox to create an even more robust GitHub Actions pipeline. Stay tuned for the next episode!

Happy shipping 🚀