Node.js Error

Error: EACCES: permission denied

error code: EACCES

Complete reference — privileged port binding, npm global install failures, and filesystem permission errors, with every fix.

Quick Answer: Node.js throws Error: EACCES: permission denied in three distinct situations: (1) Port bindingapp.listen(80) or app.listen(443) fails because ports below 1024 require root; fix with a reverse proxy, setcap, or an iptables redirect. (2) npm global installnpm install -g fails because global node_modules is owned by root; fix by switching to nvm or changing the npm prefix. (3) Filesystem operationsfs.readFile, fs.writeFile, fs.mkdir, etc. fail because the current user lacks read/write permission; fix with chmod or chown.

What is EACCES?

EACCES stands for Error ACCESs — a POSIX system error code meaning the operating system denied the operation because the current process does not have the required permission. Node.js surfaces it as Error: EACCES: permission denied whenever the kernel's access control check fails.

The error is returned by Linux and macOS. On Windows, the equivalent is typically EPERM (operation not permitted) or EACCES itself for some operations. The integer value is errno: -13 on Linux/macOS.

Exact error messages you will see:
Error: listen EACCES: permission denied 0.0.0.0:80
Error: listen EACCES: permission denied 0.0.0.0:443
npm ERR! Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules'
npm ERR! Error: EACCES: permission denied, access '/usr/local/lib/node_modules'
Error: EACCES: permission denied, open '/etc/app/config.json'
Error: EACCES: permission denied, mkdir '/var/log/myapp'
Error: EACCES: permission denied, unlink '/root/.config/app.lock'

Full Error Examples

Port binding (listen)

Error: listen EACCES: permission denied 0.0.0.0:80
    at Server.setupListenHandle [as _listen2] (node:net:1872:16)
    at listenInCluster (node:net:1920:12)
    at Server.listen (node:net:2008:7)
    at Function.listen (/project/node_modules/express/lib/application.js:635:24)
    at Object.<anonymous> (/project/app.js:5:5) {
  code: 'EACCES',
  errno: -13,
  syscall: 'listen',
  address: '0.0.0.0',
  port: 80
}

npm global install

npm ERR! code EACCES
npm ERR! syscall mkdir
npm ERR! path /usr/local/lib/node_modules/typescript
npm ERR! errno -13
npm ERR! Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules/typescript'
npm ERR!
npm ERR! Please try running this command again as root/Administrator.

Filesystem operation

Error: EACCES: permission denied, open '/etc/nginx/nginx.conf'
    at Object.openSync (node:fs:596:3)
    at Object.readFileSync (node:fs:464:35)
    at Object.<anonymous> (/project/deploy.js:12:19) {
  errno: -13,
  code: 'EACCES',
  syscall: 'open',
  path: '/etc/nginx/nginx.conf'
}

The error object diagnostic fields:

FieldMeaning
codeAlways 'EACCES' — the error type
syscallThe OS call that failed: 'listen', 'open', 'mkdir', 'unlink', 'access', etc.
pathThe exact filesystem path that was denied (fs errors)
address / portThe interface and port number (listen errors)
errno-13 on Linux/macOS — the raw POSIX error number

All Causes at a Glance

CauseTypical error messageFix summary
Binding to port below 1024listen EACCES: permission denied 0.0.0.0:80Reverse proxy, setcap, or iptables redirect
npm global prefix owned by rootEACCES: permission denied, mkdir '/usr/local/lib/node_modules'nvm reinstall or npm config set prefix
File/directory owned by another userEACCES: permission denied, open '/etc/...'sudo chown $(whoami) /path
File mode bits deny accessEACCES: permission denied, open '/var/log/...'chmod u+rw /path or chmod g+rw /path
Docker: files owned by root inside containerEACCES: permission denied, mkdir '/app/data'RUN chown -R appuser:appuser /app in Dockerfile
SELinux or AppArmor MAC policyUnix permissions look correct but EACCES persistsCheck ausearch -m avc; adjust SELinux context or AppArmor profile
Volume mount overwrites in-image permissionsEACCES only when volume is mountedSet correct permissions on the host directory or use named volumes

