Docker Builds: docker build vs Bazel rules_oci

2026/02/18

BUILDbazeldocker🍞

I wanted to understand what it actually means to “build a container image with Bazel.” So I built the same image both ways — a tiny Python app, once with a Dockerfile, once with rules_oci. Same output. Totally different build philosophy.

Docker: Imperative, Layer-by-Layer

FROM python:3.12-slim
COPY app/main.py /app/main.py
CMD ["python", "/app/main.py"]

You’re giving Docker a recipe. Do this, then this, then this. Each instruction creates a layer, and layers are cached by position — if the COPY changes, everything after it re-runs too. Even CMD, which didn’t change at all.

Bazel: Declarative, Content-Addressed

pkg_tar(
    name = "app_layer",
    srcs = ["//app:main.py"],
    package_dir = "/app",
)

oci_image(
    name = "hello_image",
    base = "@python3_slim_linux_amd64",
    entrypoint = ["python", "/app/main.py"],
    tars = [":app_layer"],
)

oci_load(
    name = "hello_tarball",
    image = ":hello_image",
    repo_tags = ["bazel-hello:latest"],
)

You’re telling Bazel what the image contains, not how to assemble it. Each target is cached by content hash — if main.py changes, only app_layer rebuilds. The base image? Untouched. No cascade.

The Difference That Matters

For this toy example, who cares. Both build in under 2 seconds.

But imagine your real Dockerfile: apt-get install, pip install, CUDA kernel compilation, tokenizer downloads. 20 minutes of layers. You change one line of application code, and Docker re-runs everything after the COPY.

Bazel doesn’t. It knows exactly which targets are affected and skips the rest.

Docker Bazel rules_oci
Cache key Instruction position + content Content hash per target
Change app code COPY + everything after rebuilds Only app_layer target
Needs Docker daemon? Yes, for build + run No — only for docker load
Output Image in Docker’s internal store A directory (OCI layout) or load script

The Gotcha: oci_image Isn’t a Tar

This tripped me up. oci_image doesn’t produce a file — it produces a directory (OCI image layout with index.json + blobs/). You can’t tar -tf it. You need oci_load to bridge it into Docker-land.

pkg_tar    --> app_layer.tar        (a file, inspectable)
oci_image  --> hello_image/         (a directory, OCI layout)
oci_load   --> hello_tarball.sh     (a script that does docker load)

Different rules, different output types. bazel cquery --output=files is your friend here.

Should You Migrate?

Honestly — maybe not. If Docker builds are fast enough and your team understands Dockerfiles, the migration cost is real. Bazel’s rules_oci shines in monorepos where build caching across many targets saves serious time. For a single service with a straightforward Dockerfile, it’s probably not worth it.

But if you’re already in Bazel-land and building container images is your bottleneck — rules_oci is the move.