TypeScript

Fix tar EPERM: operation not permitted, futime

Reproduce the ownership mismatch behind the warning, see how a current npm install actually fails, and fix the layer that creates it.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

TL;DR#

npm WARN tar EPERM: operation not permitted, futime comes from tar (via npm) trying to set the modification time of a file it just extracted, on a file the current process does not own. futimes() can only set an arbitrary timestamp — the one stored in the archive entry — if the caller owns the file or is root; a non-root user with only write access gets EPERM. The fix is not sudo or deleting node_modules: it is keeping the UID that owns the app directory, the npm cache, and anything baked in during the Docker build the same UID that runs npm install, in every layer, before and after a USER switch.

The error#

text
npm WARN tar EPERM: operation not permitted, futime
npm WARN tar EPERM: operation not permitted, futime
npm WARN tar EPERM: operation not permitted, futime
...
npm timing action:extract Completed in 276816ms
npm timing action:finalize Completed in 172ms

This is from the original report (2019, 30 votes, 28,867 views): a node:alpine image, npm install fine as root, but thousands of this warning and a hung install as a non-root user.

Why it happens#

tar's unpacker writes each file's bytes, then tries to restore the timestamp from the archive entry so the extracted tree matches what was packed. In npm's bundled tar package this is a direct call to fs.futimesSync(fd, atime, mtime), with a fallback to fs.utimesSync() on the path if the file descriptor call fails — confirmed in the current source:

text
if (entry.mtime && !this.noMtime) {
  const atime = entry.atime || new Date()
  const mtime = entry.mtime
  try {
    fs.futimesSync(fd, atime, mtime)
  } catch (futimeser) {
    try {
      fs.utimesSync(String(entry.absolute), atime, mtime)
    } catch {
      er = futimeser
    }
  }
}

Both calls try to set the file's mtime to an arbitrary value read from the tar header, not to "now". POSIX only lets you do that if you are the file's owner or the root user; owning write permission on the file is not enough — write access alone only ever lets you bump a timestamp to the current time, never set an older or custom one. So when a non-root process extracts into (or over) files it does not own, futimes returns EPERM, the fallback utimes fails the same way, and tar's own error handler treats that as a non-fatal entry error by default: it warns and continues rather than aborting the extraction — which is exactly why the 2019 report saw thousands of WARN lines instead of one clean failure.

The ownership mismatch itself has one usual cause in a Docker image: a layer ran as root (the default user, and the default for RUN npm install before any USER line) and left files owned by UID 0, then a later USER node line switches to a non-root UID without ever chown-ing what root created. The Dockerfile reference is explicit about the default: "COPY … Sets ownership of copied files. Without this flag, files are created with UID and GID of 0" — so any COPY without --chown before a non-root USER plants the mismatch.

Reproducing it today#

Reproduced on the official node:22-alpine image with its bundled npm 10.9.9, using Docker 29.9.2 on this machine, 6 November 2026.

Negative control — non-root user, ownership consistent everywhere: no error.

text
=== non-root user (node uid 1000), ownership correct everywhere, no bind mount, no external volume ===
added 7 packages, and audited 8 packages in 9s
EXIT=0

Ownership mismatch — a layer installed dependencies as root, then USER node without chown -Ring node_modules; installing one more package as node:

text
npm error code EACCES
npm error syscall mkdir
npm error path /home/node/app/node_modules/is-even
npm error Error: EACCES: permission denied, mkdir '/home/node/app/node_modules/is-even'

Downgrading an already-extracted package (so tar/npm overwrites files inside an existing root-owned directory instead of creating a new one) fails the same way, one step later:

text
npm error code EACCES
npm error syscall rename
npm error path /home/node/app/node_modules/chalk
npm error dest /home/node/app/node_modules/.chalk-uXy2BkOP
npm error Error: EACCES: permission denied, rename '/home/node/app/node_modules/chalk' -> '/home/node/app/node_modules/.chalk-uXy2BkOP'