Cause 1 – Privileged Port Binding (app.listen(80) or app.listen(443))

On Linux and macOS, TCP/UDP ports 0–1023 are designated as privileged ports. Only the root user (UID 0) or processes with the CAP_NET_BIND_SERVICE Linux capability can bind to them. When a non-root Node.js process calls app.listen(80) or app.listen(443), the kernel rejects the bind(2) syscall with EACCES.

// Triggers EACCES on Linux/macOS when run as a non-root user
const express = require('express');
const app = express();

app.listen(80, () => {
  console.log('Server running on port 80');
});
// Error: listen EACCES: permission denied 0.0.0.0:80

Fix A – Reverse proxy (recommended for production)

Run your Node.js app on a high port (3000, 8080) and put Nginx, Caddy, or HAProxy in front to accept connections on 80/443 and proxy them to your app. This is the correct production architecture — it also adds TLS termination, load balancing, static file serving, and rate limiting.

# Your Node.js app binds to a non-privileged port
const PORT = process.env.PORT || 3000;
app.listen(PORT);
# nginx.conf — minimal reverse proxy config
server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection 'upgrade';
        proxy_set_header   Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}
# Caddyfile — automatic HTTPS, even simpler
example.com {
    reverse_proxy localhost:3000
}

Fix B – setcap (Linux only — grants minimum capability)

The setcap utility grants a specific Linux capability to a binary without giving it full root privileges. Grant CAP_NET_BIND_SERVICE to the Node.js binary so it can bind to ports below 1024:

# Grant the capability (run once after installing/upgrading Node.js)
sudo setcap 'cap_net_bind_service=+ep' $(which node)

# Verify it was set
getcap $(which node)
# Output: /usr/bin/node = cap_net_bind_service+ep

# Your app now works without sudo
node app.js   # binds to port 80 successfully
Important: setcap is applied to the binary file itself and is lost when Node.js is upgraded (e.g., via apt upgrade, nvm use, or n). Add setcap to your server provisioning scripts or a post-install hook so it is reapplied automatically after upgrades.

Fix C – iptables redirect (Linux — redirect port 80 to 3000)

Use Linux's iptables NAT table to redirect incoming traffic on port 80 to your Node.js app's high port. Your app keeps running on port 3000; the kernel handles the redirect transparently.

# Redirect inbound port 80 to localhost:3000 (applies immediately)
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000

# Also redirect local connections (from the same machine)
sudo iptables -t nat -A OUTPUT -p tcp --dport 80 -j REDIRECT --to-port 3000

# Make the rule survive reboots (Debian/Ubuntu)
sudo apt-get install -y iptables-persistent
sudo netfilter-persistent save

Fix D – authbind (Linux — user-level privileged port access)

authbind lets a specific user bind to specific ports without root or setcap. Useful for per-user controlled access in shared environments.

# Install authbind
sudo apt-get install authbind

# Allow the current user to bind to port 80
sudo touch /etc/authbind/byport/80
sudo chown $USER /etc/authbind/byport/80
sudo chmod 755 /etc/authbind/byport/80

# Run Node.js through authbind
authbind --deep node app.js

Fix E – Change the port (simplest for development)

In development, there is no need to use port 80. Use any port above 1023 and tell your browser to include the port number in the URL.

// Read port from environment variable with a safe default
const PORT = parseInt(process.env.PORT, 10) || 3000;

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});
Never do this in production: sudo node app.js. Running your application as root means any vulnerability in your code or its dependencies can compromise the entire server. Use a reverse proxy or setcap instead.

Cause 2 – npm install -g Fails (global node_modules owned by root)

