Skip to content

Fix: EACCES: permission denied, mkdir / open / unlink (Node.js)

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix EACCES permission denied errors in Node.js for mkdir, open, unlink, and scandir operations, covering npm global installs, Docker, NVM, CI/CD, and filesystem permissions.

The Error

You run a Node.js application or an npm command and get:

Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules/my-package'

Or one of these variations:

Error: EACCES: permission denied, open '/var/log/app.log'
Error: EACCES: permission denied, unlink '/tmp/cache/session-abc123'
Error: EACCES: permission denied, scandir '/root/.npm/_cacache'
Error: EACCES: permission denied, access '/usr/local/lib/node_modules'
Error: EACCES: permission denied, mkdir '/home/app/.cache/node'
npm ERR! Error: EACCES: permission denied, rename '/usr/local/lib/node_modules/.package-lock.json'

The EACCES error code comes from the POSIX standard. It means the Node.js process attempted a filesystem operation — creating a directory, reading a file, deleting a file, or listing directory contents — and the operating system denied it because the process does not have the required permission on that path.

Why This Happens

Node.js delegates all filesystem operations to the operating system kernel through libuv. When your code calls fs.mkdirSync('/some/path') or fs.writeFileSync('/some/file', data), Node.js asks the OS to perform that operation on behalf of the user running the process. The OS checks three things:

  1. File ownership. Every file and directory has an owner (user) and a group. The OS checks whether the running process’s effective user ID matches the file’s owner, the file’s group, or falls into the “others” category.

  2. Permission bits. Each of the three categories (owner, group, others) has separate read (r), write (w), and execute (x) bits. To create a file in a directory, you need write and execute permission on that directory. To read a file, you need read permission. To delete (unlink) a file, you need write permission on the directory containing it.

  3. Security modules. Even when standard Unix permissions allow the operation, mandatory access control systems like SELinux or AppArmor can independently deny it.

The most common scenario that triggers EACCES in Node.js is trying to write to a directory owned by root while running as a non-root user. This happens frequently with npm global installs, cache directories, and log files. You can check the ownership and permissions of any path with:

ls -la /usr/local/lib/node_modules/

Output like this reveals the problem:

drwxr-xr-x 5 root root 4096 Apr 10 09:00 node_modules

The directory is owned by root, and only the owner has write permission. Your non-root user can read and enter the directory (r-x for others) but cannot create files or subdirectories in it.

Fix 1: Fix Directory Ownership with chown

The most direct fix is to change ownership of the directory to your user:

sudo chown -R $USER:$USER /usr/local/lib/node_modules
sudo chown -R $USER:$USER /usr/local/bin

Check ownership before and after:

ls -la /usr/local/lib/ | grep node_modules

This approach works well for development machines where you are the only user. For shared servers or production systems, the other fixes below are more appropriate because changing ownership of system directories affects all users.

If the error is about a directory inside your home folder (like ~/.npm or ~/.config), this is almost always the right fix:

sudo chown -R $USER:$USER ~/.npm
sudo chown -R $USER:$USER ~/.config

These directories should never be owned by root. They usually end up root-owned because someone ran sudo npm install at some point, which created cache files as root inside your home directory. For a deeper walkthrough of npm global permission errors, see Fix: EACCES permission denied when installing npm packages globally.

Fix 2: Configure npm Global Prefix to a User-Owned Directory

Instead of changing ownership of system directories, you can tell npm to install global packages in a directory you already own:

mkdir -p ~/.npm-global
npm config set prefix '~/.npm-global'

Then add the new bin directory to your PATH. Add this line to ~/.bashrc, ~/.zshrc, or ~/.profile:

export PATH="$HOME/.npm-global/bin:$PATH"

Reload your shell:

source ~/.bashrc

Now npm install -g writes to ~/.npm-global instead of /usr/local, and you never need sudo for global installs:

npm install -g typescript
which tsc
# /home/youruser/.npm-global/bin/tsc

This is npm’s officially recommended solution for EACCES errors on global installs. It works on any Linux distribution and macOS without requiring elevated privileges.