Same root cause as the 2019 report — a non-root process meeting root-owned files it must modify — but a different shape. Current npm extracts into its cache first and then moves files into node_modules with its own mkdir/rename code (@npmcli/fs), not through tar's warn-and-continue unpacking. The ownership problem now surfaces as one immediate, fatal EACCES instead of a flood of tar WARN lines followed by a hang. The underlying permission issue is identical; the failure mode is not.

A Docker-specific trap reproduces the same family: a freshly created named volume (docker volume create, or an anonymous volume from -v name:/path with no prior content) is owned by root:root until something inside the container chowns it. Mounting one as the npm cache for a non-root user gives:

text
npm error code EACCES
npm error
npm error Your cache folder contains root-owned files, due to a bug in
npm error previous versions of npm which has since been addressed.
npm error
npm error To permanently fix this problem, please run:
npm error   sudo chown -R 1000:1000 "/home/node/.npm"

What no longer reproduces. The WSL1/old-Docker-Desktop angle from two of the original answers — bind-mounting a Windows host folder and losing permission metadata — does not trigger the error on current Docker Desktop with WSL2. Bind-mounting a Windows folder into node:22-alpine and running npm install as the non-root node user installs cleanly; ls -la inside the container shows the mounted files as rwxrwxrwx regardless of their real Windows ownership, because Docker Desktop's current host-to-container file sharing grants full access rather than enforcing UID-based permissions on that path.

Full raw output for every run above is in repro.log alongside this draft.

Fix#

The fix is ownership consistency, not disabling permission checks.

  1. Chown what root created, before switching user, in the same or a later RUN layer:
dockerfile
FROM node:22-alpine
WORKDIR /home/node/app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
RUN chown -R node:node /home/node/app
USER node
  1. Or copy with the right owner from the start, so root never creates anything under the app directory:
dockerfile
FROM node:22-alpine
WORKDIR /home/node/app
COPY --chown=node:node package.json package-lock.json ./
USER node
RUN npm ci --omit=dev
  1. If you mount the npm cache as a volume for a non-root user, chown it once:
bash
docker run --rm -v npm-cache:/home/node/.npm node:22-alpine \
  chown -R node:node /home/node/.npm

or mount it under a path the Dockerfile already chowns, so it inherits the right owner the first time a non-root npm install writes to it.

  1. Never mix a root-run install step with a later non-root install step on the same tree without a chown -R between them — that is the one condition that reliably reproduces the error today, with or without Alpine.

sudo chown -R $(whoami) * run from outside the image, on the host, does nothing for files that only exist inside a container's layers; it only helps when the mismatch is on a bind-mounted host path, which is a different case from the one in this error.

Verify the fix#

bash
docker build -t app .
docker run --rm app sh -c "whoami && npm install --dry-run"

No tar WARN lines, no EACCES on mkdir or rename, and whoami should report the non-root user the Dockerfile sets with USER.

If the ownership is now consistent but you instead see npm ERR! code EACCES pointing at package-lock.json on a bind-mounted project directory (not inside the image), that is a narrower case covered in Fix npm EACCES: permission denied on package-lock.json — check that page's path against yours before assuming it is the same mismatch. A floating npm self-upgrade failing on an unchanged Dockerfile is a different error family, covered in Fix Docker npm EBADENGINE: Unsupported engine. Outside Docker, pnpm can fail a rename mid-install on Windows for an unrelated reason: see Fix pnpm ERR_PNPM_LINKING_FAILED on Windows — there it is an open file handle, not UID ownership.

Ownership mismatches like this one are a recurring source of confusing, non-deterministic Docker failures precisely because the symptom (a warning flood, a hang, a clean fatal error) depends on which tool version is doing the extracting and moving — the underlying rule, that only an owner or root can rewrite a file's history, has not changed since the original 2019 report; only the code path that trips over it has.

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.

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