Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for NGINX By James Joyner IV · · 10 min read

NGINX Error Guide: '403 Forbidden' permissions, index, and SELinux

Fix NGINX 403 Forbidden errors: diagnose file permissions, missing directory index, autoindex, deny rules, wrong root path, and SELinux httpd_t context denials.

  • #nginx
  • #troubleshooting
  • #errors
  • #permissions

Overview

A 403 Forbidden means NGINX found the request valid but refused to serve it: the worker process either cannot read the file/directory, was told to deny the request, or was asked to list a directory with no index and autoindex off. Unlike a 404 (not found), a 403 means the resource exists or the path is reachable — NGINX is choosing not to return it.

The error log names the precise reason:

2026/06/23 19:14:02 [error] 8101#8101: *60233 open() "/var/www/app/index.html" failed (13: Permission denied), client: 203.0.113.31, server: app.example.com, request: "GET / HTTP/1.1", host: "app.example.com"
2026/06/23 19:14:30 [error] 8101#8101: *60240 directory index of "/var/www/app/" is forbidden, client: 203.0.113.31, request: "GET / HTTP/1.1"

13: Permission denied = filesystem or SELinux. directory index ... is forbidden = no index file and autoindex off. access forbidden by rule = a deny directive. Each has a different fix.

Symptoms

  • Browser shows “403 Forbidden” and the default NGINX page.
  • error.log records Permission denied, directory index ... is forbidden, or access forbidden by rule.
  • Some paths 403 while others serve; or the whole site 403s after a content move.
  • Files exist and are readable by you, but NGINX (running as www-data/nginx) cannot read them.
curl -sI https://app.example.com/ | head -1
HTTP/1.1 403 Forbidden
sudo tail -10 /var/log/nginx/error.log
[error] 8101#8101: *60233 open() "/var/www/app/index.html" failed (13: Permission denied)

Common Root Causes

1. File or directory permissions block the worker

NGINX workers run as www-data (Debian) or nginx (RHEL). If the file is not readable, or a parent directory is not executable (searchable) by that user, you get 13: Permission denied.

sudo -u www-data stat /var/www/app/index.html
namei -l /var/www/app/index.html
drwxr-x--- root  root  /var/www/app
-rw-r----- root  root  index.html
[error] 8101#8101: *60233 open() "/var/www/app/index.html" failed (13: Permission denied)

www-data is neither owner nor in the group, and the directory lacks o+x, so the worker cannot traverse/read it.

2. Missing directory index with autoindex off

A request for a directory has no index file and autoindex is off (the default), so NGINX refuses to list it.

grep -RnE 'index|autoindex' /etc/nginx/conf.d/app.conf
ls -la /var/www/app/
index index.html index.htm;
total 8
drwxr-xr-x  app.js  styles.css   # no index.html
[error] 8101#8101: *60240 directory index of "/var/www/app/" is forbidden

No index.html exists and autoindex is off, so the directory request is forbidden.

3. Wrong root or alias path

root/alias points at a directory that does not contain the expected files (or doubles a path segment), so NGINX lands somewhere it cannot serve.

grep -RnE 'root|alias' /etc/nginx/conf.d/app.conf
location /static/ {
    alias /var/www/app/static;   # missing trailing slash -> /static path mangling
}
[error] 8101#8101: *60255 open() "/var/www/app/staticindex.html" failed (2: No such file or directory)

A misconfigured alias (missing trailing slash) mangles the path; depending on permissions this surfaces as 403 or 404. Fix the root/alias value.

4. An explicit deny rule

A deny directive (IP allowlist, or blocking dotfiles/sensitive paths) is rejecting the request by design.

grep -RnE 'deny|allow' /etc/nginx/conf.d/ /etc/nginx/sites-enabled/
location ~ /\.(?!well-known) {
    deny all;
}
allow 10.0.0.0/8;
deny  all;
[error] 8101#8101: *60270 access forbidden by rule, client: 203.0.113.31, request: "GET /.env HTTP/1.1"

access forbidden by rule means a deny matched. Verify the client/path should actually be blocked — often this is correct.

5. SELinux context wrong on the web root

On RHEL-family hosts, files served by NGINX must have the httpd_sys_content_t SELinux type. Content copied from /home or /tmp keeps the wrong label and is denied even with correct Unix permissions.

ls -Z /var/www/app/index.html
sudo ausearch -m avc -ts recent | grep nginx
unconfined_u:object_r:user_home_t:s0 /var/www/app/index.html
type=AVC msg=audit(...): avc: denied { read } for pid=8101 comm="nginx" name="index.html" scontext=system_u:system_r:httpd_t:s0 tcontext=...:user_home_t:s0
[error] 8101#8101: *60233 open() "/var/www/app/index.html" failed (13: Permission denied)

The file is labeled user_home_t, not httpd_sys_content_t; SELinux denies the read. Restore the context.

6. NGINX user mismatch (config vs reality)

Workers run as a user that does not match the file ownership scheme because user in nginx.conf was changed or differs from expectations.