Real-world scenario: A developer runs sudo npm install -g typescript once, and from then on, the npm cache at ~/.npm is partly owned by root. Every subsequent non-sudo npm command randomly fails with EACCES as it tries to write to root-owned cache directories. Fix it with sudo chown -R $USER:$USER ~/.npm.

Fix 3: Use nvm to Avoid Permission Issues Entirely

Node Version Manager (nvm) installs Node.js and npm in your home directory, which means every operation — including global package installs — runs in user-owned space. No sudo, no permission issues.

Install nvm:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

Restart your terminal, then install Node.js:

nvm install --lts
nvm use --lts

Verify the installation paths are in your home directory:

which node
# /home/youruser/.nvm/versions/node/v22.x.x/bin/node

which npm
# /home/youruser/.nvm/versions/node/v22.x.x/bin/npm

npm config get prefix
# /home/youruser/.nvm/versions/node/v22.x.x

With nvm, npm install -g writes to ~/.nvm/versions/node/<version>/lib/node_modules/, which your user owns. If you previously installed Node.js through your system package manager or from the official website, uninstall that version first to avoid conflicts.

Fix 4: Docker USER Directive and Volume Permissions

When running Node.js inside Docker, EACCES errors are extremely common. The default Docker user is root, but many Node.js base images switch to a non-root user, or you intentionally run as non-root for security. The problem arises when the container user does not own the directories it needs to write to.

Fix in Dockerfile — create and own the app directory:

FROM node:22-alpine

# The node image includes a 'node' user (uid 1000)
# Create app directory and set ownership before switching user
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app

WORKDIR /home/node/app

# Copy package files as root, then fix ownership
COPY --chown=node:node package*.json ./

USER node

RUN npm ci

COPY --chown=node:node . .

CMD ["node", "server.js"]

Fix for mounted volumes at runtime:

docker run -u $(id -u):$(id -g) -v $(pwd):/app my-node-app

Fix in Docker Compose:

services:
  app:
    build: .
    user: "1000:1000"
    volumes:
      - ./src:/app/src
      - node_modules:/app/node_modules
volumes:
  node_modules:

Using a named volume for node_modules avoids permission conflicts between the host and container filesystem. The container owns the named volume entirely.

A common mistake is running npm install as root in the Dockerfile and then switching to a non-root user. The node_modules directory ends up owned by root, and the non-root user cannot update or remove packages. Always switch to the non-root user before running npm install. For more Docker permission troubleshooting, see Fix: Docker Permission Denied While Trying to Connect to the Docker Daemon Socket.

Fix 5: /tmp and Cache Directory Permissions

Node.js and npm use temporary and cache directories extensively. When multiple users or processes share the same system, these directories can end up with mixed ownership:

Error: EACCES: permission denied, mkdir '/tmp/npm-12345'
Error: EACCES: permission denied, open '/home/user/.npm/_cacache/tmp/abc123'

Fix the npm cache:

# Check current cache location
npm config get cache
# Usually: ~/.npm

# Fix ownership
sudo chown -R $USER:$USER $(npm config get cache)

# Or clear and rebuild the cache
npm cache clean --force

Fix /tmp permission issues:

The /tmp directory should have the sticky bit set (permissions 1777), which allows any user to create files but only the owner can delete them:

ls -ld /tmp
# Should show: drwxrwxrwt

If /tmp has wrong permissions:

sudo chmod 1777 /tmp

Use a custom temp directory if you cannot fix /tmp:

export TMPDIR="$HOME/tmp"
mkdir -p "$TMPDIR"
npm install

You can also set the temp directory in your Node.js application:

const os = require('os');
process.env.TMPDIR = '/path/to/writable/tmp';
// os.tmpdir() will now return your custom path

Fix 6: Running as Root vs Non-Root

Running Node.js as root eliminates EACCES errors but creates security risks and often causes more permission problems downstream. Here is when each approach makes sense:

Running as non-root (recommended for almost everything):

# Create a dedicated user for your application
sudo useradd -m -s /bin/bash nodeapp
sudo mkdir -p /var/log/myapp /var/lib/myapp
sudo chown -R nodeapp:nodeapp /var/log/myapp /var/lib/myapp

# Run the application as that user
sudo -u nodeapp node /opt/myapp/server.js

With systemd:

[Unit]
Description=My Node.js App
After=network.target

