Skip to content

Building containers

The module builds one package from a uv monorepo into a container with minimal required dependencies. Inside a workspace, a package usually depends on sibling packages by path, and those local dependencies must be present and installed in the right order. The module reads uv.lock, works out which local members the target needs, stages them, and installs everything in an order that caches well, so you don't assemble the build context by hand.

Note

Dependency-only workspace members are supported too, including projects with no build system and those with tool.uv.package = false.

The mental model
graph LR
  Uv["Uv<br/><i>(your source tree)</i>"] -->|workspace / workspaces| WS["UvWorkspaceSource<br/><i>(one per uv.lock)</i>"]
  WS -->|build| B["UvWorkspaceBuild<br/><i>(container + sync plan)</i>"]
  WS -->|install| C1["Container"]
  B -->|with_local_dependencies / copy_venv| C2["Container"]
  B -->|venv| V["UvVenv<br/><i>(venv + its Python)</i>"]
  • Uv holds the source tree it reads from the Dagger workspace (ws). Ask it for one workspace by path (workspace) or for the collection of every workspace it finds (workspaces).
  • UvWorkspaceSource is one uv workspace (the files rooted at a uv.lock). From it you can read the required uv version, audit it (a check), build a container step by step, or install everything in one call.
  • UvWorkspaceBuild is an in-progress build: a container plus the resolved sync plan. It exposes the individual pipeline steps so you can run your own steps between them, and exports the result as a container or a UvVenv.

The convenience methods (audit, install) cover the common cases. Use the pipeline when you need control over the individual steps.

The one-call path: install

install builds a container with everything a package needs:

$ dagger call uv workspace --path . install
ctr = uv(ws).workspace(".").install()

Under the hood this is equivalent to:

ctr = (
    uv(ws)
    .workspace(".")
    .build()
    .with_remote_dependencies()  # uv sync --no-install-local
    .with_workspace_files()  # scaffold local package stubs
    .with_local_dependencies()  # editable-install from stubs, then copy real source
)

Each step is its own layer. The layers go from most stable to most volatile, so the expensive work stays cached across builds:

  1. The module parses the workspace's uv.lock to find the local packages your target depends on, directly or transitively.
  2. Remote (third-party) dependencies are installed first. They change rarely, so this layer caches well. If the cache has grown past a configurable size, uv cache prune --ci runs afterwards.
  3. The needed local members are scaffolded as stubs (their pyproject.toml, files matched by project.license-files, and an empty module) and installed with uv sync. License patterns are relative to each member's pyproject.toml and support globs such as LICENSE* and licenses/**/*.txt. uv installs workspace members as editable by default, so this step only records path links. It depends on the packages' metadata, not their code, and stays cached when only source changes.
  4. The real source is copied in last, on top of the stubs. The editable installs already point at these paths, so the code goes live without a re-sync, and a source-only change invalidates only this final layer.

If you omit a package, the module mirrors a bare uv sync and installs the current package (the one declared at the workspace root). To install every member of the workspace instead, ask for all packages.

Note

By default the module installs into the system environment and creates no .venv. Set venv=True to create one in the workspace root. For multi-stage builds, see virtual environments.

Choosing a base image

If you don't provide a base container, the module starts from a Debian-based uv image pinned to the workspace's uv version, and uv provisions a managed Python on demand. Provide your own base when you need system packages, private registry auth, or a specific platform, such as a musl/Alpine base. The base you pass determines the platform and libc of the resulting environment.

Tip

If your custom base doesn't ship uv, the module installs it from the official image (auto_install_uv=True by default). You can also call with_uv yourself in the pipeline.

Caching

By default, the module mounts a Dagger cache volume at /mnt/cache/uv and sets UV_CACHE_DIR to point there. Python downloads and package downloads are both cached across builds, so a second uv sync or uv python install for the same versions skips the network.

If your base_container already sets UV_CACHE_DIR and doesn't have a directory there, the module mounts the cache volume at that path instead of the default.

When the cache grows past 100 GiB, the module runs uv cache prune --ci. This is on by default.

Supported build backends

uv installs packages using their declared build backend. This module also needs to know their import layout so it can create valid stubs and copy the right source files. Backend support here describes that staging behavior:

Backend or project type Layout support
uv_build Default src/ layout, custom module-root, and explicit module-name strings or lists.
Other backends, including Hatchling Conventional single-module flat or src/ layouts, using a module-name heuristic. Backend-specific package mappings and build hooks are not interpreted.
Dependency-only projects Their metadata and transitive local dependencies are staged; the project itself is not installed or copied as a Python package.

uv_build layouts

The default module name is derived from the distribution name by lowercasing it and replacing dots and dashes with underscores. Explicit names can differ from the distribution name; dotted names map to nested import directories. See the uv build backend documentation for the upstream settings.

For example, this configuration declares two modules at the package root:

[tool.uv.build-backend]
module-name = ["actual_package", "compat_package"]
module-root = "."

The build copies actual_package/ and compat_package/, including files inside those directories. Both module-root = "." and module-root = "" mean a flat layout. Unrelated root-level files such as tests/ and documentation are excluded from this source-copy step. For a non-empty source root such as src or python, the entire source root is copied. Package metadata and declared project.license-files are staged separately.

Non-editable installs

With no_editable=True, changes to source code trigger a rebuild of the package. Third-party dependencies remain cached.

Staging limitations

namespace = true and type-stub (-stubs) packages are not supported by the current scaffold, which creates __init__.py placeholders. External data directories, custom include rules, and extra build-hook inputs are not staged automatically. Supply required inputs before with_local_dependencies() when using such settings; a full project copy after installation is too late for files the backend needs while building.

Running the pipeline step by step

install is a convenience wrapper. To run something between the steps, drive the pipeline yourself. build prepares the build without installing anything and returns a UvWorkspaceBuild; you then call the steps in order:

b = uv(ws).workspace(".").build(package=["my-app"])
b = b.with_remote_dependencies()  # uv sync --no-install-local
# ... run your own step here, e.g. `pulumi install` ...
b = b.with_workspace_files()  # scaffold local package stubs
ctr = b.with_local_dependencies()  # editable-install from stubs, then copy real source last

Each step returns a new UvWorkspaceBuild (or, at the end, a Container), so the chain reads top to bottom. You can also swap in a different container mid-pipeline (for instance after installing OS packages) and keep the same resolved plan.