CI that keeps up

How do I cache bun install in CI?

To stop bun install from downloading every package on every CI run, cache Bun’s global install cache (~/.bun/install/cache), not node_modules. Key the cache on bun.lock, and install with --frozen-lockfile so that CI installs only what the lockfile records. If you build in containers, copy only the manifests before the install, so that a source change does not run the install again. Dagger meets the same requirements in one place. A Dagger function mounts a cache volume at ~/.bun/install/cache, and the install step depends only on package.json and bun.lock. The same function runs on your laptop and in CI.

What a good bun install cache does

A good Bun cache setup in CI meets four requirements. Each one comes from Bun’s documentation or from the tools that CI systems provide:

  1. Cache the global install cache, not node_modules. Bun stores every downloaded package in ~/.bun/install/cache, or in the path set by the BUN_INSTALL_CACHE_DIR environment variable (Bun global cache). When the cache has a matching version, Bun uses that copy and does not download it again. Bun then hardlinks the packages into node_modules on Linux. A Bun maintainer recommends that CI cache only this directory (oven-sh/bun discussion #18752).
  2. Key the cache on bun.lock. The lockfile changes only when the dependencies change. The official actions/cache example for Bun hashes **/bun.lock (actions/cache examples). If a project still uses the binary bun.lockb, hash that file.
  3. Install from the lockfile only. “Bun does not enable --frozen-lockfile automatically in CI; pass the flag or use bun ci” (bun install). With the flag, Bun installs the exact locked versions. It exits with an error if package.json disagrees with bun.lock.
  4. Run the install only when the manifests change. In a container build, a step runs again when its inputs change. Copy package.json and bun.lock first, install, and then copy the source (Docker build cache).

Why not cache node_modules

A cached node_modules skips work that Bun has to do, and it can be wrong. It can keep stale or incorrectly resolved packages, and it can stop your project’s install scripts from running (discussion #18752). With the global cache restored, Bun builds node_modules again with hardlinks. In the same discussion, the maintainer writes that “bun install is faster than the github actions cache” in general. Measure your own pipeline with the cache and without it.

Cache bun install in GitHub Actions

In GitHub Actions, add an actions/cache step before bun install. The oven-sh/setup-bun action installs Bun, but its cache holds only the Bun executable. Its no-cache input is documented as “Disable caching of the downloaded executable” (setup-bun). The dependency cache is a separate step:

- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- uses: actions/cache@v6
  with:
    path: ~/.bun/install/cache
    key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
- run: bun install --frozen-lockfile
- run: bun test

This configuration meets requirements 1 to 3. setup-bun reads the Bun version from the packageManager field in package.json, then from engines.bun. Without either field, it uses latest.

Cache bun install in GitLab CI

GitLab caches only paths inside the project directory. Its docs say that caches “define their paths relative to the project directory, and can’t link to files outside it” (GitLab caching). Thus, move Bun’s cache into the project with BUN_INSTALL_CACHE_DIR, and key it on bun.lock with cache:key:files:

default:
  image: oven/bun:1.3.14

variables:
  BUN_INSTALL_CACHE_DIR: "$CI_PROJECT_DIR/.bun-cache"

test:
  cache:
    key:
      files:
        - bun.lock
    paths:
      - .bun-cache/
    policy: pull-push
  script:
    - bun install --frozen-lockfile
    - bun test

With pull-push, the job downloads the cache when it starts and uploads changes when it ends. If several jobs only read the cache, give them policy: pull. GitLab recommends pull for parallel jobs that use the same cache (cache:policy). Use cache, not artifacts, for dependencies. GitLab recommends cache for “packages you download from the internet”, and artifacts for results that later stages need (GitLab caching).

Cache bun install in a Dockerfile

In a Dockerfile, a cache mount keeps the Bun cache between builds, and the order of the layers skips the install. Copy the manifests first, then install with a cache mount, and then copy the source:

# syntax=docker/dockerfile:1
FROM oven/bun:1.3.14
WORKDIR /app
COPY package.json bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache \
    bun install --frozen-lockfile
COPY . .
RUN bun test

The COPY package.json bun.lock layer meets requirement 4. The cache mount meets requirement 1. When the install layer runs again, Docker’s cache mount means that “you only download new or changed packages” (Docker cache mounts). Add a .dockerignore that excludes node_modules, as Bun’s Docker guide shows.

Every CI system needs its own Bun cache setup

Each CI system needs its own copy of the same four requirements. The cache path, the key on bun.lock, and the --frozen-lockfile flag live in the workflow file, and again in .gitlab-ci.yml or the Dockerfile. When the Bun version or the cache path changes, you change every file. GitLab also needs BUN_INSTALL_CACHE_DIR, because the default path is outside the project.

The GitHub Actions and GitLab setups meet requirement 4 only in part. Their caches restore files, but they do not skip steps. Thus, bun install runs in every job, even when bun.lock did not change. Each job also downloads the cache before the install.

Cache bun install with Dagger

Dagger runs each step of a pipeline in a container and caches each step by its inputs, the same model as Docker layer caching (Dang SDK). You write the steps as functions in a Dagger module, a package of functions that build and test the project. The module below wraps the Bun commands you already run.

Create the workspace, install the Dang SDK, and create the project’s module. Dang is Dagger’s own language for modules:

dagger init
dagger module install dagger.io/sdk/dang
dagger module init dang --name bun-api-dev -y

dagger init creates dagger.toml, the workspace configuration. dagger module install writes dagger.lock, which pins the SDK module to an exact version. Commit both files. dagger module init creates the module in .dagger/modules/bun-api-dev and installs it. Replace bun-api-dev with your project’s name plus -dev.

Replace .dagger/modules/bun-api-dev/main.dang with the code below. BunApiDev is the type name that dagger module init generated from the module name:

"""
Install, test, and build the Bun API.
"""
type BunApiDev {
  """
  The Bun image. Pin it to the project's Bun version.
  """
  pub bunImage: String!

  let npmToken: Secret
  let manifests: Directory!
  let source: Directory!

  new(
    ws: Workspace!,
    bunImage: String! = "oven/bun:1.3.14",
    """
    Token for a private registry, read by bunfig.toml as $NPM_TOKEN.
    """
    npmToken: Secret = null,
  ) {
    self.bunImage = bunImage
    self.npmToken = npmToken
    self.manifests = ws.directory("/",
      include: ["**/package.json", "bun.lock", "bunfig.toml"],
      exclude: ["**/node_modules"],
    )
    self.source = ws.directory("/", exclude: ["**/node_modules", "dist", ".dagger"])
    self
  }

  let bun: Container! {
    let ctr = container
      .from(bunImage)
      .withMountedCache("/root/.bun/install/cache", cacheVolume("bun-install-cache"))
      .withWorkdir("/app")
    if (npmToken != null) {
      ctr.withSecretVariable("NPM_TOKEN", npmToken!)
    } else {
      ctr
    }
  }

  """
  Bun with the dependencies in bun.lock installed.
  """
  pub deps: Container! {
    bun
      .withDirectory("/app", manifests)
      .withExec(["bun", "install", "--frozen-lockfile"])
  }

  """
  Run bun test.
  """
  pub test: Void @check {
    deps
      .withDirectory("/app", source)
      .withExec(["bun", "test"])
      .sync
    null
  }

  """
  Build the app with bun run build.
  """
  pub build: Directory! {
    deps
      .withDirectory("/app", source)
      .withExec(["bun", "run", "build"])
      .directory("/app/dist")
  }
}

The sections below show how this module meets each requirement.

Cache the global install cache, not node_modules

withMountedCache mounts a cache volume at /root/.bun/install/cache. A cache volume is a directory whose contents persist across Dagger runs. The path is the default Bun cache for the root user, who runs the commands in the oven/bun image. It is the same directory that the GitHub Actions and Dockerfile setups cache. The module mounts no cache on node_modules, so Bun builds node_modules again from the cache at each install.

Key the cache on bun.lock

The install step reads only manifests: the package.json files, bun.lock, and bunfig.toml. Dagger caches the step by these inputs, so the key comes from bun.lock with no hash expression. When bun.lock changes, the install runs again. Bun then takes each package that did not change from the cache volume.

Install from the lockfile only

The module runs bun install --frozen-lockfile, the same command as the hand-built setups. If package.json and bun.lock disagree, the check fails with Bun’s own error:

  ┃ error: lockfile had changes, but lockfile is frozen
  ┃ note: try re-running without --frozen-lockfile and commit the updated lockfile
  ! exit code: 1

Run the install only when the manifests change

test and build add the source after the install, as the Dockerfile does. A change to a source file does not change the inputs of the install step, so Dagger uses the cached result of the install. In this guide’s project, the install step of the first dagger check installed 122 packages in 4.08 seconds. After an edit to src/index.ts, the install step did not run, and only bun test ran. That result comes from one run on one machine.

Run the checks and the build

The @check directive makes test a check, a function that dagger check runs and that passes or fails. List the checks:

dagger check -l
MODULE       CHECK  DESCRIPTION
bun-api-dev  test   Run bun test.
dang-sdk     stale  staleness check: re-generate modules and clients managed by the Dang SDK

Run them:

dagger check
CHECKS  ✔ 2 passed
✔ dag://?check=bun-api-dev/test 0.1s OK
✔ dag://?check=dang-sdk/generate/stale 1.1s OK

Build the app and write dist to your machine:

dagger api call bun-api-dev build export --path=dist

Pass a private registry token as a Dagger secret

If bunfig.toml reads a registry token from $NPM_TOKEN, give the token to the module as a secret. Bun reads environment variables in the registry settings of bunfig.toml (Bun scopes and registries). Set the module’s npmToken setting to a secret provider:

dagger module settings bun-api-dev npmToken "cmd://gh auth token"

The cmd:// provider runs the command on your machine and uses its output as the secret. Dagger removes secret values from logs and never writes them to container layers. withSecretVariable sets NPM_TOKEN in the container that runs Bun, and nowhere else.

Run the same checks locally and in CI

Cloud Checks run your checks on Dagger’s Cloud Engines on each push and pull request to a GitHub repository. You do not need a CI workflow file. Connect the repository:

dagger cloud checks on

Each push then runs the same dagger check that you run on your laptop, with the same install step and the same --frozen-lockfile flag. The cache path and the key live in one module, not in each CI file.

Bottom line: Cache ~/.bun/install/cache, not node_modules, key it on bun.lock, and install with --frozen-lockfile. In GitHub Actions, use actions/cache. In GitLab CI, move the cache into the project with BUN_INSTALL_CACHE_DIR. In a Dockerfile, use a cache mount after a COPY of only the manifests. A Dagger module meets the same requirements once, with a cache volume and an install step that depends only on the manifests, and Cloud Checks run it on every push.