Buck2 in Production: Meta’s Build System That Actually Scales

Your builds are slow. Not "wait 30 seconds" slow — "go make coffee, check Slack, contemplate career choices" slow. And the deeper your monorepo grows, the worse it gets. CMake doesn’t know what changed. Gradle rebuilds half the world when you touch a shared util. Make gives up and recompiles everything.

Meta had this problem at a scale most of us will never see — billions of lines of code, thousands of engineers, hundreds of languages. They built Buck (the original), hit its limits, and rewrote it from scratch in Rust. The result is Buck2, and it’s been open-source since 2023.

This isn’t a toy. Meta runs their entire production build pipeline on this. If you have a polyglot monorepo and you’re tired of fighting your build tooling, this is worth your afternoon.

What Buck2 Actually Is (And What It Isn’t)

Buck2 is a build system. That sounds boring. What makes it interesting:

  • Written in Rust — the daemon and core are fast. Not "faster than the JVM overhead" fast. Actually fast.
  • Starlark for config — a deterministic, Python-like DSL. No Groovy, no XML, no Makefile black magic.
  • Content-addressed caching — it hashes inputs, not timestamps. If the inputs didn’t change, the output gets served from cache, period.
  • Remote execution built-in — you can point it at a remote execution cluster and distribute builds across machines with minimal config.
  • Parallel by default — the action graph is computed upfront, and everything that can run in parallel does.

What it isn’t: a package manager. It doesn’t replace pip, cargo, or npm. It orchestrates building things, not fetching them. You’ll still use your language ecosystem’s dependency tooling and then wire the outputs into Buck2 targets.

The closest competitor is Bazel (Google’s equivalent). Buck2 is younger, faster in practice, and has a cleaner extension model. The tradeoffs are real — Bazel has a larger community and more prebuilt rules. I’ll call out where this matters.

Installing Buck2

Buck2 ships as a single binary. No JVM. No Python runtime. Just download and run.

# Grab the latest release for Linux x86_64
curl -L https://github.com/facebook/buck2/releases/latest/download/buck2-x86_64-unknown-linux-gnu.zst \
  | zstd -d > /usr/local/bin/buck2

chmod +x /usr/local/bin/buck2

# Verify it works
buck2 --version

On macOS, swap the target triple for x86_64-apple-darwin or aarch64-apple-darwin. Windows support exists but I wouldn’t trust it for a serious CI pipeline yet.

Gotcha: Buck2 runs a background daemon. The first buck2 invocation in a project starts it. The daemon caches the build graph in memory — this is a big chunk of why it’s fast. If you kill the daemon manually between builds, you lose that warm cache. Let it live.

Project Structure and Core Concepts

Every Buck2 project needs two things at the root: .buckconfig and a BUCK file (or BUILD — both work).

my-project/
├── .buckconfig          # Project-level configuration
├── BUCK                 # Root build targets
├── toolchains/
│   └── BUCK            # Toolchain definitions
├── services/
│   └── api/
│       ├── BUCK        # Targets for this component
│       └── src/
│           └── main.rs
└── libs/
    └── common/
        ├── BUCK
        └── src/

The core concepts you need to internalize:

Targets — addressable build units. Written as //path/to:name. So //services/api:binary means "the target named binary in services/api/BUCK".

Rules — functions that define how to build something (rust_binary, python_library, genrule). Rules are defined in Starlark and packaged into prelude (Buck2’s standard library of rules).

Actions — the actual work: compiling, linking, copying files. Rules declare actions; Buck2 schedules and executes them.

Providers — structured data that targets pass to their dependents. A rust_library rule provides information about its output .rlib so that rust_binary can consume it. This is how the dependency graph actually communicates.

Setting Up .buckconfig

This file is the project’s config root. Buck2 uses it to locate the project boundary (it walks up directories until it finds .buckconfig).

# .buckconfig

[repositories]
  # The name of this repository. "root" is conventional.
  root = .

[build]
  # Use all available CPU cores for local execution
  threads = 0

[project]
  # Ignore these directories when watching for file changes
  ignore = .git, node_modules, target, __pycache__

