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

/opt/waratah, /opt/waratah-partition, /opt/waratah-registry

The three binaries, side by side — waratah start locates its child binaries next to its own executable.

/var/lib/waratah

Default data root. The RocksDB store, spill files, ingest staging, and the notebook directories all default under it; run-time mounts land here.

/opt/lib

The shared libraries the base image does not carry: libsqlite3.so.0 (the bundled PROJ links it dynamically) and libz.so.1 (the notebook stack’s manylinux wheels link it).

/opt/python

A self-contained CPython for the notebook sidecar (see below); on PATH as python3.

/opt/proj-data

PROJ’s data files (proj.db and company), re-homed from the build tree and pointed at by PROJ_LIB.

dist-console (embedded)

The pre-built grevillea console, compiled into waratah_api at build time.

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_sys is copied whole into the dependency layer, so the vendored RocksDB and LZ4 compile is cached and only re-runs when those sources change.

  • dist-console and waratah_notebook/python/requirements.lock are 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=debuginfo is 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.

  • --locked fails a stale lockfile at the cheap layer instead of after the expensive compile. The tracked .cargo/config.toml sets jobs = 4, the OOM-safe C++ parallelism, which the container build inherits.

After COPY . . brings the real sources, three details keep the final build honest:

  1. The stub layer wrote src/lib.rs into two bin-only members; COPY does not delete files, so the stub is removed (guarded by its marker comment) or those packages grow a phantom empty lib target.

  2. Cargo’s freshness check is mtime-based, and COPY preserves the context’s older timestamps while the stubs were created later — without a touch, the real sources would look unchanged and the final build would link the dependency layer’s stub rlibs. waratah_rocksdb_sys keeps its context timestamps precisely so its unchanged tree reuses the cached RocksDB build, and the waratah_api touch also re-runs its build script now that the real console is present.

  3. The build fails fast when dist-console/index.html is 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-base stage runs the interpreter itself (exec-form RUN, 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/lib beside libsqlite3.so.0 and 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.