BlueByte
EACCESFixed

npm: EACCES permission denied on a global install

By Haneul SeoUpdated September 6, 20265 min

Hi, it's BlueByte. When npm install -g stops with EACCES: permission denied, nothing is wrong with the package or your network — npm tried to write into a directory your user account doesn't own, and the operating system refused. The symptom is a global install (npm install -g <something>) failing with code EACCES and a permission denied line pointing at a path like /usr/local/lib/node_modules. We'll walk through what that means, how to confirm it's the prefix directory, fix it the way npm recommends, and keep it from coming back — without reaching for sudo.

What the EACCES error is telling you

A global install fails and npm prints a block like this:

npm error code EACCES
npm error syscall mkdir
npm error path /usr/local/lib/node_modules/npm-check-updates
npm error errno -13
npm error Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules/npm-check-updates'

Older npm (v9 and earlier) prints the same failure with an npm ERR! prefix instead of npm error, and the syscall may read access, open, or unlink depending on which step failed. The constant is code EACCES with permission denied: EACCES is the POSIX code for "access denied," and errno -13 is the same thing as a number. npm isn't broken — it asked the OS to create a folder and the OS said no.

Why a global install hits a permission wall

npm install -g writes into npm's prefix directory — the global root where global packages and their bin links live. On a system-wide Node install (from an OS package manager or the official installer), that prefix is usually /usr/local (or /usr on Linux), which is owned by root. Your normal account can't create files there, so a global install fails until you either run as root or point npm at a directory you own.

That is the whole cause. It is not a dependency conflict, a corrupt cache, or a bad registry — those raise different errors. EACCES is only about who owns the target directory.

Confirm it's the prefix directory, not your project

Two commands show where npm is trying to write and who owns it:

npm config get prefix
ls -ld $(npm config get prefix)/lib/node_modules

Expected output on a system Node — note the root root ownership:

/usr/local
drwxr-xr-x 3 root root 4096 Sep  6 10:12 /usr/local/lib/node_modules

If that directory is owned by root and whoami prints your own name, the EACCES is fully explained: you're writing somewhere only root can.

Fix it the way npm recommends: a Node version manager

npm's own guidance is that the cleanest fix is to reinstall Node and npm through a Node version manager, so the entire toolchain lives under your home directory and no global install ever needs root. Once Node is installed that way, npm config get prefix points inside your home, and:

npm install -g npm-check-updates

...finishes with no EACCES. Prefer this on a fresh machine, because it also fixes the same problem for every future Node version you install.

Fix it by moving npm's prefix to a folder you own

If you can't reinstall Node, change npm's prefix to a directory you own. This is npm's documented manual option (it does not apply to Windows):

npm config set prefix ~/.local

Then put that prefix's bin on your PATH by adding this line to ~/.profile (and ~/.zprofile if you use zsh):

export PATH=~/.local/bin:$PATH

Reload the profile and install:

source ~/.profile
npm install -g npm-check-updates

Now global packages land in ~/.local/lib/node_modules and their commands in ~/.local/bin, all owned by you.

A real case: a CLI install after a distro Node

You install Node from your distro's package manager, then run npm install -g typescript and hit EACCES: permission denied, mkdir '/usr/lib/node_modules/typescript'. You run npm config get prefix and it prints /usr; ls -ld /usr/lib/node_modules shows root root. Instead of sudo, you run npm config set prefix ~/.local, add export PATH=~/.local/bin:$PATH to ~/.profile, source it, and re-run npm install -g typescript. It installs, and tsc --version works — because ~/.local/bin is on your PATH and owned by you.

Check the global install lands in your directory

Verify both the location and that the command resolves:

npm root -g
which tsc
/home/you/.local/lib/node_modules
/home/you/.local/bin/tsc

If npm root -g still shows a root-owned path, the prefix change didn't take — re-check npm config get prefix. If which finds nothing, your PATH line isn't loaded yet; open a new shell or source the profile again.

Keep EACCES from coming back — and why sudo is the wrong reflex

sudo npm install -g does make the error vanish, but it installs packages as root and can leave root-owned files inside your cache and config, which then trigger new EACCES errors on ordinary commands later. npm's guidance is to avoid it. Set the prefix once, keep it in your dotfiles, and never install global packages as root. For a command you only need to run once, npx <command> (npm 5.2+) runs it without touching the global prefix at all.

How EACCES differs from ERESOLVE

