dazel
[!IMPORTANT]
dazel has moved to ArmisSecurity/dazel. This repository
is no longer maintained. New releases, issues and pull requests live there; the documentation below
describes the current release. Existing .dazelrc files keep working; see
Upgrading from 0.0.x.
Run Bazel inside a Docker container, as if it ran on your host.
Bazel builds are fast and reproducible, but only as reproducible as the machine they run on. dazel defines the build environment as a Docker image and runs bazel in it through a transparent proxy:
dazel build //my/package/...
dazel test //my/package:tests
dazel run //my/package:binary -- --flag
The arguments go to bazel in the container unchanged (docker exec, as an argv list, never through a
shell). The workspace and bazel's output directories are bind mounted at the same paths, so bazel-bin,
bazel-out and the rest work on the host exactly as they would without dazel. The container is built,
started and restarted on demand.
dazel is maintained by Armis open source. It was created by
nadirizr at nadirizr/dazel, which
published the 0.0.x releases. ArmisSecurity/dazel continues that project: 1.0 is a typed rewrite of the
original dazel.py that keeps its .dazelrc format, and the changelog covers the
whole history.
Installation
uv tool install dazel # or: pipx install dazel
dazel needs Python 3.11 or newer and Docker. Bazel itself does not need to be installed on the host.
Quick start
- Describe the build environment in a
Dockerfile.dazelin the workspace root. The
Dockerfile in this repository is a good start: Debian with
Bazelisk installed as bazel, so the workspace's
.bazelversion selects the Bazel release.
- Optionally, add a
.dazelrcnext to it (see Configuration):
DAZEL_REPOSITORY = "myproject"
DAZEL_VOLUMES = ["$HOME/.ssh:/root/.ssh:ro"]
DAZEL_PER_WORKSPACE_CONTAINER = True
- Run
dazel build //.... The first run builds the image and starts the container. Later runs reuse
If DAZEL_DOCKERFILE does not exist, dazel pulls DAZEL_REPOSITORY/DAZEL_IMAGE_NAME instead, so a team
can share a prebuilt image.
How it works
Starting the container
The first dazel command in a workspace, and any command after the container needs a restart, starts
the container. Start-up runs under a file lock (, or the
system temp directory when there's no output root), so concurrent dazels never race. A dazel that had to
wait checks again once it holds the lock, so the container is started once.
- Image. If
DAZEL_DOCKERFILEexists, dazel builds it:
docker build -t / -f .
Otherwise it pulls / . If the pull fails but the image exists
locally, dazel uses the local image.
- Services. If
DAZEL_DOCKER_COMPOSE_FILEis set and the compose command is installed, dazel runs
pull --ignore-pull-failures, build and up -d for DAZEL_DOCKER_COMPOSE_SERVICES, with
COMPOSE_PROJECT_NAME set. Outside per-workspace mode, up also gets --force-recreate.
Otherwise dazel creates the DAZEL_NETWORK network if it is missing, and starts each
DAZEL_RUN_DEPS container that isn't already running.
- Host directories. dazel creates on the host the bazel output root, the mounted output
- Container. dazel stops and removes any old container of the same name, then runs:
docker run -id --name=<container> --ulimit nofile=1048576:1048576
[--platform=…] [--privileged] [--user=…] -w <workdir>
-v <each mount> -p <each port> -e <each env var>
[--gpus …] [--group-add …] [--shm-size=…] [--net=<network>]
<image> <DAZEL_RUN_COMMAND>
- Run file. dazel writes the container name to
DAZEL_RUN_FILE.
Mounts
The container always gets these mounts, in this order:
| Mount | Why |
|---|---|
| Each DAZEL_VOLUMES entry | Your extra mounts. |
| | The workspace (or DAZEL_DIRECTORY), at the same path as on the host. |
| at the same paths | Bazel's outputs, so bazel-* symlinks resolve on the host and the cache survives restarts. :delegated unless DAZEL_DELEGATED_VOLUME is off. |
| DAZEL_BAZEL_DISK_CACHE | A shared disk cache, when configured. |
Running a command
When the container is running, attached to the right network, with the workspace mounted, and nothing calls for a restart, dazel runs:
docker exec -i -e COLUMNS=… -e LINES=… -e TERM=… [-t] [--privileged] [--user=…] <container>
<DAZEL_COMMAND> [--bazelrc=<DAZEL_BAZEL_RC_FILE>] [output flags] <your arguments>
-t is added when stdout is a terminal. The output flags are:
- As root (the default):
--output_user_root=/var/bazel/workspace/_bazel_.--output_base=
, or / in the
isolated modes.
- With
DAZEL_USER: just--output_user_root=.
DAZEL_COMMAND, your arguments run as a command of their own:
DAZEL_COMMAND= dazel ls / lists the container's root directory.
When the container restarts
dazel starts the container again when any of these is true:
- it isn't running;
- it doesn't have this workspace mounted;
- it isn't attached to the network;
- the run file is missing;
- the Dockerfile or any rc file that was read changed after the run file was written.
DAZEL_RUN_FILE, only the first three apply.
Commands
All arguments are passed to bazel, except these, which dazel handles when they are the whole command line:
| Command | Effect |
|---|---|
| dazel --dazel-version | Print the dazel version. |
| dazel --dazel-container-name | Print the container name for this workspace (useful in scripts). |
| dazel --dazel-clean-workspace | Remove this workspace's container, bazel output base and run file. This only works in per-workspace mode, because otherwise both are shared. |
| dazel --dazel-clean-legacy [--yes] | List, or with --yes delete, the shared output base left behind by dazel 0.0.x. See Upgrading from 0.0.x. |
A command killed by signal N exits with status 128 + N, like a shell. Ctrl-C reaches bazel in the
container and does not produce a traceback.
Parallel git worktrees
By default all workspaces share one container (dazel) and one bazel output base. The container is
recreated whenever a different workspace uses it, so builds in two git worktrees keep restarting each
other. Set DAZEL_PER_WORKSPACE_CONTAINER = True to give each workspace:
- Its own container and output base:
and_
/ , keyed on the workspace's real path. dazel writes
WORKSPACE: into /README, so a base whose worktree is gone can be identified and
removed.
- A shared image, compose project and network. Compose runs with
--project-directoryset to the
- Serialised start-up. A file lock (
) guards building the image and
- Ports from the primary checkout only. Linked worktrees publish no
DAZEL_PORTS, so fixed ports
DAZEL_PORTS_ONLY_PRIMARY).
A per-workspace output base starts cold. Share work between them with a disk cache:
DAZEL_BAZEL_DISK_CACHE = "~/.cache/bazel-disk:/root/.cache/bazel-disk" mounts one host directory in
every container (creating it first, so Docker doesn't create it root-owned). Point bazel's --disk_cache
at the container path in your bazelrc. dazel passes no bazel flags of its own.
Configuration
Where settings come from
Settings come from four layers. Lowest precedence first:
~/.dazelrc: per-user defaults, e.g. your registry or extra mounts..dazelrcin the workspace root: the project configuration, committed to the repository. The
DAZEL_RC_FILE environment variable reads a different file instead, e.g. a CI configuration.
.dazelrc.localin the workspace root: a developer's personal overrides. Add it to.gitignore.DAZEL_*environment variables, e.g.DAZEL_PLATFORM=linux/amd64 dazel build //....
DAZEL_ is a setting, and
everything else is ignored, so you can compute values with ordinary code:
import os
HOST_ARCH = "arm64" if os.uname().machine in ("arm64", "aarch64") else "amd64"
DAZEL_PLATFORM = f"linux/{HOST_ARCH}"
DAZEL_VOLUMES = [f"{os.path.expanduser('~')}/.netrc:/root/.netrc:ro"]
A later layer replaces a key's value whole; lists are not merged. Because rc files run as code, running dazel in a repository means trusting that repository (see SECURITY.md).
The workspace root is the closest directory, starting from the current one (or from
DAZEL_DIRECTORY), that holds a WORKSPACE, WORKSPACE.bazel or MODULE.bazel file.
Value types
| Type | Accepted values |
|---|---|
| text | Any string. A number is converted with str(). |
| list | A Python list or tuple, or a comma-separated string. Entries are stripped, and empty entries dropped. |
| flag | A boolean. From a string (e.g. an environment variable), "", 0, false, no and off are false, case-insensitively; anything else is true. |
| path | Text that also expands a leading ~ to your home directory. A relative path resolves against the workspace root. |
| command | Text split into words like a shell would (shlex), or a list of words. Nothing is expanded. |
Variable expansion. $VAR, ${VAR}, ${VAR-default} (default when unset) and ${VAR:-default}
(default when unset or empty) are expanded from the environment. An unset variable becomes the empty
string. In a value, $PWD is the directory dazel was started in.
Expansion happens only in DAZEL_VOLUMES, DAZEL_PORTS, DAZEL_ENV_VARS and the path settings.
Nothing is passed through a shell: $(…), backticks, globs and || stay literal.
Reference
Image
DAZEL_DOCKERFILE: path, default Dockerfile.dazel. The Dockerfile to build the image from. If
the file doesn't exist, dazel pulls the image instead, so a team can share a prebuilt image by
committing a .dazelrc without a Dockerfile. Editing the Dockerfile restarts the container on the next
command.
DAZEL_BUILD_DIRECTORY: path, default the workspace root. The docker build context. Set it when
the Dockerfile copies files relative to its own directory:
DAZEL_DOCKERFILE = "tools/docker/Dockerfile"
DAZEL_BUILD_DIRECTORY = "tools/docker"
DAZEL_DOCKER_BUILD_ARGS: command, default empty. Extra docker build arguments, inserted before
-t:
DAZEL_DOCKER_BUILD_ARGS = "--pull --build-arg BAZELISK_VERSION=v1.30.0 --cache-from registry.example.com/team/dazel"
DAZEL_REPOSITORY: text, default dazel. The repository part of the image reference
. It may include a registry host (registry.example.com/team),
which is where a missing Dockerfile makes dazel pull from. Empty uses the image name alone.
DAZEL_IMAGE_NAME: text, default dazel. The image name. With DAZEL_WORKSPACE_HEX, a built
image gets the suffix _.
DAZEL_PLATFORM: text, default empty. Passed as --platform to docker run. To build for the
same platform, also add --platform=… to DAZEL_DOCKER_BUILD_ARGS. Use it to run linux/amd64
toolchains on Apple silicon, or to pin the architecture on mixed CI fleets.
Container
DAZEL_INSTANCE_NAME: text, default dazel. The container name. In the isolated modes it gets the
suffix _. dazel --dazel-container-name prints the resolved name.
DAZEL_RUN_COMMAND: command, default /bin/bash. The container's main process, which only has to
stay alive (the container runs with -id). Use e.g. sleep infinity for images without bash.
DAZEL_DOCKER_COMMAND: command, default docker. The Docker CLI, e.g. podman or
sudo docker. dazel fails early if the first word isn't on PATH.
DAZEL_DOCKER_MACHINE: text, default empty. The name of a
docker-machine. When set and docker-machine is installed, every
Docker command runs with the environment from docker-machine env .
DAZEL_DOCKER_RUN_PRIVILEGED: flag, default False. Adds --privileged to docker run,
docker exec and run dependencies. Bazel's Linux sandbox needs it in most images. Without it, use
--spawn_strategy=local (or processwrapper-sandbox) in your bazelrc.
DAZEL_USER: text, default empty (the image's user, usually root). Passed as --user to
docker run and docker exec, e.g. 1000:1000. bazel then gets
--output_user_root= instead of the root layout, so point the output
root at a directory that user owns.
DAZEL_GROUPS: list, default empty. One --group-add per entry, e.g. the group id of a mounted
Docker socket for a non-root user.
DAZEL_GPUS: list, default empty. Joined with commas and passed as --gpus: all, or
device=0,1. Needs the NVIDIA container toolkit.
DAZEL_SHM_SIZE: text, default empty. Passed as --shm-size, e.g. 2g, for tests that need more
than Docker's 64 MB /dev/shm.
DAZEL_VOLUMES: list, expanded, default empty. Extra mounts in docker run -v syntax,
host:container[:options], placed before dazel's own mounts:
DAZEL_VOLUMES = ["$HOME/.ssh:/root/.ssh:ro", "/var/run/docker.sock:/var/run/docker.sock"]
DAZEL_PORTS: list, expanded, default empty. Published ports in docker run -p syntax,
[interface:]host:container, e.g. 127.0.0.1:8080:8080. In per-workspace mode only the primary
checkout publishes them (see DAZEL_PORTS_ONLY_PRIMARY).
DAZEL_ENV_VARS: list, expanded, default empty. NAME=value entries passed with -e to
docker run, so they are set for every command in the container:
DAZEL_ENV_VARS = ["CI=${CI:-}", "GIT_ASKPASS=$PWD/.git-askpass"]
DAZEL_DIRECTORY: text, default the workspace root. Mounts this directory and makes it the
container's working directory, instead of the workspace root. In the environment it is also where the
workspace search starts. Names and output bases stay keyed on the workspace root.
DAZEL_RUN_FILE: path, default .dazel_run. A file in which dazel records the container name
after each start; scripts can read it. Its timestamp also decides restarts: a Dockerfile or rc file
newer than it restarts the container. Empty disables it, and then a running container is never
restarted for changes.
DAZEL_RC_FILE (environment only): path, default . Reads this file as the
project rc instead. ~/.dazelrc and .dazelrc.local still apply around it.
Bazel
DAZEL_COMMAND: command, default /usr/bin/bazel. The command run by docker exec before your
arguments. Use /usr/local/bin/bazelisk, or bazel --noworkspace_rc to ignore the workspace bazelrc.
Keep build flags in a bazelrc instead. Empty runs your arguments as the command.
DAZEL_BAZEL_RC_FILE: text, default empty. Passed to bazel as --bazelrc=…, a path as the
container sees it, typically relative to the workspace, e.g. tools/ci.bazelrc.
DAZEL_BAZEL_USER_OUTPUT_ROOT: text, default ~/.cache/bazel/_bazel_. The host directory
holding bazel's output bases. dazel creates it if it is missing. The output base inside it is mounted
into the container at the same path, so bazel's output symlinks resolve on the host. If empty, dazel
derives it from where the workspace's bazel-out symlink points.
DAZEL_BAZEL_DISK_CACHE: path, default empty. host[:container], a bind mount for bazel's
disk cache. The container path must be absolute and
defaults to the host path. dazel creates the host directory. dazel passes no --disk_cache flag, so
point your bazelrc's --disk_cache at the container path:
DAZEL_BAZEL_DISK_CACHE = "~/.cache/bazel-disk:/root/.cache/bazel-disk"
DAZEL_DELEGATED_VOLUME: flag, default True. Adds :delegated to the output directory mounts,
which speeds up bind mounts on Docker Desktop for macOS and is ignored elsewhere.
Workspace isolation
DAZEL_PER_WORKSPACE_CONTAINER: flag, default False. One container and output base per
workspace. They are named and keyed on the md5 of the workspace's real path. The image, compose
project and network stay shared. See Parallel git worktrees.
DAZEL_PORTS_ONLY_PRIMARY: flag, default True in per-workspace mode, False otherwise. When on,
a linked git worktree publishes no DAZEL_PORTS; only the primary checkout does. Turn it off to
publish everywhere, e.g. when each worktree uses its own ports.
DAZEL_WORKSPACE_HEX: flag, default False. The older isolation mode. Each workspace gets its own
container , compose project , output base and, when built from a
Dockerfile, image . Prefer DAZEL_PER_WORKSPACE_CONTAINER, which shares the image and
services.
Services
DAZEL_DOCKER_COMPOSE_FILE: path, default empty. A Compose file whose services start before the
container, e.g. a database the tests use.
DAZEL_DOCKER_COMPOSE_SERVICES: list, default all services. The services to pull, build and start.
DAZEL_DOCKER_COMPOSE_COMMAND: command, default docker-compose. The Compose CLI. Set it to
docker compose for the Compose v2 plugin. If the first word isn't installed, dazel falls back to
DAZEL_RUN_DEPS.
DAZEL_DOCKER_COMPOSE_PROJECT_NAME: text, default dazel. COMPOSE_PROJECT_NAME for the
compose commands.
DAZEL_NETWORK: text, default dazel. The network the container joins (--net). With a compose
file it is the compose network . Without one, dazel creates it if it is missing.
Docker's built-in host, bridge and none are used as they are. Empty adds no --net.
DAZEL_RUN_DEPS: list, default empty. Containers to start on the network when there is no compose
file (or no compose command), as image[:tag][::name]. A container without an explicit name is called
, with / and : replaced by _. One that is already running is left alone.
DAZEL_RUN_DEPS = ["postgres:16::db", "redis:7"]
Recipes
$HOMEinside the container. Variables are expanded on the host. To give the container your host
DAZEL_ENV_VARS = ["HOME=$HOME"].
- SSH agent. Mount the socket and pass its path:
DAZEL_VOLUMES = ["$SSH_AUTH_SOCK:/ssh-agent"] and DAZEL_ENV_VARS = ["SSH_AUTH_SOCK=/ssh-agent"].
- Docker from inside the container. Mount
/var/run/docker.sockthroughDAZEL_VOLUMES. When you run
DAZEL_GROUPS to the socket's group id.
- Non-root containers. With
DAZEL_USER, setDAZEL_BAZEL_USER_OUTPUT_ROOTto a directory that user
Examples
A prebuilt image from a registry. Commit no Dockerfile, and every developer pulls the same image:
DAZEL_REPOSITORY = "registry.example.com/build"
DAZEL_IMAGE_NAME = "bazel-toolchain:2026.10"
DAZEL_DOCKERFILE = "/nonexistent" # never build; always pull
DAZEL_DOCKER_RUN_PRIVILEGED = True
Services for integration tests. Start a database next to the build container, on a network the tests can reach by service name:
DAZEL_DOCKER_COMPOSE_FILE = "tools/dazel/docker-compose.yml"
DAZEL_DOCKER_COMPOSE_COMMAND = "docker compose"
DAZEL_DOCKER_COMPOSE_SERVICES = ["postgres"]
DAZEL_NETWORK = "default" # joins <project>_default, compose's own network
A developer laptop with many worktrees.
import os
DAZEL_PER_WORKSPACE_CONTAINER = True
DAZEL_BAZEL_DISK_CACHE = "~/.cache/bazel-disk:/root/.cache/bazel-disk"
DAZEL_PORTS = ["127.0.0.1:8080:8080"]
DAZEL_PLATFORM = "linux/arm64" if os.uname().machine == "arm64" else "linux/amd64"
DAZEL_DOCKER_BUILD_ARGS = f"--platform={DAZEL_PLATFORM}"
CI. Select a CI rc file through the environment and keep it free of laptop-only settings:
DAZEL_RC_FILE=tools/ci.dazelrc dazel test //...
# tools/ci.dazelrc
DAZEL_DOCKERFILE = "tools/dazel/Dockerfile"
DAZEL_DOCKER_RUN_PRIVILEGED = True
DAZEL_COMMAND = "/usr/bin/bazel --noworkspace_rc"
DAZEL_BAZEL_RC_FILE = "tools/ci.bazelrc"
DAZEL_VOLUMES = ["/var/run/docker.sock:/var/run/docker.sock", "/mnt/cache:/mnt/cache"]
DAZEL_SHM_SIZE = "1g"
GPUs.
DAZEL_GPUS = ["all"]
DAZEL_ENV_VARS = ["NVIDIA_DRIVER_CAPABILITIES=compute,utility"]
Troubleshooting
No WORKSPACE or WORKSPACE.bazel or MODULE.bazel file found above …. dazel didn't find a
workspace from the current directory. Run it inside the workspace, or set DAZEL_DIRECTORY.
ERROR: Docker executable could not be found!. The first word of DAZEL_DOCKER_COMMAND isn't on
PATH.
The container keeps restarting. Something touches the Dockerfile or an rc file after every start,
or two workspaces share one container. For the second case, use DAZEL_PER_WORKSPACE_CONTAINER. To see
which container a workspace uses, run dazel --dazel-container-name.
bazel-bin symlinks are broken on the host. The output root moved, or the container runs as a
different user. Keep DAZEL_BAZEL_USER_OUTPUT_ROOT stable. With DAZEL_USER, point it at a directory
that user can write.
Permission errors on Linux when cleaning. The container runs as root, so its outputs are
root-owned. dazel --dazel-clean-workspace removes them through a throw-away container. You can do the
same by hand: docker run --rm -v .
Mounts are empty with Docker Desktop or colima. These share only parts of the host filesystem with their VM, usually your home directory. Keep workspaces, output roots and caches under shared paths.
A shell feature in a value stops working after upgrading. 1.0 no longer passes values through a shell. Compute the value in the rc file, which is Python, instead.
Upgrading from 0.0.x
1.0 reads the same .dazelrc keys. These things behave differently:
- No shell. Commands run as argv lists. Values are no longer passed through the host shell, so
||, globs and arithmetic in values are gone; only the variable forms listed in
Configuration are expanded. Compute anything else in the rc file, which is Python, or
in the calling script.
- Paths. Relative
DAZEL_DOCKERFILE,DAZEL_DOCKER_COMPOSE_FILEandDAZEL_RUN_FILEresolve against
- Exit status. A command killed by a signal exits with
128 + Ninstead of raising an exception. - Workspaces.
WORKSPACE.bazelandMODULE.bazelmark a workspace too. Windows support was dropped. - New in 1.0.
--dazel-* commands.
- ~/.dazelrc and .dazelrc.local.
- DAZEL_BUILD_DIRECTORY and DAZEL_GROUPS.
- Restarting the container when an rc file changes.
After switching to DAZEL_PER_WORKSPACE_CONTAINER, start dazel once (it removes the old shared dazel
container), then run dazel --dazel-clean-legacy to see the shared output base 0.0.x left under the
output root, and dazel --dazel-clean-legacy --yes to delete it. Scripts that hard-code the container name
dazel should read .dazel_run or call dazel --dazel-container-name.
On Linux the container runs as root, so files it writes into bind-mounted directories are root-owned. When dazel can't remove them, it retries through a throw-away container of the same image. If that also fails, it prints the command to run.
Development
uv sync # create .venv with dazel and the dev tools
prek install # install the pre-commit and commit-msg hooks
uv run pytest # unit tests with coverage
uv run pytest -m integration --no-cov # lifecycle tests against a real docker daemon
prek run --all-files # ruff, pyrefly, uv.lock and file hygiene
Commit messages and pull request titles follow Conventional Commits.
gitfluff checks them in the commit-msg hook and in CI.
Releases are cut by pushing a v tag that matches pyproject.toml. The release workflow then
publishes to PyPI through trusted publishing.
License
MIT. Copyright (c) 2016 nadirizr; copyright (c) 2026 Armis Security.