When Node.js is installed via the system package manager (apt, brew, or the official installer), the global node_modules directory is typically /usr/local/lib/node_modules or /usr/lib/node_modules, owned by root. Running npm install -g <package> as a regular user tries to write to that root-owned directory and gets EACCES.

# This triggers EACCES on systems where node was installed as root
npm install -g typescript

# npm ERR! code EACCES
# npm ERR! syscall mkdir
# npm ERR! path /usr/local/lib/node_modules/typescript
# npm ERR! Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules/typescript'

Fix A – Switch to nvm (recommended — permanent fix)

nvm (Node Version Manager) installs Node.js and npm into ~/.nvm in your home directory, which is owned by you. Global packages are installed into your home directory too — no root ever required. This is the approach the official npm documentation recommends.

# 1. Install nvm (check https://github.com/nvm-sh/nvm for the latest version)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

# 2. Reload your shell profile
source ~/.bashrc   # or ~/.zshrc, ~/.profile

# 3. Install the Node.js version you need
nvm install 20        # install Node 20 LTS
nvm use 20
nvm alias default 20  # make it the default for new shells

# 4. Verify npm prefix is now in your home directory
npm config get prefix
# Output: /home/youruser/.nvm/versions/node/v20.x.x

# 5. Global installs now work without sudo
npm install -g typescript
npm install -g nodemon

Fix B – Change the npm global prefix to a user-owned directory

If you cannot or do not want to use nvm, configure npm to use a directory in your home folder for global packages. No root access required after the initial setup.

# 1. Create a directory for global npm packages
mkdir -p ~/.npm-global

# 2. Tell npm to use it
npm config set prefix '~/.npm-global'

# 3. Add the bin directory to your PATH
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc
source ~/.bashrc

# Verify the new prefix
npm config get prefix
# Output: /home/youruser/.npm-global

# Global installs now work without sudo
npm install -g typescript

Fix C – Fix ownership of the existing global directory (chown)

If you want to keep the current npm prefix location but stop needing root, change ownership of that directory to your user. This is a valid one-time fix on a developer machine but is not appropriate for shared servers.

# Find the current global prefix
npm config get prefix
# e.g. /usr/local

# Change ownership of the npm-managed subdirectories to your user
sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules
sudo chown -R $(whoami) $(npm config get prefix)/bin
sudo chown -R $(whoami) $(npm config get prefix)/share

# Verify you can install globally now
npm install -g typescript

Fix D – sudo npm install -g (quick fix, not recommended)

Adding sudo installs the package as root and works in the short term, but creates root-owned files that will cause EACCES on every subsequent npm install, script execution, or npx invocation run as your normal user.

# Works once, but creates root-owned files — avoid as a long-term fix
sudo npm install -g typescript

# Subsequent commands will fail with EACCES because root owns the global files
npm list -g    # may fail
npx tsc --version  # may fail with EACCES on cache dir
Warning: Using sudo npm install -g is a trap. It fixes the current install but poisons the global npm installation state. If you have already done this, the cleanest recovery is to reinstall Node.js via nvm and start fresh.

Cause 3 – Filesystem Permission Denied (fs operations)

Any Node.js fs operation — readFile, writeFile, mkdir, unlink, rename, chmod, stat, open — throws EACCES when the current OS user does not have the necessary read, write, or execute permission on the target file or directory.

const fs = require('fs');

// EACCES: trying to read a file owned by root with mode 600
fs.readFile('/etc/sudoers', 'utf8', (err, data) => {
  if (err) {
    console.error(err.code);   // 'EACCES'
    console.error(err.path);   // '/etc/sudoers'
    console.error(err.syscall); // 'open'
  }
});

// EACCES: trying to write to a directory where we have no write permission
await fs.promises.mkdir('/var/log/myapp/data', { recursive: true });
// Error: EACCES: permission denied, mkdir '/var/log/myapp/data'

Fix A – Inspect and fix file ownership

