
Modern Bazel rules for building OCI container images with advanced performance optimizations
Supports both Bzlmod and WORKSPACE setups. For WORKSPACE setup instructions, see the releases page.
rules_img was originally written by (and receives ongoing support from)
.
Add to your MODULE.bazel:
bazel_dep(name = "rules_img", version = "0.3.10")
.bazelrc# The compression algorithm to use ("gzip" or "zstd")
common --@rules_img//img/settings:compress=zstd
# Number of parallel compression workers (gzip only)
# "1" uses single-threaded stdlib gzip, "auto" uses compilation mode defaults,
# "nproc" uses all available CPUs, or specify a number (e.g., "4").
# Any number above 1 uses pgzip, which results in slightly larger files,
# but is otherwise fully compatible with the gzip format.
common --@rules_img//img/settings:compression_jobs=auto
# Compression level
# gzip: 0-9, where 0=no compression, 1=fast compression, 9=best compression
# zstd: 1-4, where 1=fast compression, 4=best compressions
# "auto" uses compilation mode defaults (-1 for default, 1 for fastbuild, 9 for opt)
common --@rules_img//img/settings:compression_level=auto
# Support for seekable eStargz layers
# with the containerd stargz-snapshotter
common --@rules_img//img/settings:estargz=enabled
# Create parent directory entries in tar files for all files
# When enabled, parent directories are automatically created in the tar for all file entries.
# This is disabled by default to avoid overwriting existing directory permissions in lower layers.
common --@rules_img//img/settings:create_parent_directories=disabled
# How to handle duplicate tree artifacts (directories) in layers.
# "full" stores each tree at its intended path (no tree-level deduplication).
# "deduplicate_symlink" replaces duplicate trees with symlinks to the first occurrence.
common --@rules_img//img/settings:layer_tree_artifact_handling=full
# How to handle runfiles when packaging binaries into layers.
# "auto" shares runfiles if RunfilesGroupInfo is provided, "shared" always shares,
# "private" never shares.
common --@rules_img//img/settings:runfiles_sharing_mode=auto
# Path for shared runfiles inside the image when runfiles sharing is enabled.
common --@rules_img//img/settings:runfiles_shared_path=/.shared_runfiles
# Opt-in to stamping of image_push rules
common --@rules_img//img/settings:stamp=disabled
# The push strategy to use (see below for more info).
# "eager", "lazy", "cas_registry", or "bes"
common --@rules_img//img/settings:push_strategy=eager
# Default registry for image_push and image_push_spec when no explicit registry is set.
# Useful for setting a project-wide default so individual push rules don't need to repeat it.
common --@rules_img//img/settings:destination_registry=gcr.io
# The load strategy to use.
# "eager" or "lazy"
common --@rules_img//img/settings:load_strategy=eager
# The daemon to target with image_load
# "docker", "containerd", "podman", or "generic"
# For "generic", set LOADER_BINARY environment variable at runtime
common --@rules_img//img/settings:load_daemon=docker
# Bazel remote cache to use for lazy pushing of container images.
# Uses the same format as Bazel's --remote_cache flag.
# Falls back to $IMG_REAPI_ENDPOINT env var.
common --@rules_img//img/settings:remote_cache=grpcs://remote.buildbuddy.io
# Remote instance name for REAPI requests.
# Same format as Bazel's --remote_instance_name flag.
# Set as instance_name in CAS RPCs and as path prefix in ByteStream resource names.
# Falls back to $IMG_REAPI_INSTANCE_NAME env var.
# Required by some RBE backends.
common --@rules_img//img/settings:remote_instance_name=my-instance-name
# Credential helper to use for authenticating gRPC connections during push operations
# in some push strategies.
# This can be the same as Bazel's credential helper.
# Falls back to $IMG_CREDENTIAL_HELPER env var.
common --@rules_img//img/settings:credential_helper=tweag-credential-helper
# Path to Docker configuration file for registry authentication.
# If set, this will be used as REGISTRY_AUTH_FILE for authenticating to registries
# when downloading image layers during build time (e.g., for lazy base image pulling).
# Typically set to ~/.docker/config.json or similar.
common --@rules_img//img/settings:docker_config_path=/home/user/.docker/config.json
Add a base image to MODULE.bazel:
pull = use_repo_rule("@rules_img//img:pull.bzl", "pull")
pull(
name = "ubuntu",
digest = "sha256:1e622c5f073b4f6bfad6632f2616c7f59ef256e96fe78bf6a595d1dc4376ac02",
registry = "index.docker.io",
repository = "library/ubuntu",
tag = "24.04",
)
If you have any *_binary target in Bazel (cc_binary, go_binary, py_binary, java_binary, rust_binary, ...), you can package it into a container image with image_from_binary:
load("@rules_img//img:image.bzl", "image_from_binary")
cc_binary(
name = "server",
srcs = ["main.cc"],
deps = [":server_lib"],
)
image_from_binary(
name = "image",
binary = ":server",
base = "@ubuntu",
)
That's it. The image's entrypoint, cmd, env, and working directory are automatically configured from the binary target:
args attributeenv attribute (or RunEnvironmentInfo provider)include_runfiles = True)For multi-platform images, set the platforms attribute:
image_from_binary(
name = "image",
binary = ":server",
base = "@ubuntu",
platforms = [
"//:linux_amd64",
"//:linux_arm64",
],
)
load("@rules_img//img:push.bzl", "image_push")
image_push(
name = "push",
image = ":image",
registry = "ghcr.io",
repository = "my-project/app",
tag = "latest",
)
Run with:
bazel run //:push
For more control over the image contents, you can compose images from individual layers using image_layer and image_manifest:
load("@rules_img//img:layer.bzl", "image_layer")
load("@rules_img//img:image.bzl", "image_manifest")
# Create a layer from files...
image_layer(
name = "app_layer",
srcs = {
"/app/bin/server": "//cmd/server",
"/app/config": "//configs:prod",
},
compress = "zstd", # Use zstd compression (optional, uses global default otherwise)
)
# ... and a second layer (add as many as you need)
image_layer(
name = "data_layer",
srcs = {"/data/logo.png": "@static_assets//:logo.png"},
)
# Build a container image:
# This will contain all layers from base (if set) and the layers given in "layers" (in the specified order).
# Try to put frequently changing layers last for better performance.
image_manifest(
name = "app_image",
base = "@ubuntu", # Optional: build "from scratch" without base.
layers = [
":data_layer",
":app_layer",
],
config_fragment = "config.json", # Optional image configuration, uses sane defaults.
)
If you're using image_from_binary, just pass the platforms attribute (see step 2).
When composing images from layers with image_manifest, use image_index with the builtin transitions feature:
load("@rules_img//img:image.bzl", "image_manifest", "image_index")
# Create platform-specific images
image_manifest(
name = "app",
layers = [":app_layer"],
)
# Combine into multi-platform index
image_index(
name = "multiarch_app",
manifests = [":app"],
platforms = [
"//:linux_amd64",
"//:linux_arm64",
],
)
For more details on working with platforms, architecture variants, and building images for macOS Docker daemons, see the Platforms Guide.
rules_img uses go-containerregistry to interact with container registries, which provides automatic credential discovery from standard locations. This means authentication works the same way as with Docker CLI, Podman, and other container tools.
When pushing or pulling images, rules_img automatically searches for credentials in the following locations (in order):
~/.docker/config.json - Standard Docker credential file$DOCKER_CONFIG/config.json - If the DOCKER_CONFIG environment variable is set${XDG_RUNTIME_DIR}/containers/auth.json - Podman credential file (typically /run/user/1000/containers/auth.json)Additionally, for Google Container Registry (gcr.io, pkg.dev, etc.), rules_img registers the Google keychain alongside the default keychain. This provides automatic authentication using Application Default Credentials (ADC), making it seamless to push/pull from GCR when running on Google Cloud or with gcloud configured locally.
For more details on how credential discovery works, see the go-containerregistry keychain documentation.
The easiest way to configure credentials is using docker login:
# Login to Docker Hub
docker login
# Login to a private registry
docker login ghcr.io
docker login registry.example.com
# Login with username and password
docker login -u myusername registry.example.com
These commands will store credentials in ~/.docker/config.json, which rules_img will automatically use.
If you're using Podman, credentials are stored in a different location:
podman login registry.example.com
This stores credentials in ${XDG_RUNTIME_DIR}/containers/auth.json, which rules_img also automatically discovers.
When Bazel runs actions in a sandbox (which is the default behavior), it may hide certain environment information like the current username and home directory. This can prevent rules_img from automatically finding your Docker credential files.
If you encounter authentication failures, you can explicitly configure the path to your Docker configuration file:
# In your .bazelrc or on the command line
common --@rules_img//img/settings:docker_config_path=/home/username/.docker/config.json
Replace /home/username/ with your actual home directory path. This setting affects:
download_blobs rule), the REGISTRY_AUTH_FILE environment variable is set to this path.image_push targets with bazel run, this ensures authentication works correctly.image_load targets with bazel run, credentials are available for any required registry access.multi_deploy targets, all combined operations can authenticate properly.Additionally, the DOCKER_CONFIG environment variable is inherited from your shell environment for all push, load, and multi_deploy operations. This means you can also use the standard Docker environment variable as an alternative:
# Alternative: use DOCKER_CONFIG environment variable
export DOCKER_CONFIG=/path/to/docker/config/dir
bazel run //:push_image
If you're experiencing authentication issues:
~/.docker/config.json or ${XDG_RUNTIME_DIR}/containers/auth.json contains the registry$DOCKER_CONFIG, ensure it points to a directory containing config.jsondocker pull or podman pull works, rules_img should work too--@rules_img//img/settings:docker_config_path to your Docker config file pathFor advanced authentication scenarios (credential helpers, custom authentication), refer to the go-containerregistry authentication documentation.
Any language that produces a *_binary target can be packaged with image_from_binary. These examples show both the simple image_from_binary approach and more advanced layer composition:
Both rules_img and rules_oci are modern Bazel rulesets for building OCI container images. While they share the goal of hermetic, reproducible container builds, they take fundamentally different architectural approaches.
rules_oci uses the oci image layout as an on-disk representation of container images at every step (base image pull, oci_image rule, oci_image_index rule).
Additionally, rules_oci chooses to use only off-the-shelf, pre-built tools for assembling images.
rules_img chooses to use providers that contain just enough information as needed for subsequent steps. We also use customized tools, instead of prebuilt ones.
This results in a more complex implementation, but also allows for interesting optimizations.
image_layer - Create layers from fileslayer_from_binary - Create a layer from a *_binary targetlayer_from_tar - Create layers from tar archivesfile_metadata - Helper for specifying file attributes of image_layer rule.image_from_binary - Package a *_binary target into a container imageimage_manifest - Build single-platform imagesimage_index - Build multi-platform image indexesimage_manifest_from_oci_layout - Convert oci_image to image_manifestimage_index_from_oci_layout - Convert oci_image_index to image_indexpull - Repository rule for pulling base imagesimages.pull - Module extension for pulling base images (EXPERIMENTAL)image_push - Push images to registriesimage_load - Load images into container daemonsmulti_deploy - Deploy multiple operations as unified commandlayer_from_file - Create layers from custom blobs (not tar files)oras_file_layer - Create oras artifact layers from individual filesoras_layer - Create oras tree layers from files and directoriesUnlike rules_oci which downloads all layers of a base image, rules_img uses a "shallow pull" approach. When you reference a base image like CUDA (which can be 10+ GB), rules_img only downloads the manifest and config - not the actual layer blobs. The layers are only downloaded when and if they're needed during push operations.
This results in:
Example with a large CUDA base image:
# This won't download the 10GB of CUDA layers!
pull(
name = "cuda",
digest = "sha256:...",
registry = "index.docker.io",
repository = "nvidia/cuda",
)
rules_img produces both the layer blob and its metadata in a single Bazel action. This design has several advantages:
The metadata includes the layer's digest, size, and diff ID, all computed during layer creation.
When writing a tar layer, rules_img uses hardlinks to deduplicate identical files. This allows for smaller container images.
rules_img offers four sophisticated push strategies compared to rules_oci's traditional approach. These strategies enable:
| Strategy | Description | Use Case | Requirements |
|---|---|---|---|
eager |
Traditional push, download all blobs to the machine running Bazel, then uploads all blobs. | Simple deployments | Normal container registry |
lazy |
Checks registry first, skips existing blobs and streams missing blobs from Bazel's remote cache | Faster CI/CD and Build without the Bytes | Bazel remote cache |
cas_registry |
Uses special container registry that is directly connected to Bazel's remote cache | Fast development cycles. | Special container registry (cmd/registry), Bazel remote cache |
bes |
Image push happens as side-effect of BES upload. Requires self-hosted BES server. | Extremely fast and efficient for large organizations. | Special BES backend (cmd/bes), Bazel remote cache |
See the Push Strategies Guide for detailed information about each strategy.
rules_img has first-class support for eStargz (enhanced stargz), enabling "lazy pulling" at container runtime. This means:
Combined with containerd's stargz-snapshotter, this can reduce container startup time from minutes to seconds for large images.
image_layer(
name = "optimized_layer",
srcs = {...},
estargz = "enabled", # Enable seekable compression
)
The same setting can be globally enabled using --@rules_img//img/settings:estargz=enabled.
Read the stargz-snapshotter documentation for more information.
rules_img loads images incrementally and efficiently by directly interfacing with the containerd API. This provides significant performance advantages over traditional approaches:
docker load entirelyThe performance difference is dramatic, especially for large images:
# Load only the platform you need
bazel run //my:image_load -- --platform linux/amd64
# Incremental loading: only new layers are transferred
# Second load of a slightly modified image is near-instant
bazel run //my:image_load # Only changed layers loaded!
When Docker doesn't support containerd storage, rules_img automatically falls back to docker load with a clear warning about the performance impact.
This is particularly powerful in development workflows where you're iterating on application layers while keeping large base images (like CUDA) unchanged - subsequent loads only transfer your small application layers.
Future Docker Support: Docker is planning to expose its contentstore API in version 29.0.0, which will enable native incremental loading (moby/moby#44369). Once this ships, rules_img will adopt it to provide incremental loading performance even when the containerd socket isn't directly accesible by users. This will bring the same efficiency benefits to all Docker users, regardless of their platform or configuration.
We invite external contributions and are eager to work together with the build systems community. Please refer to the CONTRIBUTING guide to learn more. If you want to check out the code and run a development version, follow the HACKING guide to get started.
Special thanks to Sushain Cherivirala from Stripe for the inspiring BazelCon talk "Building 1300 Container Images in 4 Minutes". This talk introduced the groundbreaking idea of using the Build Event Service (BES) to sync container images between the remote cache and registry as a side effect. While their implementation was based on the now-archived rules_docker and was never published, it laid the conceptual foundation for our BES push strategy. Their work demonstrated how to achieve dramatic performance improvements in container image builds at scale, inspiring many of the optimizations in rules_img.
Modern Bazel rules for building OCI container images with advanced performance optimizations
@bazel-contrib/rules_img