[buck2]
  # Pin the prelude to a specific version for reproducibility
  # (more on this below)
  cell_alias_base = //

[cells]
  # Reference to the prelude cell — Buck2's standard rule library
  prelude = prelude

A minimal .buckconfig like this gets you started locally. Remote execution and caching config comes later.

Gotcha: The .buckconfig defines cells — named roots that Buck2 understands. If you reference prelude without defining it, builds fail with confusing errors. You either vendor the prelude locally or fetch it as part of your bootstrap. Meta provides the prelude at github.com/facebook/buck2-prelude.

Vendoring the Prelude

The prelude is a big collection of Starlark rules for Rust, C++, Python, Go, Java, and more. You have two options: submodule or copy.

# Option 1: git submodule (recommended for teams)
git submodule add https://github.com/facebook/buck2-prelude prelude

# Option 2: just copy it in (simpler, harder to update)
git clone --depth 1 https://github.com/facebook/buck2-prelude prelude
rm -rf prelude/.git

Then update .buckconfig:

[cells]
  prelude = prelude

[cell_aliases]
  config = prelude

Writing Your First BUILD File

Let’s build a real Rust service and a shared library it depends on.

# libs/common/BUCK

rust_library(
    # The name of this target
    name = "common",

    # Source files — all .rs files under src/
    srcs = glob(["src/**/*.rs"]),

    # This library is usable by targets in any cell
    visibility = ["PUBLIC"],

    # Crate name (defaults to target name if omitted)
    crate = "common",

    # External crate dependencies (from third-party)
    deps = [
        "//third-party/rust:serde",
        "//third-party/rust:serde_json",
    ],
)
# services/api/BUCK

rust_binary(
    name = "api",

    srcs = glob(["src/**/*.rs"]),

    # Depend on our shared library
    deps = [
        "//libs/common:common",
        "//third-party/rust:tokio",
        "//third-party/rust:axum",
    ],

    # Only visible within the services/ subtree and CI tooling
    visibility = [
        "//services/...",
        "//ci:__pkg__",
    ],
)

Build it:

buck2 build //services/api:api

# Run it directly
buck2 run //services/api:api

# Test it
buck2 test //services/api:api

Gotcha: glob() in Buck2 is evaluated at target graph construction time, not at action time. This means if you add a new .rs file and Buck2’s file watcher didn’t catch it (rare but happens), you might need to buck2 kill to restart the daemon. Also, avoid glob(["**/*.rs"]) at the repo root — you’ll pull in every Rust file in the monorepo into one target, which is almost never what you want.

Third-Party Dependencies

This is the part where people get frustrated. Buck2 doesn’t manage your external dependencies — you do. For Rust, the standard approach is reindeer, Meta’s tool for vendoring crates.

# Install reindeer
cargo install reindeer

# In your third-party/rust/ directory:
reindeer buckify

reindeer reads your Cargo.toml, resolves the dependency tree, vendors the crates, and generates BUCK files for them. The result is a directory full of rust_library targets that your code can depend on as //third-party/rust:crate-name.

For Python, there’s pip-shed or manual vendoring with python_library wrappers. For Node.js, the prelude has js_library and js_bundle rules but they require more setup.

This is genuinely one of Buck2’s rough edges compared to Bazel’s rules_python with pip integration. The tooling exists but it’s less polished. That said, vendoring is actually a feature in a reproducible build system — your builds stop depending on PyPI or crates.io being up.

Setting Up Remote Caching

This is where Buck2 pays off. Remote caching means build outputs are shared across your whole team and CI. If Alice built //libs/common:common this morning, Bob gets the cached result when he pulls and builds — zero recompilation.

Buck2 supports two caching backends out of the box:

1. HTTP (simple, great for starting out)

Set up any server that supports the Remote Execution API (REAPI). Buildbarn and BuildGrid are solid self-hosted options. For a quick win, Honeycomb’s Buildbarn via Docker Compose takes about 20 minutes.

# docker-compose.yml for a minimal Buildbarn setup

