Troubleshooting

nginx 403 Forbidden — the five causes and how to fix each

A 403 from nginx means it found the path but will not serve it: missing index, file permissions, deny rules or SELinux. Read the log, then fix the cause.

Published ~7 min read

A 403 Forbidden from nginx means nginx found the path but refuses to serve it. The error log names the reason in one line: directory index of … is forbidden means there is no index file, (13: Permission denied) means filesystem permissions or SELinux, and access forbidden by rule means an allow/deny directive. Read the log first, fix that one cause, and reload.

quick fix
sudo tail -n 20 /var/log/nginx/error.log
# fix what the log names, then
sudo nginx -t && sudo systemctl reload nginx

What does the nginx error log say?

/var/log/nginx/error.log is the default on Debian, Ubuntu, Rocky, Alma and Alpine. A site can send its errors elsewhere with its own error_log line — sudo grep -R error_log /etc/nginx/ finds it — and the official Docker image writes to the container output, so use docker logs <container> there. Reload the page once, then match the newest line:

  • directory index of "/var/www/site/" is forbidden — cause 1, no index file.
  • open() "/var/www/site/index.html" failed (13: Permission denied) or "/home/alice/site/index.html" is forbidden (13: Permission denied) — cause 2, or cause 3 on Rocky, Alma and RHEL.
  • access forbidden by rule — cause 4, an allow/deny directive.
  • Nothing at all for that request — cause 5, the 403 is probably not nginx’s.

This is the same “log first” routine as for a 502 Bad Gateway; the difference is that a 403 is almost always about the files or the config, not the backend.

Cause 1: Is there an index file?

A request for a directory — / or /blog/ — makes nginx look for the files named in its index directive, index.html by default. If none exists and directory listings are off, which is the default, nginx answers 403 rather than 404.

check
ls -la /var/www/site/                                 # is index.html or index.php there?
sudo nginx -T | grep -nE 'root|index|autoindex|try_files'
  • root points at the wrong folder. Frameworks serve from a subdirectory — public/ for Laravel, dist/ or build/ for a front-end build. Point root there.
  • A PHP site without index.php in the list. Add it, in front: index index.php index.html;.
  • Expecting try_files $uri $uri/ =404; to give a 404. It does not for an existing directory: $uri/ matches it, nginx looks for an index file, and without one you get 403. The fix is still an index file — or, for an application with a front controller, index index.php; plus a fallback such as try_files $uri $uri/ /index.php?$query_string;.

Do not switch on autoindex on; just to make the 403 go away. It replaces the error with a public listing of every file in the directory.

Cause 2: Can nginx read the files?

nginx’s master process runs as root, but the workers that read your files run as an unprivileged user: www-data with the Debian and Ubuntu packages, nginx on Rocky, Alma and Alpine, in the packages from nginx.org and in the official Docker image. Check rather than assume:

which user reads the files?
ps -o user=,args= -C nginx         # workers show the user that matters
grep -n '^user' /etc/nginx/nginx.conf

That user needs read permission on the file and execute permission — the right to pass through — on every directory above it. One missing x anywhere in the path is enough for a 403. namei shows the whole chain at once:

check every directory on the path
namei -om /var/www/site/index.html
# f: /var/www/site/index.html
#  drwxr-xr-x root  root  /
#  drwxr-xr-x root  root  var
#  drwxr-xr-x root  root  www
#  drwxr-x--- alice alice site          <- no x for others: 403
#  -rw-r--r-- alice alice index.html

# test as the nginx user (nginx instead of www-data on Rocky, Alma, Alpine)
sudo -u www-data cat /var/www/site/index.html > /dev/null && echo readable

The classic case is a site inside /root or a home directory. New home directories on current Debian and Ubuntu are closed to other users, so nginx cannot pass through them however open the files inside are. The clean fix is to serve from /var/www or /srv, owned by your deploy user and readable by everyone:

a web root nginx can read
sudo mkdir -p /var/www/site
sudo chown -R $USER:$USER /var/www/site
find /var/www/site -type d -exec chmod 755 {} +
find /var/www/site -type f -exec chmod 644 {} +

If the site must stay in a home directory, chmod o+x /home/alice lets nginx pass through without being able to list it — workable, but every local account can then reach any file in there whose own permissions allow it. Files copied over from another server often arrive with the old owner’s IDs or restrictive modes; the shared-hosting-to-VPS migration guide covers fixing ownership after a move. In Docker, bind-mounted files must be readable by the container’s nginx user, not just by you on the host.

Cause 3: Is SELinux blocking nginx on Rocky, Alma or RHEL?