EACCES is a filesystem permission error — npm couldn't write to the target directory. ERESOLVE is a dependency-resolution error — npm knew exactly where to write but couldn't reconcile conflicting version requirements in your tree. If the message says code EACCES / permission denied, fix ownership or the prefix; if it says code ERESOLVE / could not resolve, that's a version conflict, and no amount of permission-fixing will move it.

Related questions

Can't I just fix it with sudo?

It works once, but sudo npm install -g installs packages as root and can seed root-owned files in your cache and config, which cause fresh EACCES errors on later commands. npm recommends against it — move the prefix to a directory you own instead.

Where does npm config set prefix save the setting, and does it persist?

It writes to your per-user .npmrc (see npm config get userconfig), so it persists across shells and Node updates for that user. Confirm the active value any time with npm config get prefix.

I changed the prefix but my command still isn't found.

The new bin directory isn't on your PATH yet. Add export PATH=~/.local/bin:$PATH to ~/.profile (and ~/.zprofile for zsh), then open a new shell or source the file again.

Does this apply to Windows?

No. The manual prefix fix is documented for macOS and Linux. On Windows the global prefix already sits under your user profile, so it doesn't hit this root-owned-directory problem.

Will moving the prefix hide packages I installed earlier with sudo?

Yes — those stay in the old root-owned prefix and won't be visible from the new one. Reinstall the ones you still use under the new prefix, then remove the old root-owned copies.

References

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

More in this category

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Since the CVE-2022-24765 fix in Git 2.35.2, Git refuses to read a repository whose working tree or .git directory is owned by a different user than the one running the command. It shows up in containers, CI jobs, sudo sessions and shared drives. Fix the ownership if the repo should be yours, or add the exact path to safe.directory in your global config — never in the repo's own config, which Git ignores for this.

Git
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

An admission webhook sits in front of your write, the API server could not get an answer out of it, and failurePolicy: Fail turned that silence into a rejection. The tail of the message is the whole diagnosis: context deadline exceeded means the call went nowhere, no endpoints available means nothing is running, and an x509 line means the API server does not trust the webhook's certificate. Each has a different fix, and none of them is your manifest.

Kubernetes
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

A statement waited the full innodb_lock_wait_timeout for a row lock another transaction is still holding, and gave up. sys.innodb_lock_waits names the blocking session and hands you the KILL statement, and a blocking_query of NULL means the blocker is idle on an open transaction. The detail most retry loops get wrong: by default only the timed-out statement is rolled back, so your transaction is still open and still holds every lock it took earlier.

MySQL
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

Reads keep working and every write is rejected, because the last background save failed and stop-writes-on-bgsave-error defaults to yes. The log names the real cause — no space, a dir the redis user can't write, a read-only mount at rename time, or fork failing with Cannot allocate memory. Fix the cause, run one BGSAVE, and writes come back on their own with no restart: rdb_last_bgsave_status flips from err to ok. Setting stop-writes-on-bgsave-error no restores writes instantly but leaves the snapshot broken, so treat it as a deliberate trade, not the fix.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (exit 134)

The V8 heap has its own ceiling, derived from system memory and the Node release, and it is often far below the RAM you have; when a build or server reaches it, V8 aborts with FATAL ERROR: Reached heap limit and exit code 134. Read the real limit with v8.getHeapStatistics().heap_size_limit, then raise it with --max-old-space-size (in MiB) or NODE_OPTIONS for a large workload, size it below the cgroup limit inside containers, and use --heapsnapshot-near-heap-limit to catch a leak in a long-running process. Exit 137 with no FATAL ERROR line is a container kill, not this.

Node.js
exec /docker-entrypoint.sh: exec format errorFixed

Docker: "exec format error" when the container starts — wrong-platform image, no emulator, or a script with no shebang

The container exits on its first instruction with exec format error — the kernel's ENOEXEC, meaning the file exists but cannot be executed here. In practice that is an image built on one CPU architecture (an Apple-silicon Mac produces linux/arm64) and run on another (an x86_64 server) with no QEMU handler registered in binfmt_misc, or an entrypoint script whose first line is not a shebang. uname -m, docker image inspect and ls /proc/sys/fs/binfmt_misc tell the causes apart; the fix is an explicit docker buildx build --platform (or a manifest list for both), QEMU registration or --platform when you mean to emulate, and a #!/bin/sh line for the script.

Docker