← All posts
DevOps7 min readProject: Docker setups across my projects

The node_modules volume trick: live code in Docker without breaking dependencies

Mounting your source code into a container gives you live reload, and immediately breaks node_modules: your Mac's packages overwrite the container's Linux ones, or vanish completely. Here is the named-volume trick that keeps dependencies inside Docker, why each app (front and back) gets its own node_modules volume, and how it saves disk space and install time.

#Docker#Node.js#Docker Compose#node_modules#Developer Experience#Yarn

The problem

Bind-mounting source code into containers hid or overwrote the container's node_modules, causing native module crashes and slow, repeated installs.

The result

Code is live-mounted, dependencies live in a dedicated volume per app and environment, native modules always match the container, and installs happen once.

Stack

DockerDocker ComposeNode.jsYarnNestJSNext.js

The problem

During development, I want two things at the same time:

  1. The app runs in Docker, next to its database and Redis, exactly like on the server.
  2. My code changes apply instantly, without rebuilding an image for every edit.

The usual answer to point 2 is a bind mount: mount the project folder into the container.

app:
  build: .
  volumes:
    - .:/app   # my code, live

And that's where things break. The Dockerfile already ran yarn install inside the image, creating /app/node_modules. Then the bind mount covers the entire /app folder with my local project folder, and the container now sees my machine's node_modules instead of its own. One of two things happens:

  • There's no node_modules on my machine → the container sees nothing → Cannot find module '@nestjs/core'.
  • There is one, installed on macOS → the container uses packages built for macOS → native modules (bcrypt, Prisma engines, sharp…) crash with errors like invalid ELF header or "wrong platform".

On top of that, every developer and every environment installed dependencies again and again, filling disks with copies of the same packages.

The details

Why native modules break

Most npm packages are plain JavaScript and work anywhere. But some include compiled binaries for a specific operating system and CPU:

  • my laptop: macOS, often Apple Silicon (arm64),
  • the container: Linux (Alpine, musl libc), often x86_64 on the server.

A binary built for one can't run on the other. When the host's node_modules is mounted into the container, those binaries are the wrong ones.

Why the image's node_modules disappears

A mount replaces whatever was at that path in the image. Mounting the project folder at /app hides everything the image had under /app, including the node_modules the Dockerfile carefully installed.

The challenges

  • Keep the live code mount: I don't want to give up instant reload.
  • Keep the container's own dependencies, installed for Linux, visible inside the container.
  • Don't reinstall on every start. Installs are slow; they should happen once and persist.
  • Several apps, several environments. Front and back have different dependencies, and dev and prod setups shouldn't share installs.
  • Disk space. Duplicate node_modules folders add up fast on a laptop and on a server.

The solution

1. Mount a named volume on top of node_modules

The trick is a second mount, more specific than the first:

app:
  build: .
  volumes:
    - .:/app                              # 1. my code, live
    - backend_node_modules:/app/node_modules  # 2. dependencies, from a volume

volumes:
  backend_node_modules:

Docker applies mounts so that the more specific path wins. The project folder is mounted at /app, and then a named volume is mounted at /app/node_modules, on top of it. Inside the container:

  • /app/src, /app/package.json, … come from my machine (live edits),
  • /app/node_modules comes from the volume, owned by Docker, never touched by my machine.

2. The first start fills the volume from the image

When an empty named volume is mounted onto a path where the image already has files, Docker copies the image's files into the volume the first time. So:

  1. the Dockerfile runs yarn install → Linux node_modules inside the image,
  2. the first docker compose up creates the empty volume → Docker copies the image's node_modules into it,
  3. every later start reuses the volume: no reinstall, instant startup.

The Dockerfile stays simple and cache-friendly:

FROM node:22-alpine
WORKDIR /app

# Dependencies first: this layer is cached until package.json or the lockfile changes
COPY package.json yarn.lock ./
COPY prisma ./prisma/
RUN yarn install

# Then the code
COPY . .
RUN yarn prisma generate

