Docker Image
The repo-root Dockerfile builds a minimal production image of Waratah:
the three binaries, the shared libraries they need, the embedded web
console, and nothing else — no shell, running as a non-root user. This
page is the developer view: how the build is layered, why it is built
that way, and how to build it yourself. For running the published image,
see the user page.
What ends up inside
| Path | Contents |
|---|---|
|
The three binaries, side by side — |
|
Default data root. The RocksDB store, spill files, ingest staging, and the notebook directories all default under it; run-time mounts land here. |
|
The shared libraries the base image does not carry: |
|
A self-contained CPython for the notebook sidecar (see below); on
|
|
PROJ’s data files ( |
|
The pre-built grevillea console, compiled into |
The runtime stage is cgr.dev/chainguard/glibc-dynamic — glibc, libgcc,
and libstdc+ for the RocksDB-linked binaries; the Rust standard
library and libproj are linked statically. The image runs as uid/gid
65532. EXPOSE covers 8080 (the RDF4J HTTP facade and console) and
7005 (the compute gRPC port, for multi-copy deployments).
The process model is one process per container:
ENTRYPOINT ["/opt/waratah"]
CMD ["standalone", "--http", "0.0.0.0:8080", "--data-dir", "/var/lib/waratah"]
standalone — the in-process engines, no child processes — is the right
shape for a container’s PID 1. --http 0.0.0.0:8080 matters: the
127.0.0.1 default would be unreachable from outside the container.
waratah-partition and waratah-registry are still shipped for future
per-component deployments (selected by overriding the command). Running
the multi-process waratah start inside one container works as a local
convenience but needs docker run --init so child processes are reaped.
The dependency cache layer
Recompiling vendored RocksDB on every source change would make the image
unusable as a dev loop, so the first stage builds the dependency graph
as its own cached layer using a stub workspace: every member’s
Cargo.toml is copied, the member sources are replaced by empty
src/lib.rs stubs, and one cargo build --release --locked --workspace
--lib compiles nothing but dependencies.
Why not cargo-chef: its cook step builds without --workspace, and
this workspace’s root is a real package (it hosts the workspace-level
integration tests) with no lib or bins of its own — cook resolves to a
three-second no-op and every dependency lands in the uncached final
layer. The stub layer does chef’s job explicitly for this workspace
shape.
Layer discipline, in brief:
-
waratah_rocksdb_sysis copied whole into the dependency layer, so the vendored RocksDB and LZ4 compile is cached and only re-runs when those sources change. -
dist-consoleandwaratah_notebook/python/requirements.lockare deliberately NOT dependency-layer inputs — they are read only by real member sources, and copying them early would invalidate the expensive layer on every console or lockfile refresh. -
CARGO_PROFILE_RELEASE_STRIP=debuginfois set before the dependency build: a profile is part of every unit’s fingerprint, so both builds must agree for the cache to be reused. Debug info is stripped (RocksDB debug info is large) but the symbol table is kept, so panic backtraces still resolve. -
--lockedfails a stale lockfile at the cheap layer instead of after the expensive compile. The tracked.cargo/config.tomlsetsjobs = 4, the OOM-safe C++ parallelism, which the container build inherits.
After COPY . . brings the real sources, three details keep the final
build honest:
-
The stub layer wrote
src/lib.rsinto two bin-only members;COPYdoes not delete files, so the stub is removed (guarded by its marker comment) or those packages grow a phantom empty lib target. -
Cargo’s freshness check is mtime-based, and
COPYpreserves the context’s older timestamps while the stubs were created later — without atouch, the real sources would look unchanged and the final build would link the dependency layer’s stub rlibs.waratah_rocksdb_syskeeps its context timestamps precisely so its unchanged tree reuses the cached RocksDB build, and thewaratah_apitouch also re-runs its build script now that the real console is present. -
The build fails fast when
dist-console/index.htmlis missing from the context, rather than silently embedding the stub console.
Native build inputs
The builder (docker.io/rust:1.99-slim-trixie by default; override with
the RUST_VERSION build arg) installs g++ and clang/libclang-dev
(RocksDB and bindgen), protobuf-compiler (the tonic-prost build
script), and cmake/make/libsqlite3-dev/sqlite3 because PROJ is
built from source — trixie’s packaged proj is below proj-sys’s
pkg-config floor. Two deliberate absences: liburing (the vendored
RocksDB would probe for it and add a runtime dependency the Chainguard
base cannot satisfy) and git — waratah_buildinfo tolerates a missing
git, and CI pins provenance through the WARATAH_GIT_DESCRIBE and
VERGEN_GIT_* build args instead.
The notebook interpreter (python-build-standalone)
The image carries a Python interpreter — and only the interpreter — for
the notebook sidecar. The heavy stack (jupyter-server, pandas, matplotlib,
rdflib, pyshacl) is not baked in: it installs at run time, on the
first --with-notebooks start, exactly as a bare-metal deployment does.
waratah_notebook’s provisioning (`waratah_notebook/src/python_env.rs)
creates a venv at {data_dir}/notebooks-venv — on the data volume, so
it persists across restarts and refreshes when the lock embedded in the
binary changes — and pip install`s the pinned lock. The image’s whole
job is that a `python3 (3.11+) exists on PATH.
The interpreter is a
[python-build-standalone](https://github.com/astral-sh/python-build-standalone)
distribution (the self-contained CPython builds that uv installs):
one relocatable tarball whose only dependency is glibc, which is what a
shell-less, package-manager-less base needs. The Dockerfile pins the
release tag and the tarball’s sha256; refresh deliberately, keeping
the 3.14 line (the lock was generated on 3.14.x).
Two image-level environment details make the provisioning work: PATH
puts /opt/python/bin first (the python3 lookup), and
PIP_NO_CACHE_DIR=1 (uid 65532 has no writable HOME for pip’s cache).
The manylinux_2_28 contract
Run-time pip must resolve everything from pre-built wheels — the
runtime image has no compilers, headers, or build tools, and must never
need them. That requires:
-
glibc >= 2.28 — asserted at build time. A
runtime-basestage runs the interpreter itself (exec-formRUN, no shell needed) and fails the image build if the base’s glibc is below the floor. pip’s own tag detection (ctypes→gnu_get_libc_version) is equally shell-less, so wheel selection works on the same terms. -
The manylinux companion libraries — libstdc++/libgcc (already shipped for the RocksDB-linked binaries) and zlib: numpy links
libz.so.1, which the Chainguard base does not carry, so it is staged into/opt/libbesidelibsqlite3.so.0and asserted the same way at build time.
Validated end to end: the full requirements.lock installs from
manylinux wheels and imports (numpy, pandas, matplotlib, rdflib,
pyshacl, jupyter-server) on the actual runtime base.
Two known limits: the image is x86_64 (an aarch64 build swaps in the arm64 python-build-standalone asset and goes through buildx), and Jupyter terminals do not work — the terminal feature spawns a shell, and the base deliberately has none. Notebooks and kernels are unaffected.
Building the image
Prerequisite: the console. It is gitignored, so produce it first — in a
grevillea checkout run pnpm build:console and copy the resulting
dist-console/ directory into the Waratah workspace root.
The wrapper script is the recommended entry point. It fails fast on the
things that would otherwise cost a full build cycle: docker missing,
no git work tree, and a missing/unreadable dist-console/ (the
Dockerfile’s own console gate fires only after the dependency layer
has compiled). It also pins every provenance field explicitly —
including a real false for a clean tree, where the naive
git diff --quiet || echo true yields the empty string that degrades
to placeholder provenance:
scripts/build_container_image.sh # docker.io/ashleysommer/waratah:latest
scripts/build_container_image.sh waratah:dev # any tag as the one argument
The underlying commands, for reference. Plain, without provenance pinning:
docker build -t waratah:dev .
With provenance pinned (an empty value counts as unset and degrades to placeholder provenance):
docker build -t waratah:dev \
--build-arg WARATAH_GIT_DESCRIBE="$(git describe --long --tags --dirty)" \
--build-arg VERGEN_GIT_SHA="$(git rev-parse HEAD)" \
--build-arg VERGEN_GIT_BRANCH="$(git branch --show-current)" \
--build-arg VERGEN_GIT_COMMIT_TIMESTAMP="$(git log -1 --format=%cI)" \
--build-arg VERGEN_GIT_DIRTY="$(git diff --quiet || echo true)" \
.
The final build compiles only -p waratah_bin -p waratah_partition -p
waratah_registry (waratah-exp must exist in the workspace but is not
part of the image). For releases, pin the Chainguard base by digest —
the form is noted in the Dockerfile.
The build context
.dockerignore is whitelist-style: everything is excluded, then exactly
the workspace manifests, the member trees, .cargo/config.toml, and
dist-console are re-included. Docs, benchmark corpora, scripts/,
target/, and VCS state never reach the builder. The root package’s
tests/ directory stays excluded on purpose — the image build runs
cargo build, which never needs the explicitly declared integration
test targets.