grep -E '^\s*user' /etc/nginx/nginx.conf
ps -o user= -C nginx | sort -u
user nginx;
www-data
nginx
[error] 8101#8101: *60233 open() ... failed (13: Permission denied)

If the configured worker user does not own/group the files, every read is denied. Align ownership or the user directive.

Diagnostic Workflow

Step 1: Read the exact 403 reason

sudo tail -20 /var/log/nginx/error.log

Permission denied -> permissions/SELinux; directory index ... is forbidden -> index/autoindex; access forbidden by rule -> a deny directive. This selects the branch.

Step 2: Find the worker user and the served path

grep -E '^\s*user' /etc/nginx/nginx.conf
ps -o user=,comm= -C nginx | sort -u
grep -RnE 'root|alias|index' /etc/nginx/conf.d/app.conf

Note the user the workers run as and the actual root/alias for the failing location.

Step 3: Test read access as the worker user

sudo -u www-data cat /var/www/app/index.html >/dev/null && echo OK || echo DENIED
namei -l /var/www/app/index.html

namei -l shows every path component’s permissions — a single non-searchable parent directory (drwx------) breaks the whole chain.

Step 4: For a directory 403, check for an index

ls -la /var/www/app/
grep -RnE 'index|autoindex' /etc/nginx/conf.d/app.conf

Either add the expected index.html, set the correct index directive, or enable autoindex on; if a listing is intended.

Step 5: On RHEL, check SELinux; then fix and reload

ls -Z /var/www/app/
sudo ausearch -m avc -ts recent | grep nginx

Restore the context if mislabeled, fix permissions, validate, and reload:

sudo chown -R www-data:www-data /var/www/app   # or align with the worker user
sudo restorecon -Rv /var/www/app               # RHEL family
sudo nginx -t && sudo systemctl reload nginx

Example Root Cause Analysis

After deploying a new site, https://app.example.com/ returns 403 for everyone, though the files are clearly present in /var/www/app/.

The error log:

[error] 8101#8101: *60233 open() "/var/www/app/index.html" failed (13: Permission denied)

Checking the path as the worker user:

sudo -u www-data cat /var/www/app/index.html >/dev/null && echo OK || echo DENIED
namei -l /var/www/app/index.html
DENIED
drwx------ deploy deploy /var/www/app
-rw-r--r-- deploy deploy index.html

The files themselves are world-readable, but the /var/www/app directory is drwx------ owned by deploywww-data cannot traverse into it, so every open fails with permission denied.

Fix: make the directory traversable/readable by the worker user, validate, and reload:

sudo chmod 755 /var/www/app
sudo chown -R deploy:www-data /var/www/app
sudo find /var/www/app -type d -exec chmod 755 {} \;
sudo nginx -t
sudo systemctl reload nginx

The site now serves index.html with 200.

Prevention Best Practices

  • Set web-root ownership and modes deliberately: directories 755, files 644, owned by the deploy user with the NGINX worker user in the group. A single non-searchable parent directory 403s the whole tree.
  • Test new content as the worker user (sudo -u www-data cat <file>) in your deploy step, so a permission problem fails the release instead of users.
  • Always ship the expected index file (or set autoindex on; intentionally) — never rely on a directory listing you did not mean to expose.
  • On RHEL-family hosts, run restorecon -Rv after moving content into the web root so SELinux labels stay httpd_sys_content_t.
  • Keep deny/allow rules in version control and review them; an over-broad deny all is a frequent cause of “it 403s for everyone”.
  • For fast triage of a 403 wave, the free incident assistant can map the error-log reason to permissions, index, or SELinux. More fixes live in the NGINX guides.

Quick Command Reference

# Read the exact 403 reason
sudo tail -20 /var/log/nginx/error.log

# Worker user + served path
grep -E '^\s*user' /etc/nginx/nginx.conf
ps -o user=,comm= -C nginx | sort -u
grep -RnE 'root|alias|index|autoindex|deny|allow' /etc/nginx/conf.d/app.conf

# Test read as the worker user
sudo -u www-data cat /var/www/app/index.html >/dev/null && echo OK || echo DENIED
namei -l /var/www/app/index.html

# SELinux (RHEL family)
ls -Z /var/www/app/
sudo ausearch -m avc -ts recent | grep nginx
sudo restorecon -Rv /var/www/app

# Fix permissions, validate, reload
sudo chown -R deploy:www-data /var/www/app
sudo find /var/www/app -type d -exec chmod 755 {} \;
sudo nginx -t && sudo systemctl reload nginx

Conclusion

A 403 Forbidden is NGINX refusing to serve something it could reach. The usual root causes:

  1. File/directory permissions block the worker user (13: Permission denied).
  2. A directory request with no index file and autoindex off.
  3. A wrong root/alias path landing NGINX in the wrong place.
  4. An explicit deny rule (access forbidden by rule).
  5. A wrong SELinux context on the web root (RHEL family).
  6. The configured worker user not matching file ownership.

Read the error-log reason first, then test access as the worker user, fix permissions/SELinux/index as indicated, and nginx -t before reloading. Most production 403s are a non-searchable parent directory or a mislabeled SELinux context.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.