[Service]
Type=simple
User=nodeapp
Group=nodeapp
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node server.js
Restart=on-failure

# Grant access to specific directories
ReadWritePaths=/var/log/myapp /var/lib/myapp

[Install]
WantedBy=multi-user.target

When root causes more problems: If you run npm install as root, it creates node_modules owned by root. Then your non-root application cannot write to that directory (for example, to update lock files or write to a local SQLite database). The fix is to consistently run everything as the same user. If you ran npm commands as root accidentally, fix the resulting ownership:

sudo chown -R $USER:$USER node_modules/
sudo chown -R $USER:$USER package-lock.json

If your npm lifecycle scripts are failing with exit codes during install, see Fix: npm ERR! code ELIFECYCLE for additional troubleshooting steps.

Fix 7: CI/CD Permission Issues

CI/CD environments (GitHub Actions, GitLab CI, Jenkins, CircleCI) frequently trigger EACCES errors because of how they manage workspaces, caches, and Docker containers.

GitHub Actions:

- name: Fix permissions
  run: sudo chown -R $USER:$USER $GITHUB_WORKSPACE

- name: Install dependencies
  run: npm ci

- name: Set npm cache directory
  run: |
    npm config set cache $GITHUB_WORKSPACE/.npm-cache
    npm ci

GitLab CI with Docker executor:

before_script:
  - npm config set cache .npm --userconfig .npmrc
  - chown -R $(id -u):$(id -g) .

install:
  script:
    - npm ci --cache .npm
  cache:
    paths:
      - .npm/

Jenkins:

Jenkins agents often run as the jenkins user, which may not own the workspace directory after certain operations:

pipeline {
    agent any
    stages {
        stage('Install') {
            steps {
                sh 'sudo chown -R jenkins:jenkins ${WORKSPACE}'
                sh 'npm ci'
            }
        }
    }
}

Common CI/CD causes of EACCES:

  • Cached node_modules from a previous build that ran as a different user (or root)
  • Docker-in-Docker setups where the inner container user doesn’t match the outer one
  • Volume mounts from the CI host into a container with mismatched UIDs
  • Build artifacts from a previous step that set restrictive permissions

The simplest fix in any CI/CD system is to ensure the user running npm ci or npm install owns the entire workspace directory before the install step runs.

Fix 8: Windows Permission Issues

On Windows, EACCES errors in Node.js have different root causes than on Linux and macOS, since Windows uses ACLs rather than Unix permission bits.

Antivirus software locking files:

Windows Defender and other antivirus programs scan files as they are created, which can temporarily lock them. When Node.js or npm tries to delete or rename a file that the antivirus is scanning, you get EACCES:

Error: EACCES: permission denied, unlink 'C:\Users\you\project\node_modules\.package-lock.json'

Fix: Add your project directory and Node.js installation directory to the antivirus exclusion list. For Windows Defender:

  1. Open Windows Security > Virus & threat protection > Manage settings
  2. Scroll to Exclusions > Add or remove exclusions
  3. Add your project folder and %APPDATA%\npm

File or directory in use by another process:

Windows does not allow deleting files that are open by any process. If npm install fails with EACCES on an unlink operation, close any editors, file explorers, or terminals that might have the directory open, then retry.

Long path names:

Windows has a 260-character path limit by default. Deeply nested node_modules directories can exceed this, causing EACCES or EPERM errors. Enable long paths:

# Run as Administrator
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force

Also enable long path support in Git:

git config --system core.longpaths true

Running without Administrator privileges:

Some npm packages with native addons (node-gyp) need write access to system directories. Use a terminal with Administrator privileges, or better yet, use nvm-windows to keep everything in user space.

For general bash permission errors on Linux and macOS, see Fix: bash: Permission denied.

Fix 9: SELinux and AppArmor Blocking Node.js

On systems with mandatory access control, you can have correct Unix permissions and still get EACCES. SELinux (Fedora, RHEL, CentOS, Amazon Linux) and AppArmor (Ubuntu, Debian) independently evaluate whether a process is allowed to perform a filesystem operation.

Diagnosing SELinux denials:

# Check if SELinux is enforcing
getenforce

# Search for recent denials related to node
sudo ausearch -m avc -ts recent | grep node