# Check the current owner, group, and permissions
ls -la /path/to/file
# Example output:
# -rw------- 1 root root 1234 May 10 12:00 /path/to/file
# The first column shows permissions; the third and fourth show owner and group.

# Change the owner to your current user
sudo chown $(whoami) /path/to/file

# Change the owner recursively for a directory
sudo chown -R $(whoami) /path/to/directory

# Verify the change
ls -la /path/to/file

Fix B – Adjust file mode bits with chmod

# Add read permission for the file's owner
chmod u+r /path/to/file

# Add read+write permission for the file's owner
chmod u+rw /path/to/file

# Add write permission for the group (useful for shared app directories)
chmod g+w /path/to/directory

# Make a directory traversable (required for listing/entering it)
chmod u+x /path/to/directory

# Set explicit mode: owner rw, group r, others none (640)
chmod 640 /path/to/file

Fix C – Write to a user-owned directory instead

Rather than fighting system-owned paths, write application data to directories your user already owns: the application's own directory, $HOME, or /tmp.

const os = require('os');
const path = require('path');
const fs = require('fs');

// Good: write to the app's own directory (you own this)
const logFile = path.join(__dirname, 'logs', 'app.log');

// Good: write to the user's home directory
const configFile = path.join(os.homedir(), '.config', 'myapp', 'config.json');

// Good: write to /tmp (world-writable, but cleared on reboot)
const tempFile = path.join(os.tmpdir(), 'myapp-cache.json');

// Ensure the directory exists before writing
await fs.promises.mkdir(path.dirname(logFile), { recursive: true });
await fs.promises.writeFile(logFile, 'started\n');

Fix D – Handle EACCES gracefully in code

const fs = require('fs/promises');

async function readConfigSafe(filePath) {
  try {
    return await fs.readFile(filePath, 'utf8');
  } catch (err) {
    if (err.code === 'EACCES') {
      // Permission denied — log clearly and use fallback
      console.error(
        `Permission denied reading ${err.path}. ` +
        `Current user: ${process.env.USER}. ` +
        `Run: sudo chown ${process.env.USER} ${err.path}`
      );
      return null; // or return a default config
    }
    if (err.code === 'ENOENT') {
      return null; // file does not exist — also acceptable
    }
    throw err; // unexpected error — re-throw
  }
}

Fix E – Docker: fix file ownership in the image

When a Docker container runs as a non-root user (via USER instruction or docker run --user) but files were COPY-ed in as root, the runtime user cannot access them.

# Dockerfile — correct pattern
FROM node:20-alpine

# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

# Transfer ownership BEFORE switching user
RUN chown -R appuser:appgroup /app

# Switch to non-root user for runtime
USER appuser

CMD ["node", "src/index.js"]
# docker-compose.yml — if a volume mount overwrites /app permissions,
# use a named volume for the data directory so it inherits image permissions
services:
  app:
    image: myapp
    volumes:
      - app_data:/app/data    # named volume — inherits image ownership
      # NOT: - ./data:/app/data  (bind mount inherits host ownership)

volumes:
  app_data:

Fix F – SELinux / AppArmor denials

On systems with Mandatory Access Control (SELinux on RHEL/CentOS/Fedora, AppArmor on Ubuntu/Debian), EACCES can be returned even when standard Unix file permissions look correct. The MAC layer enforces additional policy rules on top of DAC.

# SELinux: check for recent Access Vector Cache (AVC) denials
sudo ausearch -m avc -ts recent

# Example denial output:
# type=AVC msg=audit(1715000000.000:123): avc:  denied  { write }
#   for pid=12345 comm="node" name="app.log"
#   scontext=system_u:system_r:init_t:s0
#   tcontext=unconfined_u:object_r:var_log_t:s0 tclass=file

# Fix the SELinux file context to match what Node.js needs:
sudo chcon -t var_log_t /var/log/myapp/app.log

# Or generate and apply a custom policy module from the denial:
sudo ausearch -m avc -ts recent | audit2allow -M mypolicy
sudo semodule -i mypolicy.pp