Copying the manifests before the source means editing code doesn't invalidate the install layer. Only a dependency change triggers a new install.

3. One volume per app: front and back separate

In a stack with a frontend and a backend, each app gets its own node_modules volume:

services:
  backend:
    build: ./backend
    volumes:
      - ./backend:/app
      - backend_node_modules:/app/node_modules

  frontend:
    build: ./frontend
    volumes:
      - ./frontend:/app
      - frontend_node_modules:/app/node_modules

volumes:
  backend_node_modules:
  frontend_node_modules:

Why separate:

  • Different dependency trees. A NestJS backend and a Next.js frontend share almost nothing. Mixing them would create a huge, fragile folder.
  • Independent updates. Upgrading a frontend package never touches the backend's install, and the other way around.
  • Clear cleanup. When one app's dependencies get into a weird state, I delete only its volume.

And the same idea applies per environment: development and production setups use different volume names (app_node_modules vs. app_node_modules_dev), so experimenting in one never corrupts the other.

4. Saving space and time

This setup saves resources in several ways:

  • No node_modules needed on the host at all. The host doesn't need its own install to run the stack; dependencies live once, in the volume.
  • No reinstall on restart. The volume persists across up/down and rebuilds of the container.
  • Docker's layer cache keeps the install step cached until package.json or the lockfile changes.
  • One volume per app instead of copies scattered across folders and environments.

(If you also want a local install for your editor's autocomplete, that's fine: the container never sees it.)

5. When dependencies change

Adding a package needs one extra thought, because the volume persists on purpose:

# Option A: install inside the running container (updates the volume)
docker compose exec backend yarn add <package>

# Option B: rebuild, and reset ONLY this app's dependency volume
docker compose down
docker volume rm <project>_backend_node_modules
docker compose up -d --build

Option B is the "clean slate" for one app's dependencies, and it never touches database volumes, because those are separate named volumes too (see "Where the data lives").

6. Keep the build context clean

A .dockerignore stops the host's node_modules (and build outputs) from being copied into the image at build time:

node_modules
dist
.next
.git

Without it, COPY . . would copy macOS binaries into the image and recreate the original problem at build time instead of run time. It also makes builds much faster, because the build context shrinks dramatically.

7. Production images don't need the trick

The volume trick is for development, where code is live-mounted. Production images don't mount source code at all: they copy the built app and run it. For Next.js, I use the standalone output, which contains only the dependencies the server actually needs, so the production image has a tiny node_modules and no mounts.

Lessons learned

  • A bind mount hides everything under its path. Including the node_modules your Dockerfile installed.
  • The more specific mount wins. A named volume at /app/node_modules sits on top of the code mount.
  • Empty named volumes are seeded from the image. Install once in the Dockerfile; the volume keeps it.
  • Native modules are platform-specific. Never run a macOS node_modules inside a Linux container.
  • One dependency volume per app and per environment. Front and back stay independent; dev and prod never mix.
  • Copy manifests before code. It keeps the install layer cached across code changes.
  • .dockerignore your host's node_modules. Or the problem comes back at build time.
  • Development tricks stay in development. Production images copy built code and need no mounts.

FAQ

Why does node_modules disappear when I mount my project into a Docker container?

A bind mount replaces everything at the target path, including the node_modules created in the image. Mount a named volume at /app/node_modules so the container keeps its own dependencies.

Why do I get "invalid ELF header" errors in Docker with Node.js?

Your container is using native modules compiled for your host system (for example macOS) instead of Linux. Keep the container's node_modules separate with a named volume, and exclude the host's node_modules with .dockerignore.

How do I update node_modules stored in a Docker volume?

Either install the new package inside the running container (docker compose exec app yarn add <pkg>), or remove that app's dependency volume and rebuild so it's recreated from the new image.

Should the frontend and backend share a node_modules volume?

No. They have different dependencies and update independently. A separate volume per app keeps installs smaller, cleaner and easy to reset one at a time.

Facing a similar problem?

I help teams fix performance, backend and reliability issues in production.

Let's talk
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us