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
Steps
-
Add a
depsservice and a named volume. Mount the volume at the same path indepsand 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: trueThe volume mounted over
/app/node_moduleshides anynode_modulesfolder in your checkout from the containers. Onlydepsmounts it writable; app services mount it read-only. Put tool caches and build output in separate writable volumes. -
Use a locked install with your package manager:
Package manager Command Bun bun install --frozen-lockfilenpm npm cipnpm pnpm install --frozen-lockfileYarn (Berry) yarn install --immutableUse 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.
-
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" } -
Start the project.
depsruns, exits with status 0, then the other services start.hack psshowsdepsasexited 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 restartTo 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 restartOn 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.jsonHack 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 disabledand uses the plain volume. Setplatformin the file, or setDOCKER_DEFAULT_PLATFORMin 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:roUse 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.
-
Try a forced reinstall into the same volume, then restart the app:
hack run deps bun install --frozen-lockfile --force hack restartFor other package managers, use their force or clean-install option. We tested this step with Bun only.
-
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 -dFor 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.