# AppArmor: check for denials
sudo journalctl -xe | grep DENIED
# or
sudo dmesg | grep apparmor | grep DENIED

# Temporarily put the profile in complain mode to diagnose:
sudo aa-complain /etc/apparmor.d/usr.bin.node

EACCES vs EPERM

Both EACCES and EPERM indicate a permission failure. The distinction is at the kernel level:

ErrorerrnoMeaningWhen you see it
EACCES -13 Standard DAC (Discretionary Access Control) check failed — the Unix file permission bits or ACL denied access Reading a file you don't own, binding to a privileged port, writing to a read-only directory
EPERM -1 The operation itself is not permitted for this process, regardless of file ownership — often a privileged syscall Calling chown as non-root, setting network interface flags, MAC policy denial in some kernels, Windows permission errors

In practice: if you see EACCES, check ls -la and fix ownership or mode bits first. If permissions look correct and you still get an error, suspect SELinux, AppArmor, or a privileged operation — both may surface as EPERM in some kernel configurations.

Safe Permission Patterns

Always check err.code before reacting

const fs = require('fs/promises');

async function safeWrite(filePath, content) {
  try {
    await fs.writeFile(filePath, content, 'utf8');
  } catch (err) {
    switch (err.code) {
      case 'EACCES':
        throw new Error(
          `Permission denied writing to ${err.path}. ` +
          `Check file ownership and mode bits.`
        );
      case 'ENOENT':
        // Parent directory missing — create it and retry
        await fs.mkdir(require('path').dirname(filePath), { recursive: true });
        await fs.writeFile(filePath, content, 'utf8');
        break;
      default:
        throw err;
    }
  }
}

Check access before operating (non-atomic — informational only)

const fs = require('fs/promises');
const { constants } = require('fs');

async function checkWritable(filePath) {
  try {
    await fs.access(filePath, constants.W_OK);
    return true;
  } catch {
    return false;
  }
}

if (await checkWritable('/path/to/file')) {
  await fs.writeFile('/path/to/file', data);
} else {
  console.error('No write permission on /path/to/file — check ownership');
}
Note: fs.access() is subject to TOCTOU (time-of-check time-of-use) race conditions. For robust code, use a try/catch around the actual operation rather than checking first.

Run Node.js as the correct user in production (systemd)

# /etc/systemd/system/myapp.service
[Unit]
Description=My Node.js App
After=network.target

[Service]
Type=simple
User=appuser          # run as a dedicated non-root user
Group=appgroup
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node /opt/myapp/src/index.js
Restart=on-failure
RestartSec=5

# Let systemd provide the privileged port via socket activation,
# or use a reverse proxy and keep PORT above 1024
Environment=PORT=3000
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target

Debugging Checklist

  1. Read err.code — confirm it is 'EACCES', not 'ENOENT' or 'EPERM'.
  2. Read err.syscall'listen' means a port issue; 'open', 'mkdir', 'unlink' mean a filesystem issue.
  3. For port errors: confirm err.port is below 1024. If so, choose reverse proxy, setcap, or iptables redirect.
  4. For npm errors: run npm config get prefix — if it points to /usr/local or similar root-owned path, switch to nvm or change the prefix.
  5. For filesystem errors: run ls -la $(dirname /path/from/err) to see ownership and mode bits.
  6. Run whoami and id to confirm which user your Node.js process runs as.
  7. In Docker: run docker exec -it <container> id and ls -la /app to check the runtime user and file ownership.
  8. If Unix permissions look correct, check SELinux: sudo ausearch -m avc -ts recent or AppArmor: sudo journalctl -xe | grep DENIED.
  9. Never use sudo node or sudo npm install -g as a long-term fix — both create privilege problems downstream.
  10. After applying setcap, verify with getcap $(which node) and test with a non-root user.

Frequently Asked Questions

What is EACCES in Node.js?

