Self-hosted service
astral-sh/uv-docker-example avatar
astral-sh/uv-docker-example

uv-docker-example shows three ways to put uv in a Docker image

An example of using uv in Docker images

822 stars87 forksDockerfileApache-2.0

At a glance

What is it?
astral-sh/uv-docker-example is a small reference repository for running Python projects with uv inside containers, with a development Dockerfile, a multistage build and a standalone interpreter variant. Its real value is in the build layer ordering and the environment variables, not in the hello world app it ships.
Who is it for?
uv-docker-example fits a Python team containerising a uv-managed project that wants a working reference for layer caching, a bind-mounted development loop and a production variant, and it is worth an afternoon of reading even if you copy nothing else.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 2 days ago.
What is it written in?
Mainly Dockerfile, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The app is a excuse, the Dockerfile is the point

uv-docker-example is described as an example project for using uv in Docker images, with a focus on best practices for developing with the project mounted in the local image. The README points to the uv Docker integration guide for background.

The application is deliberately trivial. The Python code lives at src/uv_docker_example/__init__.py and contains a command line entrypoint and a basic FastAPI application, and both just display hello world output. The pyproject.toml names the project uv-docker-example at version 0.1.0, requires Python 3.11 or newer, depends on fastapi with the standard extra, uses hatchling as the build backend, and defines a hello entrypoint.

toml
[project.scripts]
hello = "uv_docker_example:hello"

[dependency-groups]
dev = [
    "ruff>=0.6.2",
    "fastapi-cli>=0.0.5",
]

Ruff is included as an example development dependency, which tells you the intent: this repository is a template for a real project's Dockerfile rather than a product. The value is in the structure of the image, and the hello world is there so that you can verify the plumbing works before you bring your own code.

The repository is Apache-2.0 licensed with a LICENSE-APACHE and a LICENSE-MIT file at the root, it has no GitHub releases, and the last push was on 2026-10-21.

Three Dockerfiles, three different jobs

The project overview names three image definitions and the differences between them are the substance of the repository.

The base Dockerfile defines the image and is described as including the installation of uv, the installing of the project dependencies and the project separately for optimal image build caching, placing environment executables on the PATH, and running the web application in development mode.

The multistage.Dockerfile extends that example to use multistage builds in order to reduce the final size of the image, and it runs the application in production mode.

The standalone.Dockerfile extends the multistage example further by using a managed Python interpreter in a multistage build instead of the system interpreter that comes with the base image. It also runs in production mode.

That is a clean progression and it maps onto real decisions. The single stage image is the one you want during development because the source code is present and the environment is mutable. The multistage build drops the build-time artefacts out of the final image. The standalone variant goes further and does not rely on the base image's Python at all.

The base image is a pre-built uv image.

dockerfile
FROM ghcr.io/astral-sh/uv:python3.12-trixie-slim

A slim Debian trixie base with Python 3.12 and uv already present, which saves you a layer of installing the tool itself.

Dependencies are installed before the source is copied

The layering in the base Dockerfile is the part worth copying. Dependencies come first, from the lockfile and the project definition only, and the project source is copied afterwards.

dockerfile
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 --locked --no-install-project

# Then, add the rest of the project source code and install it
# Installing separately from its dependencies allows optimal layer caching
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked

Three techniques are at work. The bind mounts for uv.lock and pyproject.toml mean the dependency layer is invalidated only when those two files change, and the source copy comes after, so editing application code does not reinstall dependencies. The --no-install-project flag on the first sync installs the dependencies without installing the project itself, which is what lets the two steps be separated. And the cache mounts keep the uv download cache out of the image while still reusing it between builds.

The --locked flag matters more than it looks. It makes the sync fail if the lockfile does not match the project definition, so a build cannot silently resolve different versions from what was committed.

The uv.lock file is committed at the repository root, so the mechanism has something to verify against.

Environment variables carry the runtime policy

The base Dockerfile sets a small set of environment variables, and each one encodes a decision you would otherwise have to rediscover.

dockerfile
ENV PYTHONUNBUFFERED=1
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV UV_NO_DEV=1
ENV UV_TOOL_BIN_DIR=/usr/local/bin

