TypeScript

Fix EACCES: permission denied, mkdir node_modules/.cache

Reproduced in Docker: why the cache directory cannot be created, the one-line Dockerfile move that fixes it, and why an old volume keeps the error alive after a rebuild.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
7 min read

TL;DR#

The image installs dependencies as root, then switches to a non-root user with USER. At runtime a tool such as @babel/register tries to create its cache directory inside node_modules/.cache, but node_modules belongs to root and is not writable by that user, so mkdir fails with EACCES. The fix is to make the non-root user own node_modules: move USER above the install step (or chown node_modules after it). If a Compose volume is mounted on node_modules, remove that volume too, or the old root-owned copy survives the rebuild.

The error, as posted in the Stack Overflow question (29 votes, no accepted answer):

text
app_1    | [2] Error: EACCES: permission denied, mkdir '/project/node_modules/.cache/@babel'
app_1    | [2]     at Object.fs.mkdirSync (fs.js:885:18)
app_1    | [2]     at sync (/project/node_modules/mkdirp/index.js:71:13)
app_1    | [2]     at sync (/project/node_modules/mkdirp/index.js:77:24)
app_1    | [2]     at save (/project/node_modules/@babel/register/lib/cache.js:50:20)

Why the cache directory cannot be created#

Read the asker's Dockerfile in order. It creates a user, gives it /project, and only then installs:

dockerfile
# Give owner rights to the current user
RUN chown -Rh $user:$user /project
 
# Install (local) Yarn packages and build
RUN yarn
 
USER $user

The chown runs before RUN yarn. At that moment node_modules does not exist yet, so the chown cannot cover it. RUN yarn then executes as root (no USER has been set yet) and creates node_modules owned by root, with mode drwxr-xr-x: the owner can write, everyone else can only read. The Dockerfile reference for USER explains that the user it sets applies to the RUN instructions that follow and to the container's CMD and ENTRYPOINT at runtime, so the application starts as the non-root user, after the install has already happened as root.

That is invisible until something writes under node_modules at runtime. @babel/register does: it compiles files on the fly and saves a cache under node_modules/.cache/@babel/register. Its save() function calls mkdirp, which calls fs.mkdirSync, and the kernel refuses because the parent directory is not writable by that user. That is the EACCES (errno -13) in the stack trace.

The same Dockerfile also clones and builds node-sass, a native module; if that part later fails with a binding error instead, the node-sass binding fix explains why the compiled binding must match the Node version and OS that run it.

The full path in the message is simply the first level mkdirp could not create. The question's stack shows mkdirp recursing only once (one sync frame calling another), so node_modules/.cache already existed there and was not writable, and the failure is on .cache/@babel. In a fresh install where .cache does not exist yet, the same bug reports mkdir '/project/node_modules/.cache' instead. Both forms were reproduced below.

Which Babel versions crash#

This crash is specific to older @babel/register. Babel merged PR #10557, "disable caching when babel could not read/write cache", and the Babel 7 changelog lists it in the 7.7.0 release of 5 November 2019. The question dates from April 2019, before that release. From 7.7.0 on, the same ownership problem no longer kills the process: Babel prints a warning, disables its cache and carries on. Reproduced with 7.7.0 and with 7.29.7:

text
Babel could not write cache to file: /project/node_modules/.cache/@babel/register/.babel.7.7.0.development.json 
due to a permission issue. Cache is disabled.
exit=0

So from 7.7.0 through the 7.29.x line the symptom changes from a crash to a warning, and no cache is written. The ownership mistake underneath is the same, and the fix below removes both.

Reproduce it in two minutes#

The reproduction ran on 10 October 2026 with Docker Engine 29.7.2, Docker Compose v5.5.0 and the official node:22-alpine image (Node v22.23.3, npm 10.9.9). The official Node images already ship a non-root user called node with uid 1000, as the docker-node best practices note, so there is no need for addgroup/adduser (on these images, addgroup -g 1000 and adduser -u 1000 both fail with gid '1000' in use and uid '1000' in use, reproduced).

package.json pins the pre-7.7.0 Babel that crashes:

json
{
  "name": "eacces-repro",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "@babel/core": "7.6.4",
    "@babel/register": "7.6.2"
  }
}

index.js loads the hook, and app.js prints the uid:

javascript
require('@babel/register')({ presets: [] });
require('./app');
javascript
console.log('app started as uid', process.getuid());

The broken Dockerfile reproduces the asker's order (ownership first, install as root, USER last):

dockerfile
FROM node:22-alpine
WORKDIR /project
COPY package.json index.js app.js ./
RUN chown -R node:node /project
RUN npm install
USER node
CMD ["node", "index.js"]

Building and running it gives:

text
app started as uid 1000
/project/node_modules/mkdirp/index.js:93
                    throw err0;
                    ^
 
Error: EACCES: permission denied, mkdir '/project/node_modules/.cache'
    at Object.mkdirSync (node:fs:1370:26)
    at sync (/project/node_modules/mkdirp/index.js:74:13)
    at sync (/project/node_modules/mkdirp/index.js:80:24)
    at sync (/project/node_modules/mkdirp/index.js:80:24)
    at save (/project/node_modules/@babel/register/lib/cache.js:52:20)

The ownership shows the cause directly: the project directory belongs to node, node_modules to root.