EACCES stands for Error ACCESs — a POSIX system error code meaning the OS denied the requested operation because the current user lacks the required permission. Node.js surfaces it as Error: EACCES: permission denied when binding to privileged ports (below 1024), when npm install -g cannot write to a root-owned global directory, or when any fs operation targets a file/directory the current user cannot access.

How do I fix Error: listen EACCES: permission denied 0.0.0.0:80?

Three production-safe options: (1) Reverse proxy — run your app on port 3000 and let Nginx/Caddy listen on 80 and forward traffic; (2) setcap on Linux — sudo setcap 'cap_net_bind_service=+ep' $(which node); (3) iptables redirectsudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000. Never run Node.js as root in production.

How do I fix npm ERR! EACCES: permission denied when running npm install -g?

The recommended fix is to switch to nvm: install nvm from github.com/nvm-sh/nvm, then run nvm install 20 && nvm use 20. nvm installs Node.js into your home directory so global packages never require root. Alternative: run mkdir -p ~/.npm-global && npm config set prefix '~/.npm-global' and add ~/.npm-global/bin to your PATH. Avoid sudo npm install -g — it creates root-owned files that break future commands.

How do I fix EACCES: permission denied on a file or directory?

Run ls -la /path/to/file to see the owner and permissions. Then either: (1) take ownership: sudo chown $(whoami) /path/to/file; (2) add permissions: chmod u+rw /path/to/file; (3) move your write target to a directory you already own (the app directory, home directory, or /tmp). If permissions look correct but EACCES persists, check SELinux or AppArmor audit logs.

Is it safe to use sudo node or sudo npm install -g to fix EACCES?

No. Running sudo node app.js means any vulnerability in your code or dependencies can compromise the entire server. Running sudo npm install -g installs root-owned files that break all subsequent npm commands run as your normal user. Use nvm, npm prefix configuration, reverse proxy, or setcap instead — these solve the permission problem without creating security or maintenance risks.

What is the difference between EACCES and EPERM in Node.js?

EACCES (errno -13) means the standard Unix DAC permission check failed — the file mode bits or directory ACL denied access. EPERM (errno -1) means the operation itself is not permitted regardless of file ownership — typically a privileged syscall (e.g., chown as non-root) or a MAC (SELinux/AppArmor) denial. If you see EACCES, fix ownership and mode bits. If you see EPERM with correct-looking permissions, investigate MAC policies or privileged syscall requirements.

Why does EACCES happen in Docker or CI but not locally?

Common causes: (1) The container runs as a non-root user but files were copied into the image as root — fix with RUN chown -R appuser:appgroup /app in your Dockerfile after the COPY steps; (2) a bind-mount volume overwrites a directory that had correct in-image permissions with a host directory owned by root; (3) CI clones the repo as a different user than owns the cached node_modules layer. Always run id and ls -la inside the container to confirm the runtime user and file ownership.

How do I grant Node.js permission to bind to port 80 without sudo?

On Linux, use setcap: sudo setcap 'cap_net_bind_service=+ep' $(which node). This grants only the specific capability needed to bind to privileged ports without full root access. Verify with getcap $(which node). Re-apply this command after every Node.js upgrade since setcap is applied to the binary file. Alternatively, configure an iptables redirect or use a reverse proxy.

How do I fix EACCES: permission denied on macOS?

For port binding on macOS: macOS does not support setcap; use a reverse proxy (Nginx, Caddy) or pfctl port forwarding (echo "rdr pass on lo0 proto tcp from any to any port 80 -> 127.0.0.1 port 3000" | sudo pfctl -ef -). For npm global installs: switch to nvm (brew install nvm) or set a custom npm prefix. For filesystem EACCES: use ls -la and chown/chmod exactly as on Linux. macOS does not use SELinux, but may use System Integrity Protection (SIP) which prevents modification of system-owned paths — move your data to user-owned directories instead.

Related Errors