Error: EACCES: permission denied in three distinct situations: (1) Port binding — app.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 install — npm install -g fails because global node_modules is owned by root; fix by switching to nvm or changing the npm prefix. (3) Filesystem operations — fs.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.
Error: listen EACCES: permission denied 0.0.0.0:80Error: listen EACCES: permission denied 0.0.0.0:443npm 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:
| Field | Meaning |
|---|---|
code | Always 'EACCES' — the error type |
syscall | The OS call that failed: 'listen', 'open', 'mkdir', 'unlink', 'access', etc. |
path | The exact filesystem path that was denied (fs errors) |
address / port | The interface and port number (listen errors) |
errno | -13 on Linux/macOS — the raw POSIX error number |
All Causes at a Glance
| Cause | Typical error message | Fix summary |
|---|---|---|
| Binding to port below 1024 | listen EACCES: permission denied 0.0.0.0:80 | Reverse proxy, setcap, or iptables redirect |
| npm global prefix owned by root | EACCES: permission denied, mkdir '/usr/local/lib/node_modules' | nvm reinstall or npm config set prefix |
| File/directory owned by another user | EACCES: permission denied, open '/etc/...' | sudo chown $(whoami) /path |
| File mode bits deny access | EACCES: permission denied, open '/var/log/...' | chmod u+rw /path or chmod g+rw /path |
| Docker: files owned by root inside container | EACCES: permission denied, mkdir '/app/data' | RUN chown -R appuser:appuser /app in Dockerfile |
| SELinux or AppArmor MAC policy | Unix permissions look correct but EACCES persists | Check ausearch -m avc; adjust SELinux context or AppArmor profile |
| Volume mount overwrites in-image permissions | EACCES only when volume is mounted | Set 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
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}`);
});
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
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:
| Error | errno | Meaning | When 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');
}
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
- Read
err.code— confirm it is'EACCES', not'ENOENT'or'EPERM'. - Read
err.syscall—'listen'means a port issue;'open','mkdir','unlink'mean a filesystem issue. - For port errors: confirm
err.portis below 1024. If so, choose reverse proxy, setcap, or iptables redirect. - For npm errors: run
npm config get prefix— if it points to/usr/localor similar root-owned path, switch to nvm or change the prefix. - For filesystem errors: run
ls -la $(dirname /path/from/err)to see ownership and mode bits. - Run
whoamiandidto confirm which user your Node.js process runs as. - In Docker: run
docker exec -it <container> idandls -la /appto check the runtime user and file ownership. - If Unix permissions look correct, check SELinux:
sudo ausearch -m avc -ts recentor AppArmor:sudo journalctl -xe | grep DENIED. - Never use
sudo nodeorsudo npm install -gas a long-term fix — both create privilege problems downstream. - 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 redirect — sudo 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
ENOENT: no such file or directory— the path does not exist at all (vs. EACCES where it exists but is inaccessible)EADDRINUSE: address already in use— port binding fails because another process owns the port (vs. EACCES where the current user lacks privilege)EMFILE: too many open files— file descriptor limit exhausted; the file exists and is accessible but cannot be openedEPERM: operation not permitted— similar to EACCES but for privileged syscalls rather than DAC file permission checks; no dedicated page yet