services:
  frontend:
    image: buildbarn/bb-frontend:20240101
    ports:
      - "8980:8980"  # REAPI gRPC
    volumes:
      - ./buildbarn-config:/config
    command: ["/config/frontend.jsonnet"]

  storage:
    image: buildbarn/bb-storage:20240101
    volumes:
      - cache-data:/storage
      - ./buildbarn-config:/config
    command: ["/config/storage.jsonnet"]

  scheduler:
    image: buildbarn/bb-scheduler:20240101
    volumes:
      - ./buildbarn-config:/config
    command: ["/config/scheduler.jsonnet"]

volumes:
  cache-data:

2. Wiring Buck2 to the cache

# .buckconfig — add remote cache config

[buck2_re_client]
  # gRPC endpoint of your REAPI server
  engine_address = grpc://your-buildbarn-host:8980

  # Enable remote cache (read + write)
  cache_mode = RemoteDepFilesEnabled

[buck2]
  # Upload outputs to cache after every successful build
  upload_all_actions = true

For cloud-hosted solutions: BuildBuddy has a generous free tier and works with both Buck2 and Bazel. You point the engine_address at their endpoint and get remote caching immediately without running your own infrastructure.

Gotcha: Remote caching only helps if your actions are hermetic — meaning the same inputs always produce the same outputs. If your build embeds $(date) or reads from a file outside the declared inputs, you’ll get cache misses constantly. Run buck2 audit providers //your:target to inspect what a target claims as inputs.

Configuring Toolchains

Toolchains tell Buck2 which compiler to use. Without an explicit toolchain, rules fall back to whatever’s on your PATH, which is fine locally and a disaster for reproducibility in CI.

# toolchains/BUCK

# Use the system Rust toolchain (reasonable for most setups)
system_rust_toolchain(
    name = "rust",
    # Pin to a specific channel for reproducibility
    channel = "stable",
    visibility = ["PUBLIC"],
)

# Or a vendored toolchain for air-gapped environments
rust_toolchain(
    name = "rust-vendored",
    rustc = "//tools/rustc:rustc",
    visibility = ["PUBLIC"],
)
# .buckconfig — register the toolchain

[toolchains]
  rust = //toolchains:rust

For hermetic C++ builds, look at toolchain_utils in the prelude — it lets you vendor Clang/LLVM as a target and reference it explicitly. This is overkill for small teams but essential if you’re shipping to multiple environments.

CI/CD Integration

Buck2 plays well with any CI system. Here’s a production-ready GitHub Actions workflow:

# .github/workflows/build.yml

name: Build and Test

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          # Fetch submodules so the prelude is available
          submodules: recursive

      - name: Install Buck2
        run: |
          curl -L \
            https://github.com/facebook/buck2/releases/latest/download/buck2-x86_64-unknown-linux-gnu.zst \
            | zstd -d > /usr/local/bin/buck2
          chmod +x /usr/local/bin/buck2

      - name: Build all targets
        run: buck2 build //...
        env:
          # Point at your remote cache
          BUCK2_RE_ENGINE_ADDRESS: grpc://your-cache:8980

      - name: Run tests
        run: buck2 test //...
        env:
          BUCK2_RE_ENGINE_ADDRESS: grpc://your-cache:8980

      - name: Check for build artifacts
        # Artifacts land in buck-out/gen/
        run: ls buck-out/gen/services/api/api

Production-ready tip: Add --num-threads=$(nproc) explicitly in CI. The default 0 (auto-detect) is fine locally but can behave unexpectedly on some CI runners that share CPUs.

Querying the Build Graph

One of Buck2’s killer features that people underuse: buck2 uquery and buck2 cquery.

# What targets does //services/api:api depend on (transitively)?
buck2 uquery "deps(//services/api:api)"

# Which targets would be affected if I change libs/common/src/lib.rs?
buck2 uquery "rdeps(//..., //libs/common:common)"

# Find all Rust binaries in the repo
buck2 uquery "kind(rust_binary, //...)"

# What files does this target read?
buck2 uquery "inputs(//services/api:api)"