PYTHONUNBUFFERED stops Python buffering stdout and stderr, which the comment explains is to avoid the application crashing without emitting any logs because of buffering. In a container that is a real failure mode, because the log output is the only evidence you get.

UV_COMPILE_BYTECODE turns on bytecode compilation, so the interpreter does not compile the source on every start.

UV_LINK_MODE=copy is the interesting one. The comment says to copy from the cache instead of linking since it is a mounted volume. uv can hardlink files out of its cache into the virtual environment, which is fast and space efficient, but hardlinks cannot cross a filesystem boundary, and a bind-mounted volume is a different filesystem. Without this setting the install would fail or fall back badly in exactly the development setup this example is built for.

UV_NO_DEV omits development dependencies from the sync, and UV_TOOL_BIN_DIR puts installed tools such as the console scripts on the PATH so they can be run out of the box. The PATH for the project environment is set separately with ENV PATH="/app/.venv/bin:$PATH".

The entrypoint is reset with ENTRYPOINT [] so that uv is not invoked implicitly, and the final USER nonroot switches away from root.

The compose file uses watch rather than a bind mount

The compose.yml is where the development loop is defined, and it deliberately does not use a plain bind mount.

yaml
    develop:
      watch:
        # Sync the working directory with the `/app` directory in the container
        - action: sync
          path: .
          target: /app
          # Exclude the project virtual environment — it could be for a
          # different platform in the container
          ignore:
            - .venv/

        # Rebuild the image if dependencies change by checking uv.lock
        - action: rebuild
          path: ./uv.lock

The README calls the watch directive a best-practice method for updating the container on local changes, and links the Docker documentation comparing compose watch against bind mounts.

The two actions cover the two kinds of change. A sync copies the working directory into the container at /app when files change, and a rebuild re-runs the image build when uv.lock changes, which is exactly when the dependency layer needs to be regenerated. With a plain bind mount, a lockfile change would leave the container with stale dependencies until you rebuilt by hand.

The ignore entry for .venv is not cosmetic. The virtual environment on your host may be built for a different platform than the container, so syncing it in would break the container's environment, and the same reasoning applies to the .dockerignore file, which the README notes includes an entry for .venv to keep it out of image builds.

One caveat the README calls out: the .dockerignore file is not applied to volume mounts during container runs. The two mechanisms have to be configured separately.

Running it, and the difference between the two entrypoints

A run.sh utility is provided for quickly building the image and starting a container, and it demonstrates bind mounts for the project and the virtual environment directories so that edits are reflected immediately.

console
$ ./run.sh

After that, the site is at http://localhost:8000. The image starts the web application by default, and the script also provides a command line interface for demonstration purposes, invoked by passing an argument.

console
$ ./run.sh hello

The compose route is documented as more complex than docker run but with more robust support for various workflows.

bash
docker compose up --watch 

The useful commands section shows how to check the environment is up to date after an image build, with the uv sync --locked call and its output.

console
$ ./run.sh uv sync --locked
Audited 2 packages ...

You can also drop into a shell in the container with ./run.sh /bin/bash, build the image without running anything with docker build ., and build the multistage variant with docker build . --file multistage.Dockerfile.

The compose service also sets UV_NO_DEV=0 in the environment to include development dependencies while working locally, and runs as root during development so the environment is mutable, with the README noting that you could instead set UV_PROJECT_ENVIRONMENT to a path owned by the non-root user. That is a real trade: root in development avoids permission friction on the mounted volume, at the cost of the container not matching the production user model.

Where this example stops being enough

The first limitation is that the default image is a development image. The CMD runs the application through uv run fastapi dev with hot reloading, and the README says that in production you should use fastapi run instead. Hot reload needs the source and the watcher, which is the opposite of what a production image wants, so the base Dockerfile is not something you would ship as is. The multistage and standalone files are the production-shaped answers, and they are where the actual configuration decisions live.

The second limitation is that the Dockerfile shown in full here ends with a truncated command line, so the exact host and port arguments are not visible in what the project publishes in this form. You would need to open the repository.

The third is scope. There is no health check, no multi-stage user permission hardening beyond the nonroot user, no read-only filesystem, no distroless base, and no image scanning or SBOM step. This is a starting point for a development-friendly image, not a hardened production template.

The fourth is that the whole approach depends on a mounted volume for the development loop, which means it is aimed at a single-machine local workflow. A CI system that builds the image and runs the tests inside it does not need the compose watch configuration at all, and adding it there is noise.

Finally, the application itself is hello world with two dependencies, so the caching behaviour is demonstrated on a trivial scale. The layer ordering still holds, but the size of the win from the cache mounts only becomes visible on a real dependency tree.

How it compares to a plain Dockerfile with pip

The comparison worth making is against the Dockerfile you would write without uv, which is a base python image, a copy of the requirements file, a pip install, a copy of the source and a CMD.

The plain version has the same layering problem and the same fix, so the caching argument alone does not carry uv. What uv adds is the lockfile as the unit of truth, with --locked making the install a verification rather than a resolution, and a single tool that manages both the project environment and the tools installed into it, which is what UV_TOOL_BIN_DIR and UV_COMPILE_BYTECODE are about.

Against a hand-rolled multi-stage Dockerfile, this repository's contribution is that the awkward decisions are already made and commented. The copy-instead-of-link setting for the mounted volume, the exclusion of .venv from both the image and the sync, the reset of the entrypoint, the separate dependency and project install steps: each of these is a problem you hit once and then remember forever. Reading them in one file is faster than rediscovering them.

Against a template repository or a generator, the difference is that this is readable. It is a handful of files you can copy into a project and edit, with no framework layer and no tool that decides things for you. The trade is that it does not scale to a monorepo with many services, where you would want those decisions factored into a shared base image instead.

And against doing nothing, the honest answer is that for a small service the default Dockerfile is fine and this repository is a way to learn the tricks rather than a dependency.

Editorial conclusion

uv-docker-example fits a Python team containerising a uv-managed project that wants a working reference for layer caching, a bind-mounted development loop and a production variant, and it is worth an afternoon of reading even if you copy nothing else. It does not fit a team that needs a hardened production image out of the box, because the default image runs the application in development mode with hot reloading and the README notes you should switch to fastapi run in production. Before adopting, read the three Dockerfiles side by side to decide which one matches your build, check that your uv.lock is committed so that uv sync --locked has something to verify against, and confirm that UV_NO_DEV is set the way you intend in each environment.

Frequently asked questions

What does the uv-docker-example project demonstrate?

It demonstrates how to use uv in Docker images with a focus on best practices for developing with the project mounted in the local image. It ships a base Dockerfile for development, a multistage.Dockerfile that runs in production mode, and a standalone.Dockerfile that uses a managed Python interpreter.

How do I run the example application in a container?

Run ./run.sh to build the image and start a container, then check http://localhost:8000. The compose route is docker compose up --watch, and the command line entrypoint is invoked as ./run.sh hello.

Why does the uv Dockerfile install dependencies before copying the source?

The first sync uses bind mounts for uv.lock and pyproject.toml with the --no-install-project flag, so the dependency layer is rebuilt only when those files change. The source is copied afterwards, which the comment describes as allowing optimal layer caching.

What do the UV_NO_DEV, UV_LINK_MODE and UV_COMPILE_BYTECODE variables do?

UV_NO_DEV omits development dependencies from the sync, UV_LINK_MODE is set to copy because the cache is hardlinked onto a mounted volume, and UV_COMPILE_BYTECODE enables bytecode compilation. UV_TOOL_BIN_DIR places installed tools on the PATH so they can be run out of the box.

Why is .venv excluded in both the .dockerignore and the compose watch configuration?

The .dockerignore keeps the .venv out of image builds, and the compose watch sync ignores it because the host virtual environment could be for a different platform than the container. The README also notes that the .dockerignore file is not applied to volume mounts during container runs, so the two have to be configured separately.

Is the default uv-docker-example image suitable for production?

Not as it stands. The base Dockerfile runs the application in development mode with fastapi dev and hot reloading, and the README notes that in production you should use fastapi run instead. The multistage.Dockerfile and standalone.Dockerfile are the production-shaped variants.

Official sources

  1. astral-sh/uv-docker-example on GitHub
  2. Issues
  3. License: Apache-2.0
  4. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/astral-sh-uv-docker-example.svg)](https://hysenlabs.com/projects/astral-sh-uv-docker-example)

Was this useful?