text
uid=1000(node) gid=1000(node) groups=1000(node),1000(node)
drwxr-xr-x   38 root     root          4096 Oct 10 09:47 /project/node_modules

Creating .cache as root at build time (a mkdir -p appended to the install step) produces the exact string from the question:

text
Error: EACCES: permission denied, mkdir '/project/node_modules/.cache/@babel'

Fix: let the runtime user own node_modules#

1. Move USER above the install#

One change to the broken Dockerfile: the USER node line moves up, so the install runs as the same user that runs the app.

dockerfile
FROM node:22-alpine
WORKDIR /project
COPY package.json index.js app.js ./
RUN chown -R node:node /project
USER node
RUN npm install
CMD ["node", "index.js"]

This is also answer 2 on the question. Applied to the asker's own file, it means placing USER $user between the chown and RUN yarn (that exact yarn image was not rebuilt here; the reproduction uses npm).

Keep the RUN chown -R node:node /project line. WORKDIR creates /project as root, and in the reproduction a variant that relied on COPY --chown=node:node alone (no RUN chown) failed at build time, because npm could not create node_modules inside the root-owned directory:

text
#8 7.661 npm error Error: EACCES: permission denied, mkdir '/project/node_modules'

2. Or chown node_modules after the install#

If you prefer to keep the install as root, hand the result over afterwards. In the reproduction this alone also fixes the crash:

dockerfile
FROM node:22-alpine
WORKDIR /project
COPY package.json index.js app.js ./
RUN chown -R node:node /project
RUN npm install && chown -R node:node /project/node_modules
USER node
CMD ["node", "index.js"]

3. Do not reach for unsafe-perm#

The highest-voted answer on the question (score 20) recommends npm config set unsafe-perm true. On a current image this breaks the build:

text
0.769 npm error `unsafe-perm` is not a valid npm option

The npm 7 beta changelog says the user, group, uid, gid and unsafe-perm configurations are no longer relevant, and npm 10.9.9 rejects the setting outright.

Verify the fix#

Rebuild with the fixed Dockerfile and run it. The app starts, exits 0, and Babel writes its cache, owned by node:

text
app started as uid 1000
exit=0
drwxr-xr-x    1 node     node          4096 Oct 10 09:48 /project/node_modules
-rw-r--r--    1 node     node          2077 Oct 10 09:48 .babel.7.6.4.development.json

A quick check on any image, before even starting the app, is to list the owner of node_modules as the runtime user:

bash
docker run --rm your-image sh -c 'id; ls -ld node_modules'

With the root-owned drwxr-xr-x permissions shown above, the non-root user cannot create a new child directly under node_modules. Ownership alone does not prove that every write beneath it fails: an existing subdirectory can have different permissions or belong to node. A supplementary Docker control on 4 November 2026 confirmed that creating a child of the root-owned parent failed, while writing inside an existing node-owned child succeeded. Check the permissions along the actual failing path.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

When the rebuild changes nothing: the node_modules volume#

Many Compose setups mount a volume on node_modules so that a bind-mounted source tree does not hide the image's dependencies; the Docker development environment tutorial uses exactly this volume trick. On the official Node images, combine it with the existing node user and a USER line placed before the install, as shown above:

yaml
services:
  app:
    build: .
    volumes:
      - node_modules:/project/node_modules
volumes:
  node_modules:

The Docker volumes documentation explains the trap: Docker copies the image's files into a volume only when the volume is empty, and a non-empty volume hides whatever the image has at that path. The first up with the broken image fills the volume with root-owned files. After switching to the fixed Dockerfile and running docker compose up --build, the new image is correct, but the container still sees the old volume:

text
app-1  | app started as uid 1000
app-1  | Error: EACCES: permission denied, mkdir '/project/node_modules/.cache'

The volume still holds root's copy:

text
drwxr-xr-x   38 root     root          4096 Oct 10 09:49 /project/node_modules

For this throwaway reproduction only, remove its disposable volumes and start again. The docker compose down reference explains that -v removes all named volumes declared in the Compose file and attached anonymous volumes. In a real project this can delete PostgreSQL data as well as dependencies: identify and repair or recreate only the disposable dependency volume instead.

bash
docker compose down -v
docker compose up --build

With a fresh volume populated from the fixed image, node_modules is owned by node and the app exits 0. The same happens with an anonymous volume (- /project/node_modules): a plain up after the rebuild still fails, while docker compose up -V (renew anonymous volumes) recreates it and the app starts. Check all anonymous mounts before using that renewal option; it is not limited to the dependency folder.

A host bind mount on node_modules (answer 3 on the question) is a different case, where ownership comes from the host directory. It was not part of this reproduction. Bind mounts have their own surprise in the other direction: the "Could not find a required file: index.html" fix shows a host mount replacing a folder the image already contains.

In this reproduction, EACCES on node_modules/.cache comes from the installation ownership: install as the runtime user, or hand node_modules over with chown. Fix the Dockerfile, then repair or recreate only the disposable dependency volume that still holds the root-owned copy. Preserve database volumes and other persistent data.

Further reading: Fix babel-node: not found on Heroku (devDependencies pruned). Also see Fix: Local package.json exists, but node_modules missing. Also see Fix npm EACCES: permission denied on package-lock.json. Also see Fix curl getaddrinfo() thread failed to start in Docker. Also see Fix react-scripts: Permission denied on npm run build.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote