This guide provides step-by-step instructions for migrating from rules_oci to rules_img.
- Understanding the Differences
- MODULE.bazel Setup
- Building Layers
- Building Images
- Multi-Platform Images
- Output Groups
- Interoperability with rules_oci
- Container Structure Tests
- Deployment
Both rules_oci and rules_img build OCI-compliant container images with Bazel, but they have fundamentally different architectural approaches:
- Uses OCI image layout as on-disk representation at every step
- Uses off-the-shelf, pre-built tools for assembling images
- Downloads all base image layers during the pull phase
- Uses Bazel providers that contain just enough information for subsequent steps
- Uses custom-built tools optimized for specific workflows
- Performs shallow pulling - only downloads manifests/configs, not layer blobs
- Enables advanced optimizations like lazy push, CAS integration, and eStargz support
bazel_dep(name = "rules_oci", version = "2.0.0")
# Most users either create images with rules_pkg or tar.bzl.
bazel_dep(name = "rules_pkg", version = "0.10.1")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
oci.pull(
name = "distroless_cc",
digest = "sha256:e1065a1d58800a7294f74e67c32ec4146d09d6cbe471c1fa7ed456b2d2bf06e0",
image = "gcr.io/distroless/cc-debian12",
platforms = ["linux/amd64", "linux/arm64"],
)
use_repo(oci, "distroless_cc")bazel_dep(name = "rules_img", version = "<version>")
# rules_pkg and tar.bzl are no longer needed.
pull = use_repo_rule("@rules_img//img:pull.bzl", "pull")
pull(
name = "distroless_cc",
digest = "sha256:e1065a1d58800a7294f74e67c32ec4146d09d6cbe471c1fa7ed456b2d2bf06e0",
registry = "gcr.io",
repository = "distroless/cc-debian12",
)Key Changes:
oci.pull()becomespull()(a repository rule, not an extension)imageparameter splits into separateregistryandrepositoryparametersregistriesallows defining mirror registries that host the same image.platformsparameter is removed (single- and multi-platform images just work without extra configuration)- No need for
use_repo()- thepull()rule directly creates a repository
There are three approaches to migrating layers from rules_oci:
Both rules_oci and rules_img can consume pkg_tar and tar.bzl targets directly.
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
pkg_tar(
name = "app_layer",
srcs = ["//cmd/server"],
package_dir = "/app/bin",
)
oci_image(
name = "image",
tars = [":app_layer"],
)load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
pkg_tar(
name = "app_layer",
srcs = ["//cmd/server"],
package_dir = "/app/bin",
)
image_manifest(
name = "image",
layers = [":app_layer"], # Can use tar file directly!
)Key Change: tars attribute becomes layers attribute.
For more control over compression and optimization:
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
load("@rules_img//img:layer.bzl", "layer_from_tar")
pkg_tar(
name = "app_tar",
srcs = ["//cmd/server"],
package_dir = "/app/bin",
)
layer_from_tar(
name = "app_layer",
src = ":app_tar",
compress = "zstd", # Recompress with zstd
optimize = True, # Deduplicate files
)
image_manifest(
name = "image",
layers = [":app_layer"],
)Benefits:
- Control compression algorithm (gzip, zstd, or none)
- Enable optimization to deduplicate identical files
- Add layer annotations
- Calculate layer digest in shared target
The native image_layer rule provides the best integration with rules_img:
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
pkg_tar(
name = "app_layer",
srcs = [
"//cmd/server",
"//configs:prod_config",
],
package_dir = "/app",
)load("@rules_img//img:layer.bzl", "image_layer")
image_layer(
name = "app_layer",
srcs = {
"/app/bin/server": "//cmd/server",
"/app/config/prod.json": "//configs:prod_config",
},
compress = "zstd", # Optional: defaults to global setting
)Benefits:
- More intuitive path mapping (destination → source)
- Deduplicates files using hardlinks
- Built-in support for file metadata, symlinks, and permissions
- Better performance (single Bazel action writes layer blob and layer metadata)
load("@rules_img//img:layer.bzl", "image_layer", "file_metadata")
image_layer(
name = "secure_layer",
srcs = {
"/app/bin/server": "//cmd/server",
"/etc/app/config": "//configs:prod",
"/etc/app/secret": "//secrets:api_key",
},
default_metadata = file_metadata(
mode = "0644",
uid = 1000,
gid = 1000,
),
file_metadata = {
"/app/bin/server": file_metadata(mode = "0755", uid = 0, gid = 0),
"/etc/app/secret": file_metadata(mode = "0600"),
},
symlinks = {
"/usr/bin/server": "/app/bin/server",
},
)load("@rules_oci//oci:defs.bzl", "oci_image")
oci_image(
name = "app_image",
base = "@distroless_cc",
tars = [":app_layer"],
entrypoint = ["/app/bin/server"],
env = {
"PORT": "8080",
"ENV": "production",
},
labels = {
"org.opencontainers.image.version": "1.0.0",
"org.opencontainers.image.source": "https://github.com/myorg/myapp",
},
)load("@rules_img//img:image.bzl", "image_manifest")
image_manifest(
name = "app_image",
base = "@distroless_cc",
layers = [":app_layer"],
entrypoint = ["/app/bin/server"],
env = {
"PORT": "8080",
"ENV": "production",
},
labels = {
"org.opencontainers.image.version": "1.0.0",
"org.opencontainers.image.source": "https://github.com/myorg/myapp",
},
)Key Changes:
oci_image→image_manifesttars→layers
| rules_oci | rules_img | Notes |
|---|---|---|
base |
base |
✅ Same |
tars |
layers |
|
entrypoint |
entrypoint |
✅ Same |
cmd |
cmd |
✅ Same |
created |
created |
✅ Same |
env (dict) |
env |
✅ Same |
env (label to file) |
env_file |
env accepts either a dict or a label pointing to a file of KEY=VALUE lines. In rules_img, use the separate env_file attribute for file-based env vars. Values from env take precedence over env_file entries. |
labels |
labels |
✅ Same |
annotations |
annotations |
✅ Same |
user |
user |
✅ Same |
workdir |
working_dir |
|
exposed_ports |
N/A | config_fragment |
volumes |
N/A | config_fragment |
os |
Uses Bazel target platform | |
architecture |
Uses Bazel target platform |
Unlike rules_oci, users cannot set the image's os and architecture directly.
Instead, rules_img uses the target platform of bazel (--platforms=...).
Most users should use the image_index rule to configure the target platform of the image.
In rare cases where you need to build a single-architecture image (image_manifest) for a specific platform, you can set the platform attribute.
This ensures that the layers of the image are actually built for the target platform (and there is no way to accidentally build images with layers for a different platform).
For attributes not directly supported (like exposed_ports or volumes), use config_fragment:
# config.json
{
"config": {
"ExposedPorts": {
"8080/tcp": {},
"9090/tcp": {}
},
"Volumes": {
"/data": {}
}
}
}
# BUILD.bazel
image_manifest(
name = "app_image",
base = "@distroless_cc",
layers = [":app_layer"],
entrypoint = ["/app/bin/server"],
config_fragment = "config.json",
)Refer to the OCI specification to learn about all possible fields and their meaning.
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_image_index")
oci_image(
name = "app",
tars = [":app_layer"],
)
oci_image_index(
name = "app_multiarch",
images = [
":app",
],
platforms = [
"//platforms:linux_amd64",
"//platforms:linux_arm64",
],
)rules_img also offers a multi-platform transition:
load("@rules_img//img:image.bzl", "image_manifest", "image_index")
load("@rules_img//img:layer.bzl", "image_layer")
image_layer(
name = "app_layer",
srcs = {
"/bin/app": "//cmd/app",
},
)
# Single manifest definition
image_manifest(
name = "app",
base = "@distroless_cc",
layers = [":app_layer"],
entrypoint = ["/bin/app"],
)
# Automatically builds for multiple platforms!
image_index(
name = "app_multiarch",
manifests = [":app"], # Single manifest
platforms = [
"//platforms:linux_amd64",
"//platforms:linux_arm64",
],
)In rare cases, you can leave platforms empty and manually specify the manifests to include.
Most users should not need this.
image_manifest(
name = "app_amd64",
layers = [":app_layer"],
platform = "//platforms:linux_amd64",
labels = {"architecture": "amd64"},
)
image_manifest(
name = "app_arm64",
layers = [":app_layer"],
platform = "//platforms:linux_arm64",
labels = {"architecture": "arm64"},
)
image_index(
name = "app_multiarch",
manifests = [
":app_amd64",
":app_arm64",
],
)rules_img supports output groups for accessing different image formats.
Important
In rules_img, base images are shallow by default.
In practice, this means that Bazel doesn't have access to layer blobs of externally pulled base images in build actions.
When building oci layouts, those layers are needed, so you can change the layer_handling attribute of the pull rule if you run into issues.
# Access via default info: the image label refers to the tree artifact directly
oci_image(
name = "app_image",
tars = [":app_layer"],
)You can access the oci layout directory using the :app_image label:
bazel build //path/to:app_imageimage_manifest(
name = "app_image",
layers = [":app_layer"],
)
# Create filegroup to surface the output group containing the oci layout directory
filegroup(
name = "image_layout",
srcs = [":app_image"],
output_group = "oci_layout",
)
# Another option: build as tarball
filegroup(
name = "image_layout_tar",
srcs = [":app_image"],
output_group = "oci_tarball",
)Build with:
# To build the oci layout
bazel build //path/to:image_layout
# alternatively, you can also access output groups directly:
bazel build //path/to:app_image --output_groups=oci_layout
# The same works for the oci layout tar
bazel build //path/to:image_layout_tar
bazel build //path/to:app_image --output_groups=oci_tarballFor compatibility with docker load:
load("@rules_oci//oci:defs.bzl", "oci_load")
oci_load(
name = "tarball",
image = ":app_image",
repo_tags = ["myapp:latest"],
)
filegroup(
name = "docker_tarball",
srcs = [":tarball"],
output_group = "tarball",
)load("@rules_img//img:load.bzl", "image_load")
image_load(
name = "load",
image = ":app_image",
tag = "myapp:latest",
)
filegroup(
name = "docker_tarball",
srcs = [":load"],
output_group = "tarball",
)Note: The Docker tarball is only available for single-platform images in rules_img.
rules_img and rules_oci can interoperate using OCI layout directories, enabling gradual migration or using both rulesets in the same project.
rules_img images can be consumed by rules_oci via the oci_layout output group:
# rules_img image
load("@rules_img//img:image.bzl", "image_manifest")
image_manifest(
name = "base_app",
layers = [":app_layer"],
entrypoint = ["/app/bin/server"],
)
# Export as OCI layout
filegroup(
name = "base_app_layout",
srcs = [":base_app"],
output_group = "oci_layout",
)The OCI layout can be used directly in rules_oci targets:
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_push", "oci_load")
# Use as base image
oci_image(
name = "extended_image",
base = ":base_app_layout",
tars = [":additional_layer"],
)
# Push directly
oci_push(
name = "push",
image = ":base_app_layout",
repository = "ghcr.io/myorg/myapp",
)
# Load directly
oci_load(
name = "load",
image = ":base_app_layout",
repo_tags = ["myapp:latest"],
)rules_oci images can be converted to rules_img using the conversion rules. See docs/convert.md for full documentation.
load("@rules_oci//oci:defs.bzl", "oci_image")
load("@rules_img//img:convert.bzl", "image_manifest_from_oci_layout")
load("@rules_img//img:media_types.bzl", "GZIP_LAYER")
# rules_oci image
oci_image(
name = "legacy_image",
base = "@distroless_cc",
tars = [":legacy_layer"],
)
# Convert to rules_img
image_manifest_from_oci_layout(
name = "converted_image",
src = ":legacy_image",
architecture = "amd64",
os = "linux",
layers = [GZIP_LAYER],
)The converted image can then be used with rules_img rules:
load("@rules_img//img:image.bzl", "image_manifest")
load("@rules_img//img:push.bzl", "image_push")
# Build on the converted image
image_manifest(
name = "enhanced_image",
base = ":converted_image",
layers = [":new_layer"],
)
# Push using rules_img
image_push(
name = "push",
image = ":enhanced_image",
registry = "ghcr.io",
repository = "myorg/myapp",
tag = "latest",
)For multi-platform images, use image_index_from_oci_layout:
load("@rules_img//img:convert.bzl", "image_index_from_oci_layout")
load("@rules_img//img:media_types.bzl", "GZIP_LAYER")
image_index_from_oci_layout(
name = "converted_multiarch",
src = ":legacy_multiarch_image",
layers = [GZIP_LAYER],
manifests = ["linux/amd64", "linux/arm64"],
)Container structure tests verify the contents and configuration of your images.
See a working example in
e2e/generic/integrationtest/container_structure_test.
load("@container_structure_test//:defs.bzl", "container_structure_test")
load("@rules_oci//oci:defs.bzl", "oci_load")
oci_load(
name = "image_tarball",
image = ":app_image",
repo_tags = ["test:latest"],
)
container_structure_test(
name = "structure_test",
driver = "tar",
image = ":image_tarball",
configs = ["test_config.yaml"],
)load("@container_structure_test//:defs.bzl", "container_structure_test")
load("@rules_img//img:load.bzl", "image_load")
image_load(
name = "load_image",
image = ":app_image",
tag = "test:latest",
)
filegroup(
name = "image_tarball",
srcs = [":load_image"],
output_group = "tarball",
)
container_structure_test(
name = "structure_test",
driver = "tar",
image = ":image_tarball",
configs = ["test_config.yaml"],
)load("@container_structure_test//:defs.bzl", "container_structure_test")
container_structure_test(
name = "structure_test_docker",
driver = "docker",
image = ":app_image",
configs = ["test_config.yaml"],
)This approach requires Docker to be available and will load the image into the Docker daemon before testing.
load("@rules_oci//oci:defs.bzl", "oci_push")
oci_push(
name = "push",
image = ":app_image",
repository = "ghcr.io/myorg/myapp",
remote_tags = ["latest", "v1.0.0"],
)load("@rules_img//img:push.bzl", "image_push")
image_push(
name = "push",
image = ":app_image",
registry = "ghcr.io",
repository = "myorg/myapp",
tag_list = ["latest", "v1.0.0"],
)Key Changes:
oci_push→image_pushrepositoryparameter splits intoregistryandrepositoryrepository_filein rules_oci →destination_filein rules_img (works the same way: file contains{registry}/{repository})remote_tagsin rules_oci takes a list of strings or a label (file) → in rules_img, there are separate attributes:tag(string) andtag_list(list of string) allow setting tags in the build filetag_filetakes a file with newline-separated tags.
Run with:
bazel run //path/to:pushoci_push(
name = "push_multiarch",
image = ":app_multiarch", # image_index
repository = "ghcr.io/myorg/myapp",
remote_tags = ["latest"],
)image_push(
name = "push_multiarch",
image = ":app_multiarch", # image_index
registry = "ghcr.io",
repository = "myorg/myapp",
tag = "latest",
)oci_push(
name = "push",
image = ":app_image",
repository = "ghcr.io/myorg/myapp",
remote_tags = ["v1.0.0"],
)image_push(
name = "push",
image = ":app_image",
registry = "ghcr.io",
repository = "myorg/myapp",
tag = "v1.0.0", # Use 'tag' for single tag
)rules_img offers advanced push strategies for better performance:
# Default: eager (downloads all layers locally, then pushes)
image_push(
name = "push_eager",
image = ":app_image",
registry = "ghcr.io",
repository = "myorg/myapp",
tag = "latest",
strategy = "eager", # Default
)
# Lazy: streams from remote cache, skips existing blobs
image_push(
name = "push_lazy",
image = ":app_image",
registry = "ghcr.io",
repository = "myorg/myapp",
tag = "latest",
strategy = "lazy", # Requires remote cache
)Configure globally in .bazelrc:
# Use lazy push by default (requires remote cache)
common --@rules_img//img/settings:push_strategy=lazy
# Point to your remote cache
common --@rules_img//img/settings:remote_cache=grpcs://remote.buildbuddy.ioSee the push strategies documentation for more details.
load("@rules_oci//oci:defs.bzl", "oci_load")
oci_load(
name = "load",
image = ":app_image",
repo_tags = ["myapp:latest", "myapp:v1.0.0"],
)load("@rules_img//img:load.bzl", "image_load")
image_load(
name = "load",
image = ":app_image",
tag_list = ["myapp:latest", "myapp:v1.0.0"],
)Key Changes:
oci_load→image_loadrepo_tags→tag_list
Run with:
bazel run //path/to:loadrules_img provides incremental loading when using containerd:
- Only new/changed layers are transferred
- Existing blobs are skipped automatically
- Much faster for iterative development
Enable with:
image_load(
name = "load",
image = ":app_image",
tag = "myapp:dev",
daemon = "containerd", # Use containerd directly
)Or configure globally in .bazelrc:
common --@rules_img//img/settings:load_daemon=containerdHere's a complete before/after example showing a full application migration:
# MODULE.bazel
bazel_dep(name = "rules_oci", version = "2.0.0")
bazel_dep(name = "rules_pkg", version = "0.10.1")
bazel_dep(name = "rules_go", version = "0.58.3")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
oci.pull(
name = "distroless_base",
digest = "sha256:e1065a1d58800a7294f74e67c32ec4146d09d6cbe471c1fa7ed456b2d2bf06e0",
image = "gcr.io/distroless/base-debian12",
platforms = ["linux/amd64", "linux/arm64"],
)
use_repo(oci, "distroless_base")
# BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_image_index", "oci_push", "oci_load")
go_binary(
name = "server",
srcs = ["main.go"],
)
pkg_tar(
name = "server_layer",
srcs = [":server"],
package_dir = "/app",
)
oci_image(
name = "server_image_amd64",
base = "@distroless_base",
tars = [":server_layer"],
entrypoint = ["/app/server"],
env = {"PORT": "8080"},
target_compatible_with = ["@platforms//cpu:x86_64"],
)
oci_image(
name = "server_image_arm64",
base = "@distroless_base",
tars = [":server_layer"],
entrypoint = ["/app/server"],
env = {"PORT": "8080"},
target_compatible_with = ["@platforms//cpu:aarch64"],
)
oci_image_index(
name = "server_multiarch",
images = [
":server_image_amd64",
":server_image_arm64",
],
)
oci_push(
name = "push",
image = ":server_multiarch",
repository = "ghcr.io/myorg/server",
remote_tags = ["latest"],
)
oci_load(
name = "load",
image = ":server_image_amd64",
repo_tags = ["server:dev"],
)# MODULE.bazel
bazel_dep(name = "rules_img", version = "<version>")
bazel_dep(name = "rules_go", version = "0.58.3")
pull = use_repo_rule("@rules_img//img:pull.bzl", "pull")
pull(
name = "distroless_base",
digest = "sha256:e1065a1d58800a7294f74e67c32ec4146d09d6cbe471c1fa7ed456b2d2bf06e0",
registry = "gcr.io",
repository = "distroless/base-debian12",
)
# BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary")
load("@rules_img//img:image.bzl", "image_manifest", "image_index")
load("@rules_img//img:layer.bzl", "image_layer")
load("@rules_img//img:push.bzl", "image_push")
load("@rules_img//img:load.bzl", "image_load")
go_binary(
name = "server",
srcs = ["main.go"],
)
# Native image_layer (recommended)
image_layer(
name = "server_layer",
srcs = {
"/app/server": ":server",
},
)
# Single manifest definition
image_manifest(
name = "server_image",
base = "@distroless_base",
layers = [":server_layer"],
entrypoint = ["/app/server"],
env = {"PORT": "8080"},
)
# Automatically builds for multiple platforms
image_index(
name = "server_multiarch",
manifests = [":server_image"],
platforms = [
"//platforms:linux_x86_64",
"//platforms:linux_aarch64",
],
)
image_push(
name = "push",
image = ":server_multiarch",
registry = "ghcr.io",
repository = "myorg/server",
tag = "latest",
)
image_load(
name = "load",
image = ":server_image", # Can load multiarch too!
tag = "server:dev",
)Use this checklist to track your migration:
- Update
MODULE.bazelto userules_img - Convert
oci.pull()topull()repository rules - Update base image references (split
imageintoregistry/repository) - Migrate layers:
- Option 1: Use
pkg_tar/tartargets directly inlayersattribute - Option 2: Wrap with
layer_from_tarfor better control - Option 3: Migrate to
image_layer(recommended)
- Option 1: Use
- Convert
oci_imagetoimage_manifest- Rename
tars→layers - Rename
workdir→working_dir - If
envwas a file label, useenv_fileinstead - Move unsupported attributes to
config_fragmentif needed
- Rename
- Convert
oci_image_indextoimage_index - Use output groups:
-
oci_layout(for OCI layout dir) -
oci_tarball(for OCI layout tar) -
tarballoutput group onimage_loadfor Docker format
-
- Update container structure tests (minimal changes)
- Convert
oci_pushtoimage_push- Split
repositoryintoregistry+repository - Rename
remote_tags→tag_listortag - Consider using advanced push strategies
- Split
- Convert
oci_loadtoimage_load- Rename
repo_tags→tag,tag_listortag_file - Consider enabling incremental loading
- Rename
- Test your migration:
- Build images:
bazel build //path/to:image - Push images:
bazel run //path/to:push - Load images:
bazel run //path/to:load - Run structure tests:
bazel test //path/to:structure_test
- Build images:
If you encounter issues during migration:
- Check the examples directory for working patterns
- Review the API documentation
- Open an issue on GitHub
Happy migrating! 🚀