Python Docker Container with uv
¶
We divide our Docker workflow into different layers. This allows us to provide
new builds more quickly. We start with the layers that change the least so that
we can cache the artefacts for as long as possible. This is also the reason why
we keep the installations of the dependencies from uv.lock
and the
installation of our application strictly
separate – our code probably changes faster than that of the dependencies.
See also
First, we build an initial build container that prevents us from having to deliver our build tools.
See also
1# syntax=docker/dockerfile:1.9 2FROM ubuntu:noble AS build 3 4SHELL ["sh", "-exc"] 5 6RUN <<EOT 7apt update -qy 8apt install -qyy \ 9 -o APT::Install-Recommends=false \ 10 -o APT::Install-Suggests=false \ 11 build-essential \ 12 ca-certificates \ 13 python3-setuptools \ 14 python3.12-dev 15EOT
- Line 4:
If you are using Podman, you should use the Docker compatibility mode.
- Line 6:
If necessary, you can also prefix each
RUN
script withset -ex
, which makes troubleshooting easier.
We then build a virtual Python environment with our application in the
/app
directory and copy it to our runtime container. One of the advantages of this is that the same base container can be used for different Python versions and virtual environments. With uv 0.4.4 this has become much easier thanks to the environment variableUV_PROJECT_ENVIRONMENT
:1COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv 2 3ENV UV_LINK_MODE=copy \ 4 UV_COMPILE_BYTECODE=1 \ 5 UV_PYTHON_DOWNLOADS=never \ 6 UV_PYTHON=python3.12 \ 7 UV_PROJECT_ENVIRONMENT=/app
- Line 1:
Safety-conscious organisations should check and pack
uv
themselves.- Line 3:
This prevents uv from complaining about not being able to use hardlinks.
- Line 4:
The Python packages are byte-compiled to shorten the start times of the container.
- Line 6:
Select Python version.
- Line 7:
Declare
/app
as target foruv sync
.
Now we create the
app
Dockerfile:1COPY pyproject.toml /_lock/ 2COPY uv.lock /_lock/ 3 4RUN --mount=type=cache,target=/root/.cache <<EOT 5cd /_lock 6uv sync \ 7 --locked \ 8 --no-dev \ 9 --no-install-project 10EOT 11 12COPY . /src 13RUN --mount=type=cache,target=/root/.cache \ 14 cd /src && uv sync --locked --no-dev --no-editable
- Lines 1–2:
The
lock
files are moved to a directory that is not in the runtime container. The slash at the end ensures thatCOPY
automatically creates/_lock/
.- Line 4:
For example, the build cache mount prevents all wheels from having to be rebuilt if the layer with your dependencies has to be rebuilt.
See also
- Lines 6–9:
The dependencies are synchronised without the application itself. This layer is cached until the uv.lock file file or
pyproject.toml
changes.- Line 14:
myapp
is installed from/src
without any dependencies.
Finally, we create the runtime container:
1FROM python:3.12-slim 2SHELL ["sh", "-exc"] 3 4ENV PATH=/app/bin:$PATH 5 6RUN <<EOT 7groupadd -r app 8useradd -r -d /app -g app -N app 9EOT 10 11ENTRYPOINT ["/docker-entrypoint.sh"] 12 13STOPSIGNAL SIGINT 14 15RUN <<EOT 16apt update -qy 17apt install -qyy \ 18 -o APT::Install-Recommends=false \ 19 -o APT::Install-Suggests=false \ 20 python3.12 \ 21 libpython3.12 \ 22 libpcre3 \ 23 libxml2 24 25apt clean 26rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 27EOT 28 29COPY docker-entrypoint.sh / 30COPY . /app/ 31 32COPY --from=build --chown=app:app /app /app 33 34USER app 35WORKDIR /app 36 37RUN <<EOT 38python -V 39python -Im site 40python -Ic 'import myapp' 41EOT
- Line 4:
Optional: Adds the virtual environment to the search path.
- Lines 6–9:
Runs the application as a service user
app
.- Line 13:
In the Python ecosystem, it is not necessarily common for your application to respond to a
SIGTERM
.STOPSIGNAL SIGINT
is an easy way to work around this.- Lines 20–21:
Note that the dependencies at runtime are different from the dependencies at build time. Also, there is no
uv
.- Lines 29–30:
If your application is not a Python package installed with
uv sync
, you must copy your application into the container here.- Line 32:
This copies the pre-built directory
/app
into the runtime container and changes the permissions on the service userapp
and the groupapp
in one step.- Lines 37–41:
Optional: I usually use this introspection for a smoke test, which ensures that the application can actually be imported.