If the permissions above are correct and the log still says (13: Permission denied), look at SELinux. It is enforcing by default on Rocky, AlmaLinux and RHEL, and it lets nginx read only files that carry a web-content label such as httpd_sys_content_t. Files created in a home directory, or moved from one with mv, keep their old label — and get a 403 with perfect Unix permissions.

diagnose
getenforce                         # Enforcing?
ls -Z /srv/site                    # look for httpd_sys_content_t
sudo ausearch -m avc -ts recent    # the denials SELinux logged
label the directory permanently
sudo dnf install -y policycoreutils-python-utils     # provides semanage
sudo semanage fcontext -a -t httpd_sys_content_t '/srv/site(/.*)?'
sudo restorecon -Rv /srv/site

/var/www/html already carries the right default label, so for files moved in there, sudo restorecon -Rv /var/www/html alone is enough. chcon also changes a label, but a later full relabel undoes it; semanage plus restorecon survives. Directories the application writes to need httpd_sys_rw_content_t instead. Do not “fix” this with setenforce 0 or by disabling SELinux — that removes protection from every service on the machine to fix one directory label.

Cause 4: Is a deny rule matching?

access forbidden by rule means an allow/deny directive matched the request. Find them all in the loaded config:

find the rule
sudo nginx -T | grep -nE 'deny|allow|satisfy'
  • An allow list you are not on. allow 203.0.113.0/24; deny all; is the usual way to protect an admin path, and on phpMyAdmin a 403 from it is the protection the phpMyAdmin guide recommends doing its job. Behind a CDN or proxy, nginx sees the proxy’s address unless set_real_ip_from and real_ip_header are configured.
  • A dotfile block that also catches Let’s Encrypt. location ~ /\. { deny all; } protects .git and .env, but it also matches /.well-known/acme-challenge/, and certificate validation fails with 403. Exempt that one path (see below).
  • Basic auth. A wrong password with auth_basic returns 401, not 403. A 403 on a password-protected location comes from the allow/deny half of a satisfy block.
keep dotfiles blocked, let ACME through
location ^~ /.well-known/acme-challenge/ {
    allow all;
}
location ~ /\.(?!well-known) {
    deny all;
}

Getting the certificate itself is covered in the HTTPS step of pointing a domain at your VPS.

Cause 5: Is the 403 coming from nginx at all?

If the error log is silent, check who actually answered:

who sent the 403?
curl -sI https://example.com/path | grep -iE '^(HTTP|server)'

The HTTP header checker asks from outside your network and says when a CDN’s bot protection, not your server, gave the answer.

  • A CDN or WAF in the server header — its firewall blocked the request before it reached you. The block page looks different from nginx’s plain one, and the fix is in that service’s dashboard.
  • server: nginx, but the page is your application’s — nginx passed the request on and the app returned 403: a WordPress security plugin, a CSRF check, middleware. The access log shows the 403, the error log does not; look in the application’s logs.
  • A body that says only Access denied. — PHP-FPM refused a script whose name does not end in .php, its security.limit_extensions rule. The nginx error log shows it as FastCGI sent in stderr with has been denied (see security.limit_extensions). Fix the nginx location so only .php files reach PHP-FPM, rather than widening the list.

Whatever you changed, test before reloading — sudo nginx -t catches a typo before it takes the site down, and reload applies the change without dropping connections. On Alpine, use rc-service nginx reload.

If you would rather not manage file ownership and SELinux labels at all, managed Plesk hosting sets up each site’s user and permissions for you.

Frequently asked

Why do I get 403 instead of 404?

nginx found the path — usually a directory — but will not serve it: there is no index file and directory listing is off, the worker user cannot read it, or a rule denies it. A 404 means nothing exists at that path. The line in /var/log/nginx/error.log says which case you have.

Should I chmod 777 my web root?

No. Serving files needs only 644 on files and 755 on directories, plus execute permission on every parent directory. 777 lets any process on the server, including a compromised script, rewrite the files nginx serves. Make only upload or cache directories writable, and only for the application's user.

Why do I get 403 on Rocky Linux when the permissions look right?

SELinux. nginx may only read files labelled httpd_sys_content_t, and files created in a home directory or moved with mv keep their old label. Check with 'ls -Z', then run "semanage fcontext -a -t httpd_sys_content_t '/srv/site(/.*)?'" and 'restorecon -Rv /srv/site'. Do not disable SELinux.

Why does nginx return 403 for a directory?

The request maps to a directory that contains none of the files named in the index directive, and autoindex is off. Add index.html or index.php and list it in 'index', or fix 'root' if it points at the wrong folder. 'try_files $uri $uri/ =404' does not turn this into a 404, because '$uri/' matches the existing directory.

Related articles

All troubleshooting