hack.foo
Recipes

Install dependencies in a deps service

The commands and configuration on this page were audited against v5.0.0-next.14. The execution notes below come from the newer recipe source revision; the examples were not rerun with the pinned binary.

Install Node and Bun packages (npm, pnpm, Yarn, Bun) inside the container environment, not on your machine. Give the project one deps service that installs once into a named volume, and let every Node or Bun service use that volume.

The complete project is in examples/recipes/deps-service. Every Node or Bun project in Config examples uses the same pattern.

Why not install on your machine

Containers run Linux. Many packages ship a native binary for each platform, or compile one at install time. A package manager installs only the binary for the platform it runs on. If you install on macOS, or on a different Linux C library, and mount that node_modules into the container, those packages fail to load.

For example, Rollup installed on a glibc Linux machine and then run in an Alpine (musl) container fails like this:

error: Cannot find module @rollup/rollup-linux-x64-musl.

On macOS you get the same kind of error for darwin-arm64 binaries. Packages such as esbuild, Rollup, SWC, Sharp, Prisma and bcrypt are typical examples. A shared node_modules also mixes up two installs: your machine's and the container's.

How it works

One install, shared by every service One install, shared by every service

Steps

  1. Add a deps service and a named volume. Mount the volume at the same path in deps and in every service that runs your code.

    name: deps-service
    services:
      # One-shot installer: runs on every start, inside Linux, then exits.
      deps:
        image: oven/bun:1.4.4-slim
        working_dir: /app
        command: ["bun", "install", "--frozen-lockfile"]
        volumes:
          - ..:/app
          - node_modules:/app/node_modules
    
      # Every Node or Bun service mounts the same volume at the same path and
      # waits for the installer to finish successfully.
      app:
        image: oven/bun:1.4.4-slim
        working_dir: /app
        command: ["bun", "run", "dev"]
        volumes:
          - ..:/app
          - node_modules:/app/node_modules:ro
        depends_on:
          deps:
            condition: service_completed_successfully
        labels:
          caddy: deps-service.hack.local
          caddy.reverse_proxy: "{{upstreams 3000}}"
          caddy.tls: internal
        networks:
          - hack-dev
          - default
    
    volumes:
      node_modules: {}
    
    networks:
      hack-dev:
        external: true

    The volume mounted over /app/node_modules hides any node_modules folder in your checkout from the containers. Only deps mounts it writable; app services mount it read-only. Put tool caches and build output in separate writable volumes.

  2. Use a locked install with your package manager:

    Package managerCommand
    Bunbun install --frozen-lockfile
    npmnpm ci
    pnpmpnpm install --frozen-lockfile
    Yarn (Berry)yarn install --immutable

    Use the same runtime and package manager versions as the project pins. If the locked install fails, find out why (a stale lockfile, a different package manager version, the wrong package manager). Do not drop the flag to get a green start.

  3. The project config needs nothing special for this pattern:

    {
      "$schema": "https://schemas.hack.local/hack.config.schema.json",
      "name": "deps-service",
      "dev_host": "deps-service.hack.local"
    }
  4. Start the project. deps runs, exits with status 0, then the other services start. hack ps shows deps as exited 0; Hack does not count that as a failed start.

    hack up -d
    hack ps

After a lockfile change

deps runs on every hack up and hack restart. After you pull a lockfile change, restart:

hack restart

To add a package, run the package manager in the deps container. It updates package.json and the lockfile in your checkout and installs into the volume:

hack run deps bun add nanoid@5.1.6
hack restart

On Linux with Docker Engine, files the container writes in your checkout (the lockfile here) belong to root. See Common pitfalls.

Branch instances and worktrees

Each branch instance is its own Compose project, so by default it gets its own volume. A linked worktree (or --branch <name>) installs into <project>--<branch>_node_modules. That is simple and safe, but every worktree installs from scratch once.

Share dependency caches only between worktrees you trust equally. Each worktree can run its own writable installer against the shared volume. A matching lockfile, a read-only app mount or the locked installer protocol does not establish trust in another worktree's code. Keep the default per-instance volumes for untrusted worktrees.

To share one install between equally trusted instances that have the same lockfile, label the deps service and set its platform:

  deps:
    image: oven/bun:1.4.4-slim
    platform: linux/arm64 # linux/amd64 on Intel and AMD machines
    working_dir: /app
    command: ["bun", "install", "--frozen-lockfile"]
    volumes:
      - ..:/app
      - node_modules:/app/node_modules
    labels:
      hack.dependencies.cache-volume: node_modules
      hack.dependencies.lockfiles: bun.lock,package.json

Hack then names the volume from a fingerprint of the lockfiles, the installer image and the platform: hack-cache-<project>-node_modules-<hash>. Instances with the same fingerprint use the same volume. When a worktree changes its lockfile and restarts, it gets a new volume; the other instances keep theirs.

  • Without a resolved platform, Hack prints Shared dependency cache disabled and uses the plain volume. Set platform in the file, or set DOCKER_DEFAULT_PLATFORM in your shell when your team uses different machines.

  • The label selects a volume. It does not stop two installers from writing the same volume at once, and it does not prove the install is complete. For that, see the opt-in locked installer protocol.

  • While instances share a volume, hack run deps bun add ... installs into the shared volume, so the other instances see the new package too. Change only the lockfile first, then restart; the new lockfile selects a new volume:

    hack run deps bun add nanoid@5.1.6 --lockfile-only
    hack restart

More detail: Dependency directories and caches.

Monorepos and workspaces

Bun and pnpm also write a node_modules folder into each workspace package, not only at the root. Give each of those folders its own named volume, and mount all of them in deps and in every app service. The monorepo example does this with a YAML anchor:

x-app-volumes: &app-volumes
  - ..:/app
  - node_modules:/app/node_modules:ro
  - web_node_modules:/app/apps/web/node_modules:ro
  - api_node_modules:/app/apps/api/node_modules:ro

Use this read-only list for app services, and a separate writable list for deps, as the complete monorepo example does. Add a volume when you add a workspace package that has dependencies. If you miss one, the container writes that folder into your checkout.

Recover a stale or half-written volume

An interrupted install, or a package changed by hand, can leave the volume broken. A plain locked install may not notice: Bun reports no changes and leaves a damaged package in place.

  1. Try a forced reinstall into the same volume, then restart the app:

    hack run deps bun install --frozen-lockfile --force
    hack restart

    For other package managers, use their force or clean-install option. We tested this step with Bun only.

  2. If that does not help, remove the volume and start again. Stop the instance first; Docker refuses to remove a volume that a container still uses.

    hack down
    docker volume ls --filter label=com.docker.compose.project=<project>
    docker volume rm <project>_node_modules     # or the hack-cache-... name
    hack up -d

    For a branch instance the project is <project>--<branch>.

hack down never removes the dependency volume, and neither does hack down --prune-caches.

What was tested

The read-only consumer mounts added later were checked statically, without a new Docker run. The execution results below describe the original writable mounts.

Each step on this page was run with Docker Engine on Linux (amd64). The platform: linux/arm64 value in the shared-volume example is for Apple silicon; we ran it with linux/amd64. The macOS failure mode was not reproduced; the musl example above was.

Related: Dependency directories and caches, Config examples, Common pitfalls.


Source: Recipe corrections (next.14 compatible) · docs/recipes/deps-service.md.

On this page