This is powerful for PR impact analysis. Before merging a change, run rdeps to know exactly which targets need rebuilding. Pipe it into your CI to run only the affected tests instead of the entire suite.

Gotchas: The Things That Will Bite You

Daemon state after .buckconfig changes. Buck2 caches the config in the daemon. If you change .buckconfig, kill the daemon first: buck2 kill. Otherwise you’ll see the old config in effect and spend 30 minutes debugging.

visibility is strict. If you don’t declare visibility, targets are only visible within their own package. This is correct behavior for a monorepo — it enforces ownership — but it surprises people coming from Bazel where //visibility:public is the first thing everyone slaps on everything. Push back against the reflex to make everything public. Enforce boundaries.

glob() order is deterministic but not alphabetical. If your build depends on file ordering (unlikely but it happens with codegen), don’t rely on glob() returning files in any particular order. Sort explicitly if you need order.

buck-out/ grows forever. Buck2 stores all build outputs in buck-out/. After a few months on an active repo this is gigabytes. Add a cron job: buck2 clean --stale removes outputs that are no longer referenced. Don’t run rm -rf buck-out/ manually — you’ll lose the daemon’s link to its cache metadata.

Starlark is not Python. It looks like Python. It isn’t. No import, no standard library, no mutable global state. Loops exist but mutation is restricted. If you try to be clever with Starlark and write something Python-brained, you will get a confusing error. Read the Starlark spec — it’s short.

Remote execution != remote caching. Remote caching uploads and downloads artifacts. Remote execution actually runs your build actions on remote workers. You can use caching without execution. Execution requires a compatible REAPI worker (Buildbarn’s bb-worker, BuildBuddy’s executor, etc.) and is significantly more setup. Start with caching; add execution when you’ve outgrown local parallelism.

BXL: Buck2 Extension Language

BXL (Buck eXtension Language) lets you write Starlark scripts that query and interact with the build graph programmatically. Think of it as a scripting interface to your build system.

# scripts/find_unused_deps.bxl

def _unused_deps_impl(ctx):
    # Query all rust_library targets
    targets = ctx.uquery().kind("rust_library", "//...")
    
    for target in targets.traverse():
        deps = ctx.uquery().deps(target)
        
        # Custom logic to detect unused deps
        # (simplified — real impl would check actual symbol usage)
        ctx.output.print(target.label)

unused_deps = bxl_main(
    impl = _unused_deps_impl,
    cli_args = {},
)
buck2 bxl //scripts/find_unused_deps.bxl:unused_deps

BXL is where you build custom tooling: automated dependency audits, build graph visualization, custom CI impact analysis. It’s niche but powerful for platform teams.

Buck2 vs Bazel: The Honest Comparison

People ask this constantly. The short version:

Choose Buck2 if: You’re starting fresh, you want the fastest incremental builds, you’re heavy on Rust (the Rust rules are first-class), or you want a leaner mental model.

Choose Bazel if: You need a larger ecosystem of prebuilt rules right now, your team has existing Bazel knowledge, or you need mature Python/Java tooling out of the box.

Both use similar concepts (targets, rules, hermetic actions, remote execution). Migrating between them is painful but not impossible — the conceptual model transfers even if the syntax doesn’t.

One real difference: Buck2’s daemon and Rust core make it noticeably faster at graph computation. For repos with 50k+ targets, this matters. For repos with 500 targets, you probably won’t feel it.

Where to Go From Here

The Buck2 documentation is decent but incomplete in places — the project moves fast. The GitHub discussions tab is genuinely active and Meta engineers respond.

For rules beyond what the prelude covers, look at reindeer for Rust deps, and the community-maintained buck2-ghc-rules if Haskell is somehow your problem.

The investment to set up Buck2 properly is a week, maybe two. What you get back is builds that scale linearly with the machine count you throw at them, a build graph you can actually query, and incremental builds that are fast enough that you stop avoiding them. That behavioral change — engineers who run tests constantly instead of batching them to avoid waiting — is worth more than any CI optimization.

Your build system should be an accelerant, not a tax. Buck2 gets there.

👁 Views: 112,741 · Unique visitors: 45,402