Skip to main content

Bazel Build System

Lumio uses a dual build system: Cargo for local development, Bazel for CI and container images. Developers write and test code with standard Cargo commands locally. Bazel takes over in CI where its remote caching and incremental target detection provide significant speed improvements.

Why Bazel

  • Remote caching via BuildBuddy -- unchanged targets are not rebuilt across CI runs
  • Incremental target detection via bazel-diff -- only affected targets are tested on PRs
  • Hermetic builds -- reproducible container images from pinned toolchains and vendored dependencies
  • Multi-app image builds -- a single workflow builds and pushes images for all 10 deployable apps

Architecture

lumio/
├── MODULE.bazel # Root module definition
├── .bazelrc # Local Bazel settings
├── .bazelrc.ci # CI-specific settings (BuildBuddy remote cache)
├── misc/toolchains/
│ ├── rust.MODULE.bazel # Rust toolchain (edition 2024, pinned version)
│ ├── proto.MODULE.bazel # Protobuf toolchain
│ ├── v8.MODULE.bazel # V8 prebuilt static library
│ ├── docker.MODULE.bazel # Base image (Alpine, pinned by digest)
│ └── BUILD.bazel # cc_library for V8 native linking
├── vendor/cargo/ # 816 vendored crate BUILD files
│ ├── BUILD.actix-web-4.13.0.bazel
│ ├── BUILD.tokio-1.*.bazel
│ └── ...
├── crates/*/BUILD.bazel # One BUILD per internal crate
└── apps/*/BUILD.bazel # One BUILD per app (binary + image targets)

The root MODULE.bazel includes four toolchain modules and pins the rules_rust fork via git_override.

zaflun/rules_rust Fork

Lumio uses a fork of rules_rust at github.com/zaflun/rules_rust. The fork fixes two issues in the upstream repository:

  1. Sandbox path rotation for cargo_build_script -- the upstream OUT_DIR handling breaks when Bazel rotates sandbox paths between actions. The fork stabilizes OUT_DIR so build scripts (e.g., utoipa-swagger-ui asset embedding) produce deterministic outputs.
  2. cargo_runfiles data file mapping -- the upstream mapping does not resolve data dependencies (e.g., V8 binding files) correctly in sandboxed builds. The fork patches the runfiles lookup.

The fork publishes prebuilt cargo-bazel binaries via GitHub Releases for Linux (x86_64, aarch64), macOS (aarch64), and Windows (x86_64, aarch64). These are fetched as http_archive dependencies in MODULE.bazel so that the vendor step does not require building cargo-bazel from source.

Vendored Crates

All external Rust dependencies are vendored into vendor/cargo/ as auto-generated BUILD files (816 files). This avoids network fetches during builds and ensures reproducibility.

Update workflow after changing Cargo.lock:

bazel run //vendor:cargo_vendor

This regenerates all BUILD.*.bazel files in vendor/cargo/ using crate_universe. The vendored files are committed to the repository.

BUILD.bazel Patterns

Crate (library + tests)

Every internal crate follows this pattern:

exports_files(["Cargo.toml"])

load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")

rust_library(
name = "lo-common",
srcs = glob(["src/**/*.rs"]),
edition = "2024",
deps = [
"@crate_index//:actix-web",
"@crate_index//:serde",
"@crate_index//:serde_json",
],
visibility = ["//visibility:public"],
)

rust_test(
name = "lo-common_test",
crate = ":lo-common",
)

The exports_files(["Cargo.toml"]) line is required -- the vendor toolchain reads Cargo.toml metadata from every workspace member. Omitting it causes vendor failures.

App (binary + container image)

App BUILD files define a rust_binary, then layer it into a container image:

load("@rules_img//img:image.bzl", "image_manifest")
load("@rules_img//img:layer.bzl", "image_layer")
load("@rules_img//img:push.bzl", "image_push")
load("@rules_rust//rust:defs.bzl", "rust_binary")

rust_binary(
name = "api",
srcs = glob(["src/**/*.rs"]),
edition = "2024",
deps = ["//crates/lo-common", ...],
)

image_layer(
name = "api_layer",
srcs = {
"/usr/local/bin/lumio-api": ":api",
"/etc/lumio/config/default.toml": "config/default.toml",
"/etc/lumio/config/production.toml": "config/production.toml",
},
)

image_manifest(
name = "api_image",
base = "@alpine",
layers = [":api_layer"],
entrypoint = ["/usr/local/bin/lumio-api"],
env = {
"LUMIO__SERVER__HOST": "0.0.0.0",
"LUMIO__SERVER__PORT": "8080",
},
)

image_push(
name = "api_push",
image = ":api_image",
registry = "ghcr.io",
repository = "zaflun/lumio/api",
)

The base image (@alpine) is pinned by digest in misc/toolchains/docker.MODULE.bazel for reproducibility.

Adding a New Crate

  1. Create the crate directory under crates/ with Cargo.toml and src/.
  2. Add it to the workspace [members] in the root Cargo.toml.
  3. Create crates/{name}/BUILD.bazel following the library + test pattern above. Include exports_files(["Cargo.toml"]) at the top.
  4. Run bazel run //vendor:cargo_vendor to regenerate vendored BUILD files if the crate introduces new external dependencies.
  5. Verify the build: bazel build //crates/{name}:...
  6. If the crate is a dependency of a deployable app, add it to the app's deps in the app's BUILD.bazel.

V8 Integration

Crates and apps that depend on deno_core (the V8 JavaScript engine) require special linking. The V8 engine is distributed as a prebuilt static library for x86_64-unknown-linux-gnu, fetched from the denoland/rusty_v8 GitHub Releases.

Toolchain setup (misc/toolchains/v8.MODULE.bazel):

http_archive(
name = "v8_prebuilt",
build_file_content = 'exports_files(["librusty_v8.a"], ...)',
urls = ["https://github.com/denoland/rusty_v8/releases/download/v147.4.0/..."],
)

Linking in app BUILD files -- apps that transitively depend on V8 define a local cc_library and add it to the binary's deps:

load("@rules_cc//cc:cc_library.bzl", "cc_library")

cc_library(
name = "v8_native",
srcs = ["@v8_prebuilt//:librusty_v8.a"],
linkstatic = True,
linkopts = ["-lstdc++", "-ldl", "-lpthread"],
)

rust_binary(
name = "bot-module-worker",
deps = [
"//crates/lo-bot-module-worker",
":v8_native",
...
],
)

Vendor BUILD for the v8 crate -- the vendored BUILD.v8-*.bazel uses $(execpath) to resolve the binding file path at build time:

rustc_env = {
"RUSTY_V8_SRC_BINDING_PATH": "$(execpath gen/src_binding_simdutf_release_x86_64-unknown-linux-gnu.rs)",
},

Key Commands

CommandDescription
just bazel-buildBuild all crates and apps
just bazel-testRun all crate tests
just bazel-imagesBuild container images for all Rust apps
just bazel-clippyRun clippy via Bazel aspects
just bazel-vendorRegenerate vendored crate BUILD files

Direct Bazel invocations:

# Build a single crate
bazel build //crates/lo-common

# Test a single crate
bazel test //crates/lo-common:lo-common_test

# Build a specific app image
bazel build //apps/api:api_image

# Push an image (requires GHCR authentication)
bazel run //apps/api:api_push --@rules_img//img/settings:push_tag="2026.5.3"

BuildBuddy

Remote caching is provided by BuildBuddy. Configuration lives in .bazelrc.ci, which is imported by CI workflows:

build --remote_cache=grpcs://remote.buildbuddy.io
build --remote_timeout=3600
build --remote_cache_compression

Cache write policy:

ContextWrites
PR buildsRead-only (--noremote_upload_local_results)
Protected branches (next, main)Read-write (--remote_upload_local_results=true)

This prevents cache poisoning from untrusted PR builds while keeping the cache warm from protected-branch builds. Authentication uses the BUILDBUDDY_API_KEY secret passed via --remote_header.

Troubleshooting

OpenSSL in sandbox

Some crates (e.g., openssl-sys) need environment variables to find system libraries inside the Bazel sandbox. If builds fail with OpenSSL linker errors, check that action_env settings in .bazelrc expose the necessary paths.

Missing exports_files for Cargo.toml

Every crate's BUILD.bazel must include exports_files(["Cargo.toml"]). Without it, the vendor toolchain cannot read crate metadata and bazel run //vendor:cargo_vendor fails with a missing-file error.

V8 linking failures

V8 linking requires:

  1. The prebuilt librusty_v8.a archive matching the deno_core version in Cargo.lock.
  2. A cc_library target in the app's BUILD.bazel that links the archive with -lstdc++ -ldl -lpthread.
  3. The v8 vendor BUILD must have the correct $(execpath) for the binding source file.

If the deno_core or v8 crate version changes in Cargo.lock, update the SHA and URL in misc/toolchains/v8.MODULE.bazel to match the corresponding rusty_v8 release.