monorepo.
monorepo10 min read

Cargo, uv, and Bun in One Repo: A Polyglot Monorepo Setup That Actually Works

Run Cargo, uv, and Bun side by side in a single monorepo. Workspace files, .gitignore strategy, root Makefile orchestration, and the pitfalls that bite at 3am.

Cargo, uv, and Bun in One Repo: A Polyglot Monorepo Setup That Actually Works

Picking one language for a whole project is a luxury. Most real systems grow tendrils: a Rust daemon for the hot path, a Python service for the LLM glue, a TypeScript frontend because nothing else renders a UI quickly enough. Splitting these across three GitHub repos sounds clean until you spend a week chasing schema drift across boundaries you can no longer atomically refactor.

A polyglot monorepo solves that, but only if the build tooling cooperates. Cargo, uv, and Bun were each designed to own their corner of the disk. Putting them in the same tree without a plan produces a target/ directory that swallows 14 GB, three flavors of lockfile conflict per PR, and a CI matrix that takes 22 minutes to tell you a typo in a TOML file broke everything.

This piece walks through a working layout for a cargo + uv + bun monorepo: where each workspace file lives, what the .gitignore actually needs, how a root Makefile orchestrates the three toolchains without becoming a 400-line wrapper, and the specific footguns that bit me before the layout settled.

The Layout

Start with the directory tree, because everything downstream is shaped by it:

your_project/
  Cargo.toml             # Rust workspace root
  pyproject.toml         # uv workspace root
  package.json           # Bun workspace root (or bunfig.toml)
  Makefile               # one entry point for humans + CI
  .gitignore
  rust-apps/
    daemon/
      Cargo.toml         # member of Rust workspace
      src/
    agent/
      Cargo.toml
      src/
  py-services/
    orchestrator/
      pyproject.toml     # member of uv workspace
      src/orchestrator/
    memory/
      pyproject.toml
      src/memory/
  ts-apps/
    webapp/
      package.json       # member of Bun workspace
      src/
  packages/
    py-shared/           # shared pydantic schemas
      pyproject.toml
    rust-shared/         # shared types crate
      Cargo.toml

Three workspace roots at the top level, each owning its own subset of directories. Nothing is nested inside another tool's territory. rust-apps/ is invisible to uv and Bun. py-services/ is invisible to Cargo. This sounds obvious until you remember that Cargo will happily traverse into node_modules/ looking for a Cargo.toml if it gets confused, and uv's resolver is unhappy if it discovers a stray pyproject.toml it didn't expect.

The names matter less than the discipline: pick a prefix per language and stick with it. I have seen teams pick apps/ + services/ + packages/ for a polyglot setup and watch new contributors guess wrong about which folder takes which kind of project.

Cargo Workspace Root

The root Cargo.toml lists members by glob and declares the resolver version:

[workspace]
resolver = "2"
members = [
    "rust-apps/*",
    "packages/rust-shared",
]

[workspace.package]
edition = "2021"
rust-version = "1.78"