# Check the audit log
sudo grep "denied.*node" /var/log/audit/audit.log | tail -10

Temporarily disable SELinux to confirm it is the cause:

sudo setenforce 0
# Run your Node.js application again
# If it works, SELinux was the problem
sudo setenforce 1  # re-enable immediately

Permanent fix — set the correct SELinux context:

# Allow Node.js to read and write to your app directory
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/opt/myapp(/.*)?"
sudo restorecon -Rv /opt/myapp

# If your app uses a non-standard port, allow it
sudo semanage port -a -t http_port_t -p tcp 3000

Diagnosing AppArmor denials:

# Check for AppArmor denials in system logs
sudo dmesg | grep -i apparmor | tail -10
sudo journalctl -k | grep -i apparmor | tail -10

# Check which profiles are enforcing
sudo aa-status

Fix AppArmor denials:

# Put the profile in complain mode (logs instead of blocking)
sudo aa-complain /usr/bin/node

# Or create a local override to allow specific paths
sudo nano /etc/apparmor.d/local/usr.bin.node
# Add: /opt/myapp/** rw,
sudo apparmor_parser -r /etc/apparmor.d/usr.bin.node

If your Node.js application runs in a Docker container on a SELinux-enabled host, add the :z flag to volume mounts:

docker run -v /opt/myapp/data:/app/data:z my-node-app

This tells Docker to relabel the volume contents with the correct SELinux context for the container.

How other tools and platforms enforce permissions

The same EACCES surface looks different once you cross OS or runtime boundaries. The error code is POSIX, but the gatekeeper that denies the call is not always the classic rwx triplet.

POSIX permissions vs Windows ACLs. Linux and macOS evaluate three permission triplets (owner, group, others) plus optional POSIX ACLs via setfacl/getfacl. Windows uses NTFS ACLs, which are per-principal entries with granular rights (ReadData, WriteData, AppendData, Synchronize, etc.). Node.js on Windows maps a denial to EACCES, but chmod and chown calls are mostly no-ops — you have to inspect the ACL with icacls C:\path\to\file and grant rights with icacls "C:\path" /grant "%USERNAME%":(OI)(CI)F. A file can be writable by Unix permissions on a mounted NTFS volume yet still rejected by the ACL.

SELinux, AppArmor, and Linux capabilities. Even when the rwx bits allow the call, SELinux (Fedora, RHEL, Amazon Linux 2023) and AppArmor (Ubuntu, Debian) can deny it independently. SELinux uses type enforcement — every file has a label like httpd_sys_rw_content_t, and a process can only touch labels its domain is allowed to. AppArmor uses path-based profiles in /etc/apparmor.d/. Both produce EACCES at the syscall level with no hint about which MAC layer blocked it. Linux capabilities like CAP_DAC_OVERRIDE and CAP_CHOWN are a third axis: dropping them in a Docker securityContext or a systemd CapabilityBoundingSet can cause EACCES on operations that look fine under ls -l.

Container user remapping. Docker, Podman, and containerd can remap container UIDs into a different host UID range using userns-remap (Docker) or rootless mode (Podman). A container that thinks it runs as UID 1000 can be writing as host UID 100000:65536. Bind-mounted host directories owned by your real UID 1000 then look root-owned from inside the container, and writes fail. Either disable user namespace remapping for that mount, use a named volume, or pre-chown the host directory to the remapped range.

macOS APFS quirks. macOS layers two protections on top of POSIX permissions. System Integrity Protection (SIP) denies writes to /usr, /System, and parts of /Library even for root. Transparency, Consent and Control (TCC) asks for user consent the first time a process touches ~/Documents, ~/Desktop, ~/Downloads, removable volumes, or iCloud Drive. A Node.js process launched from a terminal without Full Disk Access can hit EACCES on these locations while running fine on /tmp. On Apple Silicon, an x86_64 Node.js binary running under Rosetta also runs inside a slightly different sandbox — switch to the native arm64 build to rule sandbox issues out.

Filesystem capabilities (xattrs). On Linux, extended attributes like security.capability and the immutable flag (chattr +i) can make a file unwritable regardless of mode bits. Run lsattr to check for the i (immutable) or a (append-only) attribute, and getcap on executables to inspect file capabilities. Network filesystems (NFS, CIFS) add their own server-side ACL layer on top.

Still Not Working?

The path does not exist

EACCES usually means a permission problem, but occasionally Node.js reports EACCES when the issue is actually a missing parent directory. Verify the full path exists:

# Check every component of the path
namei -l /opt/myapp/logs/output.log

If a parent directory does not exist, create it with the correct ownership:

sudo mkdir -p /opt/myapp/logs
sudo chown nodeapp:nodeapp /opt/myapp/logs

Read-only filesystem

If the filesystem itself is mounted read-only, no permission change will help:

mount | grep ' / '
# Look for 'ro' in the mount options

This is common in containerized environments, certain cloud instances, and when a disk has errors. If intentional (like a read-only Docker layer), write to a volume or tmpfs mount instead.

File handle or inode exhaustion

When the system runs out of file handles or inodes, the errors can sometimes appear as EACCES rather than EMFILE or ENOSPC:

# Check open file limits
ulimit -n

# Check inode usage
df -i

# Increase the open file limit for the current session
ulimit -n 65535

For persistent changes, add to /etc/security/limits.conf:

nodeapp  soft  nofile  65535
nodeapp  hard  nofile  65535

npm’s global package directory is corrupted

If fixing permissions does not resolve npm install -g errors, the global node_modules directory may have structural issues. In that case, remove and reinstall:

# Check the prefix
npm config get prefix

# Remove the global node_modules (be careful with this)
sudo rm -rf /usr/local/lib/node_modules
sudo mkdir /usr/local/lib/node_modules
sudo chown -R $USER:$USER /usr/local/lib/node_modules

# Reinstall global packages
npm install -g <your-packages>

Process is sandboxed

Some environments sandbox Node.js processes using namespaces, seccomp, or container runtimes. In these cases, the process may genuinely be restricted from accessing certain paths. Check if your process is running in a restricted environment:

cat /proc/self/status | grep Seccomp
# Seccomp: 2 means seccomp is active with a filter

In Kubernetes, check if the pod has a restrictive security context:

securityContext:
  readOnlyRootFilesystem: true  # This blocks writes everywhere except mounted volumes

If readOnlyRootFilesystem is true, you must write to an explicitly mounted volume or an emptyDir mount.

Bind-mounted directory has the wrong SELinux label

On Fedora, RHEL, and CoreOS, a Docker or Podman bind mount that worked on Ubuntu fails with EACCES because the host directory carries an unconfined_u:object_r:user_home_t:s0 label that the container’s domain cannot write to. Add the :Z flag to relabel the mount privately for that container, or :z to share it across containers:

docker run -v /host/data:/app/data:Z myapp

Without :Z or :z the syscalls fail before your code ever runs. Check the current label with ls -lZ /host/data.

Windows file is in use by Search Indexer or OneDrive

On Windows, EACCES on unlink or rename often means another process is holding an open handle on the file. The usual culprits are Windows Search (SearchIndexer.exe), OneDrive’s reparse-point sync, and any open Explorer preview pane. Use Resource Monitor → CPU → Associated Handles and search for the path to identify the holder. Exclude the project directory from OneDrive sync and from Windows Search indexing for stable npm installs.

Immutable or append-only attributes are set

Even root cannot write to a file marked immutable. Check with lsattr:

lsattr /path/to/file
# ----i--------e-- /path/to/file   # the 'i' means immutable

Remove the flag:

sudo chattr -i /path/to/file

Backup tools and security hardening scripts sometimes set +i on configuration files. Append-only (+a) is similar — writes that are not pure appends fail with EACCES.

Process inherited a restrictive umask from a service manager

If your Node.js process runs under systemd, launchd, or supervisord with a restrictive UMask= (for example 0077), every file and directory it creates is unreadable by other users. Later steps in your pipeline that run as a different user then get EACCES on those paths. Set UMask=0022 (or 0002 for group-writable workflows) in the unit file and reload the daemon.


Related: Fix: EACCES permission denied when installing npm packages globally | Fix: bash: Permission denied | Fix: Docker Permission Denied While Trying to Connect to the Docker Daemon Socket | Fix: npm ERR! code ELIFECYCLE

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles