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.

  1. First, we build an initial build container that prevents us from having to deliver our build tools.

     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 with set -ex, which makes troubleshooting easier.

  2. 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 variable UV_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 for uv sync.

  3. 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 that COPY 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.

  4. 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 user app and the group app 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.