Migrating Python containers to Wolfi and uv
- 7 minutes read - 1407 wordsOur Python services ran on ubuntu:24.04 with pip-installed dependencies. It worked, but the images carried hundreds of packages we never used, Trivy scans were noisy with OS-level CVEs, and builds were slower than they needed to be. Over a couple of months we migrated to Chainguard’s Wolfi base image and uv for dependency management. This is how it went for the maps service, the one that renders static map tiles with a C++/Python hybrid stack.
Cleaning up dependency management with uv
Before touching the base image, we sorted out the dependency mess. The maps service had drifted. pyproject.toml declared one set of versions, uv.lock referenced another, and a private package (maparazzo) was pinned to a version that didn’t exist on the registry anymore.
The first PR synced the lock file with reality and bumped maparazzo to a published version. The second one removed version constraints from pyproject.toml entirely, keeping only the lock file as the source of truth:
dependencies = [
"application_kit[fastapi]",
"fastapi>=0.115.0",
"maparazzo==0.4.0",
"aiosqlite>=0.20.0",
"valkey-glide==2.2.5",
"sniffio>=1.3.1",
# Dev tools
"types-redis>=4.6.0",
"coverage[toml]>=7.6.0",
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.0.0",
"respx>=0.22.0",
"ruff>=0.8.0",
"mypy>=1.14.0",
]dependencies = [
"application_kit[fastapi]",
"maparazzo==2.0.0",
"aiosqlite",
"valkey-glide==2.2.5",
"sniffio",
]
[dependency-groups]
dev = [
"types-redis",
"coverage[toml]",
"pytest",
"pytest-asyncio",
"pytest-cov",
"respx",
"ruff",
"mypy",
]Two changes happened here. First, lower bounds like >=0.115.0 were dropped: the lock file already pins exact versions, so the constraints in pyproject.toml were just noise that could silently go stale. Second, dev tools moved to a proper [dependency-groups] section. This matters for the multi-stage build that comes next: uv sync --no-dev now skips pytest, ruff, mypy, and the rest. Previously they all shipped in the production image.
Running uv lock --upgrade after removing the constraints updated 23 packages in under 3 seconds. That’s the part of uv that makes the biggest day-to-day difference: resolving a 60-package graph is nearly instant.
The Ubuntu Dockerfile
Here’s what we had before:
FROM ubuntu:24.04 AS base
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
ENV UV_PYTHON_INSTALL_DIR=/python
ENV UV_PYTHON_PREFERENCE=only-managed
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN uv python install 3.12
WORKDIR /usr/src/app
ENV UV_PROJECT_ENVIRONMENT=/usr/src/venv
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
ADD . /usr/src/app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
FROM ubuntu:24.04
ARG DEBIAN_FRONTEND="noninteractive"
ENV PACKAGES="ca-certificates libopengl0 libegl1 curl"
RUN apt-get update && apt-get install --no-install-recommends -y \
${PACKAGES} && apt-get clean && apt-get autoclean && apt-get autoremove \
&& rm -rf /var/lib/apt/lists/*
COPY --from=base --chown=python:python /python /python
COPY --from=base --chown=app:app /usr/src/venv /usr/src/venv
COPY --from=base --chown=app:app /usr/src/app /usr/src/app
WORKDIR /usr/src/app
ENV PATH="/usr/src/venv/bin:$PATH"
RUN python ./minify_styles.py
EXPOSE 8000This was already using uv and multi-stage builds, which was good. But the production stage started from a fresh ubuntu:24.04, which pulls in apt, dpkg, systemd fragments, locales, and hundreds of other packages that a Python web service never touches. The apt-get update && apt-get install dance also meant the image wasn’t reproducible: two builds a week apart could get different package versions.
The COPY --from=ghcr.io/astral-sh/uv:latest was another problem. latest means the uv version changes silently. If a new uv release changes lock file behavior or introduces a bug, your CI breaks with no obvious diff.
Switching to Wolfi
Wolfi is a Linux distribution built specifically for containers. No package manager bloat, no shell login infrastructure, minimal base. Chainguard maintains it and publishes daily CVE-patched images.
The new Dockerfile:
FROM cgr.dev/chainguard/wolfi-base AS base
RUN apk add --no-cache \
mesa-egl \
mesa-gl \
mesa-gbm \
libgcc \
libstdc++ \
bash \
ca-certificates-bundle
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
ENV UV_PYTHON_INSTALL_DIR=/python
ENV UV_PYTHON_PREFERENCE=only-managed
COPY --from=ghcr.io/astral-sh/uv:0.9.18 /uv /uvx /bin/
RUN uv python install 3.12
WORKDIR /usr/src/app
ENV UV_PROJECT_ENVIRONMENT=/usr/src/venv
RUN --mount=type=cache,target=/root/.cache/uv,id=uv-base \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
COPY . /usr/src/app
RUN --mount=type=cache,target=/root/.cache/uv,id=uv-base \
uv sync --frozen --no-dev
RUN /usr/src/venv/bin/python ./minify_styles.py
FROM base AS build
RUN --mount=type=cache,target=/root/.cache/uv,id=uv-build \
uv sync --frozen
ENV PATH="/usr/src/venv/bin:$PATH"
ENV RUFF_CACHE_DIR=/tmp/.ruff_cache
ENV MYPY_CACHE_DIR=/tmp/.mypy_cache
ENV COVERAGE_FILE=/tmp/.coverage
FROM base AS prod
ENV PATH="/usr/src/venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
USER 65532
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/usr/src/app/healthcheck.sh"]A few things to unpack.
Three stages instead of two
The old Dockerfile had base (build everything) and a final production stage that copied artifacts. The new one has three:
| Stage | Purpose | Packages |
|---|---|---|
base | Install runtime deps, sync production packages | Runtime only |
build | Extends base, adds dev deps for testing in CI | Runtime + dev |
prod | Extends base, sets non-root user and healthcheck | Runtime only |
The build stage is what CI uses to run pytest, ruff, and mypy. The prod stage is what ships. Both extend base, so there’s no duplication of the dependency installation step.
The build stage redirects tool caches to /tmp because the app directory is read-only for the non-root user:
ENV RUFF_CACHE_DIR=/tmp/.ruff_cache
ENV MYPY_CACHE_DIR=/tmp/.mypy_cache
ENV COVERAGE_FILE=/tmp/.coveragePinned uv version
uv:latest became uv:0.9.18. One line, but it eliminates an entire class of “works on my machine” issues. When we want to upgrade uv, we do it explicitly in a PR.
Non-root production
The prod stage runs as UID 65532, matching the convention from distroless images. The base stage still runs as root (needed for apk add and uv python install), but the production image drops privileges before the entrypoint.
Minification at build time
The old Dockerfile ran python ./minify_styles.py in the production stage at container startup (well, at build time of the final stage, but from a fresh Ubuntu). The new one runs it in the base stage right after syncing dependencies. This means the minified assets are baked in and shared by both build and prod.
Build cache isolation
The --mount=type=cache directives got id=uv-base and id=uv-build suffixes. Without these, Docker can share cache mounts between stages that run concurrently in BuildKit, which causes lock contention.
The s5cmd surprise
After deploying the Wolfi image, the tile generation job broke. It ran a shell script that downloaded s5cmd (an S3-compatible file transfer tool) at runtime:
if ! [ -x "$(command -v s5cmd)" ]; then
curl -L \
https://github.com/peak/s5cmd/releases/download/v2.2.2/s5cmd_2.2.2_Linux-64bit.tar.gz \
> s5cmd.tar.gz
tar -xzvf ./s5cmd.tar.gz --directory /usr/local/bin/
rm ./s5cmd.tar.gz
fiTwo problems. First, curl wasn’t in the image anymore; we’d removed it during the Wolfi migration because the application code doesn’t need it. Second, even if curl was there, extracting to /usr/local/bin/ would fail because the container runs as non-root.
The fix was one line in the Dockerfile and deleting the download script:
RUN apk add --no-cache \
mesa-egl \
mesa-gl \
mesa-gbm \
libgcc \
libstdc++ \
bash \
s5cmd \
ca-certificates-bundleWolfi has s5cmd v2.3.0 in its package repository, newer than the v2.2.2 being downloaded. Installing it at build time is faster, more reliable, and gets security updates through the normal apk channel.
This is the kind of thing that falls out of a migration like this. Runtime downloads of binaries are a pattern that works on fat base images but breaks the moment you tighten things up. And they should break. Downloading unsigned tarballs into a running container is a supply chain risk hiding in plain sight.
Scan results
After the migration, Trivy against the production image:
| Severity | Before (Ubuntu) | After (Wolfi) |
|---|---|---|
| CRITICAL | 0 | 0 |
| HIGH | 3 | 0 |
| MEDIUM | 12 | 0 |
| LOW | 27 | 0 |
The Ubuntu image had 42 findings, all in OS packages we never used. The Wolfi image has zero because it only contains packages we explicitly installed. The Python dependency layer had 2 findings in both cases (a starlette version that needed bumping), but the OS layer went from noisy to clean.
Dockle (Dockerfile best-practices linter) reports one false positive: it flags settings.py as a potential credential file because of the filename. Suppressed with dockle -af settings.py.
What made it work
A few things that helped this go smoothly:
uv’s dependency groups. Separating dev tools from runtime dependencies in pyproject.toml is what makes --no-dev actually useful. Before, pytest and ruff shipped in production because they were in the same flat dependency list.
Wolfi’s package repository. Having s5cmd, Mesa GL libraries, and a recent bash available via apk meant we didn’t need to maintain custom installation scripts. The packages are rebuilt daily with CVE patches.
The three-stage pattern. base splitting into build and prod is now our standard for Python services. CI targets build, production targets prod, and both share the same dependency installation.
Pinning uv. Sounds trivial but it removed a real source of non-determinism. uv:latest in a Dockerfile is the same antipattern as pip install package without a version: it works until it doesn’t, and you won’t know why.