[workspace.dependencies]
tokio = { version = "1.39", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"

workspace.dependencies is the single most useful Cargo feature for monorepos. Every member crate references shared deps as tokio = { workspace = true } and version drift dies on the spot. A cargo update at the root touches everything; a per-crate cargo update does not exist as a concept and that is a feature.

A subtle gotcha: glob members (rust-apps/*) catch hidden directories on some filesystems. If you have a .idea/ or .vscode/ folder at that level with anything that even smells like a Cargo manifest, Cargo will complain. Put IDE folders at the repo root, not inside rust-apps/.

uv Workspace Root

uv added workspace support in late 2024 and it shipped stable in the 0.5 series. The root pyproject.toml declares the workspace:

[project]
name = "your-project-workspace"
version = "0"
requires-python = ">=3.12"

[tool.uv.workspace]
members = [
    "py-services/*",
    "packages/py-shared",
]

[tool.uv.sources]
py-shared = { workspace = true }

The [tool.uv.sources] block is what makes cross-package imports work without publishing to a registry. Inside py-services/orchestrator/pyproject.toml you list py-shared as a normal dependency:

[project]
name = "orchestrator"
dependencies = [
    "py-shared",
    "fastapi>=0.115",
    "pydantic>=2.9",
]

uv resolves py-shared to the local workspace member and editable-installs it. Run uv sync at the root and every member gets one virtualenv at .venv/ with all packages installed. This is the single biggest ergonomic win over the old pip install -e . dance per service.

Compared to Poetry's workspace story, uv is dramatically faster \u2014 a cold install on a 12-package workspace finishes in under 4 seconds on my laptop where Poetry took close to 90. Compared to a pip-tools per-service setup, uv removes the requirement to manage individual requirements.txt files entirely. Pick uv when you want one venv for the whole repo; stay on per-service venvs if your services have genuinely incompatible Python version requirements (rare).

Bun Workspace Root

Bun workspaces piggyback on the npm workspaces field in package.json. The root file:

{
  "name": "your-project-ts",
  "private": true,
  "workspaces": [
    "ts-apps/*",
    "packages/ts-shared"
  ],
  "devDependencies": {
    "typescript": "^5.6.0"
  }
}

bun install at the root walks the workspaces field, links cross-package dependencies via symlinks in node_modules/, and writes a single bun.lockb (binary lockfile) at the root. The binary lockfile produces clean diffs (a single Bin line in PRs) but means you cannot edit it by hand the way you would massage a package-lock.json. The tradeoff is worth it: Bun installs are routinely 5\u201310\u00d7 faster than npm install on the same package.json, and the lockfile shrinks the diff noise on dependency PRs by an order of magnitude.

If you prefer human-readable lockfiles, bun install --save-text-lockfile produces a bun.lock text file instead. Pick one across the repo and commit to it. Mixing both modes across branches confuses the install resolver.

The .gitignore That Actually Works

The naive .gitignore for a polyglot setup is three concatenated stock files for Rust, Python, and Node. That works, mostly, but two specific entries deserve attention:

# Rust
/target/
**/*.rs.bk

# Python
.venv/
__pycache__/
*.pyc
.ruff_cache/
.mypy_cache/
.pytest_cache/
dist/
*.egg-info/

# Bun / TypeScript
node_modules/
.next/
dist/
*.tsbuildinfo

# IDE
.idea/
.vscode/
*.swp

# OS
.DS_Store
Thumbs.db

Two pitfalls hide in there. First, target/ should be anchored with a leading slash (/target/) at the repo root if you only have a top-level Cargo workspace. Without the anchor, any directory named target/ anywhere in the tree gets ignored \u2014 fine until a Python service legitimately needs a target/ output folder and silently disappears from git.

Second, dist/ shows up for both Python and TypeScript. That is fine because both languages produce dist/ artifacts as build outputs. What is not fine is committing a dist/ from a release build because you forgot to gitignore it in a fresh service. The safest pattern is to keep these globs in the root .gitignore and resist the urge to override them per-service.

One thing you probably want NOT in .gitignore: the lockfiles. Cargo.lock, uv.lock, and bun.lockb all belong in version control for application repos. Library crates traditionally don't commit Cargo.lock, but in a monorepo where everything is an application, commit all three.

Root Makefile Orchestration

Three toolchains means three sets of commands developers need to remember. A root Makefile collapses that to one:

.PHONY: install fmt lint test check clean help

help:
\t@echo "Targets: install fmt lint test check clean"

install:
\tcargo fetch
\tuv sync
\tbun install

fmt:
\tcargo fmt --all
\tuv run ruff format .
\tbun run --filter '*' fmt || true

lint:
\tcargo clippy --workspace --all-targets -- -D warnings
\tuv run ruff check .
\tuv run mypy py-services packages/py-shared
\tbun run --filter '*' lint

test:
\tcargo test --workspace
\tuv run pytest py-services packages/py-shared
\tbun test

check: lint test

clean:
\tcargo clean
\trm -rf .venv
\trm -rf node_modules
\tfind . -name __pycache__ -type d -exec rm -rf {} +

This is intentionally flat. No conditional logic, no per-service loops written in shell, no clever dependency targets. The Makefile is a list of three-line recipes that any developer can read top to bottom. If you find yourself writing Make functions or shelling into Python to compute targets, you have outgrown Make \u2014 move to just or a real task runner like task (Taskfile.dev). Both are 80% as ergonomic as Make with 200% less footgun surface.

The bun run --filter '*' lint invocation runs the lint script in every workspace member that defines one. The || true on fmt is there because Bun returns non-zero when a workspace member is missing the script and we'd rather not require every package to define a no-op formatter.

One pattern worth adopting: a check target that runs lint + test sequentially is exactly what CI calls. Keep the Makefile and the CI workflow in sync so make check locally and the CI job exercise identical code paths. The day they drift is the day a green PR breaks main.

CI Considerations

GitHub Actions handles polyglot monorepos reasonably well if you let the cache do its job:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true
      - uses: oven-sh/setup-bun@v2
      - run: make install
      - run: make check

Swatinem/rust-cache and astral-sh/setup-uv's built-in cache cover roughly 80% of cold-start install time on a clean runner. Bun installs are fast enough that caching node_modules/ is borderline pointless \u2014 a fresh bun install on a 50-package workspace finishes in under 8 seconds, often quicker than the cache restore step itself.

For larger repos consider path filters: only run the Rust job when rust-apps/** or packages/rust-shared/** changes, only run the Python job when py-services/** changes, etc. The paths filter on GitHub Actions covers this pattern without needing a Bazel-grade build system.

Footguns I Walked Into

A handful of specific things bit me before this layout settled:

Stale .venv/ after restructure. If you reorganize the workspace and a member moves between directories, the editable installs in .venv/site-packages/ point at the old location. Symptom: imports work for some teammates and not others. Fix: make clean && make install after any workspace member move.

Cargo + uv competing for target/. A Python service that produces a target/ build directory will get ignored by git because the root .gitignore has /target/. I worked around it by renaming the Python output to out/ or build/, which is a saner default for non-Rust artifacts anyway.

Bun symlinks confusing Python tooling. Bun creates symlinks under node_modules/ for workspace cross-references. If a Python tool (mypy, ruff) is configured to scan from the repo root without exclusions, it follows the symlinks into TypeScript territory and produces nonsense errors. Add node_modules/ to your tool.ruff.exclude and tool.mypy.exclude lists explicitly.

Lockfile merge conflicts on parallel PRs. Two PRs that both touch dependencies in the same workspace will produce a lockfile conflict on the second merge. uv and Bun both regenerate cleanly from pyproject.toml / package.json, but Cargo wants you to manually resolve Cargo.lock. The least painful workflow is to merge dependency PRs serially and rebase rather than merge. A make install after the rebase regenerates all three lockfiles in a consistent state.

When This Layout Stops Working

The setup above scales comfortably to maybe 20\u201330 packages across the three languages. Past that, a few signals suggest you need heavier tooling:

  • CI runs longer than 8 minutes even with caches warm \u2014 start path-filtering aggressively or look at Nx or Turborepo
  • Cross-language dependencies that can't be expressed as "Python service calls Rust binary over HTTP" \u2014 consider gRPC + buf for schema sharing, or pyo3 to embed Rust directly into Python
  • Multiple teams want isolated CI policies per language \u2014 extract to multiple repos joined by a git submodule or just accept the merge cost

For one or two developers shipping a polyglot product, the Cargo + uv + Bun + Makefile setup is plenty. The total ceremony is one pyproject.toml per service, one Cargo.toml per crate, one package.json per TS app, and a Makefile that fits on a single screen. Anything more elaborate is a guess at scale you don't have yet.

References: