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 withuv 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.
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 viapip
, giving us a >10x faster resolver thanpip
orPoetry
. It's designed for speed, reliability, and modern packaging workflows. - Install Build Dependencies:
We temporarily install system packages like
build-essential
andcurl
— necessary for compiling Python wheels (thinkpsycopg2
,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 forvenv
or Poetry hacks. - Strip your build stage with
apt-get purge
andrm -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 Dockerfile
Let’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
, oruvicorn
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
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.
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.
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
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 pull
ed 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 withpytest
, and building withdocker
— all run viauv
, 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 🚀