npm EACCES Permission Denied? Fix It Safely
Fix npm EACCES permission errors without sudo by switching to nvm, relocating the npm prefix, and repairing root-owned cache files..
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓Basic npm install and terminal skills
- ✓A machine where you can run Node
- ✓Familiarity with PATH and file ownership
- Never sudo npm: root-owned files in ~/.npm and global node_modules cause every future install to fail
- Install Node via nvm so runtimes and globals live in your home directory with correct ownership throughout
- Point npm prefix at ~/.npm-global and add its bin to PATH for user-local global packages
- Repair past damage with sudo chown -R on ~/.npm plus npm cache verify
- Verify with npm cache verify plus a fresh global install, proving no workflow still needs sudo
Imagine hiring a locksmith who fixes your door but keeps a master key and changes the locks to only accept his key. The next locksmith can't work until you undo all of that. That's sudo npm: running installs as root litters your home folder with root-owned files, and every normal install afterward gets denied on files you supposedly own. The fix isn't a bigger hammer — it's moving your toolbox somewhere you own, so everyday installs never need superuser powers.
You run npm install -g a handy CLI and get EACCES: permission denied, mkdir '/usr/local/lib/node_modules'. Someone suggests sudo npm install -g. It works — and from that day on, every install without sudo fails with permission errors on ~/.npm/_cacache. You've traded one error for a chronic condition, and the standard advice (more sudo) keeps making it worse.
EACCES is Unix doing its job: your user can't write where it doesn't own. The correct response is owning the right directories, not borrowing root's power per command. Modern Node setups keep everything user-local by default — but only if Node itself was installed user-local. A system-wide Node from apt or an installer drags global paths into root-owned territory.
This guide repairs the damage and restructures so it can't recur. You'll reclaim ~/.npm ownership, move globals user-local with nvm or npm prefix, and verify with commands that prove the fix. Ten minutes now saves every future install. The pattern repeats because each sudo fix deepens the ownership damage while looking like it helped.
Why sudo npm Poisons Everything It Touches
sudo runs npm as root, so every file it creates — cache entries in ~/.npm, extracted packages, lockfile writes, global links — belongs to root. Your normal user afterward can't overwrite, clean, or verify those paths, and npm reports EACCES on operations that worked yesterday. Each sudo rerun deepens the damage while appearing to fix it, because root can always write over the mess it made. The cycle ends only when root stops touching user paths.
The security angle is worse than the inconvenience. npm install executes package lifecycle scripts, and under sudo those scripts run with full system privileges — a compromised or malicious dependency gets root on your machine or CI runner. Legitimate packages rarely need root; those that do (native builds needing compilers) need the toolchain installed as root separately, not the npm invocation itself. Ban sudo npm in docs, profiles, Dockerfiles, and CI templates alike.
Lifecycle scripts turn the ownership problem into a security incident. npm install executes preinstall, install, and postinstall scripts from every package in the tree — under sudo, each runs as root with full system access. A compromised dependency (typosquats, protestware escalations, maintainer-account takeovers have all happened) gains root instead of user-level confinement. Even legitimate packages behave differently as root: node-gyp builds, cache writes, and binary downloads assume the installing user's home, scattering root-owned artifacts across paths the user can't later clean. Audit your tree's scripts with npm ls plus install-script scanners before ever considering elevation, and you'll find the risk was never theoretical. The rule for the whole team is absolute: npm processes run as the project owner, full stop — toolchain setup (compilers, headers) happens through system packages, never through elevated npm.
nvm: The User-Local Node That Ends the Category
nvm installs Node versions, npm, and global packages entirely under ~/.nvm, owned by you. Global installs land in versioned user-local directories, the cache stays yours, and sudo never enters the picture. Per-project .nvmrc files pin versions so nvm use aligns every shell with the repo. For teams, this kills two error classes at once: EACCES (everything is user-owned) and version drift (everyone runs the pinned Node).
Migration is straightforward: install nvm, install your Node major, reinstall needed globals without sudo, and uninstall or ignore the system Node. Update CI images to use the same major (or nvm in CI for exact parity). The only friction is shell integration — ensure profile files source nvm on login and in non-interactive CI shells. After migration, which node and npm root -g should both point under your home directory.
Team rollout needs a migration window, not a flag day. Announce the standard (.nvmrc plus nvm use), provide a 30-minute pairing slot for stragglers, and set a date after which CI and docs assume the user-local toolchain — developers migrate at their own pace inside the window. Handle the stragglers' globals explicitly: list currently installed global packages (npm ls -g --depth=0), reinstall each under nvm without sudo, and uninstall the system Node last so nothing breaks mid-transition. For version parity, have CI read .nvmrc (nvm use in the pipeline or an image tag matching it) so laptop and runner can't drift. Editors need the nvm Node on PATH too — point VS Code's runtime and ESLint integrations at ~/.nvm versions or diagnostics disagree with builds. One afternoon of coordinated migration buys years without a single EACCES ticket.
npm Prefix + PATH: User-Local Globals Without nvm
When nvm isn't an option — managed workstations, constrained CI images, servers with system Node — relocate the global prefix instead. mkdir ~/.npm-global, npm config set prefix to it, and prepend its bin to PATH in your shell profile. Global installs then land in owned territory while the system Node stays put. This fixes -g EACCES with three lines and no runtime migration.
Make the PATH change stick: add it to .bashrc/.zshrc and to CI env blocks, then verify with which <tool> resolving under ~/.npm-global after a fresh login. Document it in onboarding — every new shell without the PATH entry revives the confusion (installed but command not found). For Docker, prefer the USER-node pattern over prefix tricks so the whole image stays single-UID.
Windows developers need the parallel recipe since profiles differ. On Windows, npm config set prefix to a user-owned folder (e.g. %APPDATA%/npm already is, but custom paths work) plus PATH updates through environment settings — no administrator prompt required afterward. Keep the recipe in one cross-platform onboarding doc with per-OS tabs so nobody improvises their own variant. Verify persistence per platform: fresh login shells on Unix (bash -lc 'which <tool>'), fresh terminals on Windows, and CI's non-interactive shell where profile files may not source at all (set PATH in the workflow env block instead). When tools vanish after updates, the checklist is short: profile sourced, prefix intact (npm config get prefix), bin on PATH. Three lines of config plus verification beats another round of sudo-shaped workarounds on every platform the team ships from.
Repairing Cache and Tree Ownership
Past sudo damage needs one explicit repair: recursive chown of ~/.npm (and the project tree if polluted) back to your UID, followed by npm cache verify to check integrity. Use numeric IDs from id -u to avoid username ambiguity on shared machines. After repair, run an unprivileged install immediately to prove the cycle is broken — if EACCES returns, something still invokes npm as root and the hunt continues through shell history, aliases, and CI definitions.
Never npm cache clean --force as root: it rebuilds cache scaffolding root-owned and re-poisons what you just fixed. Run verify as yourself; it repairs what it can and reports what it can't. In Docker, repair means rebuilding without cache after removing the sudo step — chowning inside a poisoned layer bakes the workaround into the image instead of removing the cause.
Shared machines and CI caches need ownership choreography beyond one chown. On shared build servers, give each project its own npm cache directory (npm_config_cache per job) so parallel builds as different users never collide on cache files. In CI, scope caches by lockfile hash and restore with matching UIDs — a cache saved by a root-run job poisons every non-root consumer on restore, so key the cache on the non-root job and never share across user boundaries. For Docker, COPY with --chown=node:node keeps artifacts owned correctly from the first layer instead of requiring repair layers that bloat the image. When repair recurs on one machine, stop repairing and investigate: scheduled tasks, IDE integrations, or aliased npm commands running elevated are re-poisoning on a loop. Find the loop with process auditing (who writes root-owned files into ~/.npm) and the repairs finally stick.
Docker and CI: One UID End to End
Containers multiply EACCES because Dockerfiles switch users mid-build: apt steps as root, npm steps that should be non-root, artifacts copied with root ownership. The rule is one UID for everything npm touches. Create or use the node user, chown the workdir to it before any npm step, and run installs, builds, and caches as that user. Lint Dockerfiles for sudo npm and for COPY without --chown where the consumer is non-root.
In CI, match runner UIDs to artifact ownership: caches restored from root-run jobs poison non-root jobs on restore. Prefer npm ci over install for reproducibility, mount caches user-consistently, and assert with find for root-owned files in app paths as a build step. The 3-day poisoned-cache incident ended with exactly this assertion — cheap, permanent, and self-documenting.
Rootless builds are the endgame for container pipelines. Docker BuildKit's rootless mode plus USER directives mean no layer ever contains root-created app files, and Kaniko or buildah-based CI runners extend the guarantee to platforms without daemon trust. Until then, enforce the discipline mechanically: hadolint or custom grep checks failing Dockerfiles with sudo npm, a build step asserting find /app -user root returns empty, and base images pinned to digests so upstream USER changes can't silently alter ownership semantics. Cache mounts (BuildKit --mount=type=cache) need matching UIDs too — a root-populated cache mount poisons faster than layers because it persists across builds by design. Document the UID contract (which user owns /app, caches, and artifacts) at the top of every Dockerfile. Ownership stops being folklore and becomes a checked invariant that survives every debug session.
Verifying the Fix: Prove sudo Is Gone for Good
Repairs feel complete before they are, so verify with commands that prove each layer. Run npm cache verify as yourself and confirm zero errors; install a trivial global (npm install -g cowsay) without sudo and confirm which cowsay resolves under your user-local prefix; delete and reinstall one project's node_modules to prove the tree builds unprivileged. Each check targets one past failure mode — cache, globals, project tree — and together they certify the whole workflow.
Lock the result so it survives onboarding and hurry. Document the nvm-or-prefix standard in the team README with copy-paste setup steps, add a CI lint that fails on sudo npm in scripts and Dockerfiles, and include the verification trio in new-hire setup so every machine proves itself on day one. Re-audit quarterly with find commands for root-owned files in npm paths; drift returns through debug steps and borrowed snippets, not malice. A team that verifies once and documents permanently spends its future install time shipping features instead of chowning caches. The goal isn't one fixed laptop — it's a fleet where EACCES can't recur because no path requires privileges nobody should need. Start today: the checklist takes ten minutes and pays for itself by Friday.
sudo npm in CI Poisoned Caches for 3 Days
- Docker layer caches preserve ownership damage, not just file contents. Removing the offending line isn't enough — purge the cached layers or the poison ships forever.
- Debug steps deserve the same review as features. One sudo line in a hurry cost 3 days and 12 CI-hours daily; a lint rule now blocks sudo npm in Dockerfiles entirely.
- Run one UID end-to-end in builds. Every user switch in a Dockerfile is an ownership boundary that npm's cache and tree are happy to trip over.
| File | Command / Code | Purpose |
|---|---|---|
| ls -la ~/.npm | head -8 | Why sudo npm Poisons Everything It Touches | |
| curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash | nvm | |
| mkdir -p ~/.npm-global | npm Prefix + PATH | |
| sudo chown -R $(id -u):$(id -g) ~/.npm ~/.npm-global 2>/dev/null | Repairing Cache and Tree Ownership | |
| grep -n '^USER\|sudo npm' Dockerfile | Docker and CI | |
| npm cache verify | Verifying the Fix |
Key takeaways
Common mistakes to avoid
6 patternsRunning sudo npm to silence EACCES
Cleaning the cache as root after repairing
Setting PATH only in the live shell
Switching USER mid-Dockerfile around npm steps
Using --no-cache rebuilds as the permanent workaround
Keeping the system Node and sudo-ing around it
Interview Questions on This Topic
Why is sudo npm install -g a bad fix for EACCES?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's Node.js. Mark it forged?
6 min read · try the examples if you haven't