Nginx returns 403 Forbidden when it locates the request but refuses to serve it. Unlike a 404, the path exists — something is denying access.
403 Forbidden
nginx/1.24.0
Step 1: Read the Error Log
The error log names the exact reason. Do not guess:
sudo tail -n 30 /var/log/nginx/error.log
You will see one of these:
directory index of "/var/www/html/" is forbidden, client: 203.0.113.9
open() "/var/www/html/index.html" failed (13: Permission denied), client: 203.0.113.9
access forbidden by rule, client: 203.0.113.9
Each maps to a different fix. If the log is not where you expect, see Where Are Nginx Logs Stored?.
Cause 1: directory index ... is forbidden — No Index File
Nginx was asked for a directory, found no index file, and refuses to list the contents because autoindex is off by default.
Check what is actually there:
ls -la /var/www/html/
Fix A — provide an index file. If your app's entry point is not index.html, tell nginx:
server {
root /var/www/html;
index index.php index.html index.htm;
}
Nginx tries each name in order and serves the first that exists.
Fix B — enable directory listing, only if you actually want one:
location /downloads/ {
autoindex on;
}
Be deliberate about this. A listing exposes everything in the directory — backups, dotfiles, .git, stray archives. Enable it on a purpose-built download directory, never on a general web root.
Cause 2: (13: Permission denied) — Filesystem Permissions
The nginx worker cannot read the file. This has two halves, and the second is the one people miss.
First, identify the worker user:
ps -o user= -C nginx | sort -u
www-data on Debian/Ubuntu, nginx on RHEL/Rocky/AlmaLinux. The root entry you also see is the master process — it is the worker user that matters.
Test access as that user directly:
sudo -u www-data stat /var/www/html/index.html
If that fails, you have found the problem without any guesswork.
Check the whole path, not just the file. Nginx needs execute (x) on every directory from / down to the file. One directory missing it blocks everything beneath:
namei -l /var/www/html/index.html
namei -l prints the permissions of each component in the path — the fastest way to spot the one directory at fault.
Apply correct permissions:
sudo chown -R www-data:www-data /var/www/html
sudo find /var/www/html -type d -exec chmod 755 {} \;
sudo find /var/www/html -type f -exec chmod 644 {} \;
755 for directories, 644 for files. Do not use chmod -R 777. It makes every file writable by every user and process on the box, so any compromised service can rewrite your site — and it does not fix anything 755 does not.
Serving from a home directory is a frequent trigger, because home directories are usually mode 700 and nginx cannot traverse in at all. Moving the content to /var/www or /srv is cleaner than loosening permissions on someone's home directory.
Cause 3: SELinux (RHEL, Rocky, AlmaLinux)
Permissions look perfect, sudo -u nginx stat succeeds, and you still get 403. That is SELinux.
Confirm it is enforcing and check for denials:
getenforce
sudo ausearch -m avc -ts recent | grep nginx
Look at the file labels:
ls -Z /var/www/html/
Web content needs the httpd_sys_content_t type. Files copied from /home or /tmp carry the wrong label and are denied.
Fix it durably:
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/www(/.*)?"
sudo restorecon -Rv /srv/www
semanage records the rule and restorecon applies it. Using chcon alone appears to work but is undone by the next filesystem relabel, and the error returns weeks later with no obvious trigger.
If nginx also needs to write (uploads, cache), use httpd_sys_rw_content_t for those directories only.
Do not disable SELinux to fix a labelling problem. setenforce 0 will make the 403 vanish and confirm the diagnosis, but shipping with SELinux off trades a five-minute fix for a permanently weaker server.
Cause 4: access forbidden by rule — An Explicit Deny
Something in your config is refusing on purpose. Search for it:
sudo nginx -T | grep -n -E "deny|allow|internal"
Common culprits:
location / {
deny all; # blanket block, often left from testing
}
location ~ /\. {
deny all; # blocks dotfiles — usually intentional
}
location /admin/ {
allow 192.0.2.0/24;
deny all; # IP allowlist; you are not on the list
}
nginx -T dumps the fully resolved config with every include, which is the only reliable way to find a rule buried in a file you forgot about.
Cause 5: Wrong root, or root vs alias
If root points somewhere that exists but is not your site, you can get 403 instead of 404 — nginx finds a directory, has no index for it, and refuses.
sudo nginx -T | grep -n "root\|alias"
A classic trap is mixing up root and alias in a location block:
location /static/ {
root /var/www/app; # serves /var/www/app/static/…
}
location /static/ {
alias /var/www/app/; # serves /var/www/app/… (path replaced, not appended)
}
root appends the URI to the path; alias replaces the matched prefix. Choosing the wrong one silently points nginx at a directory that is not what you meant.
Verify the Fix
sudo nginx -t
sudo systemctl reload nginx
curl -I http://127.0.0.1/ # expect 200
sudo tail -f /var/log/nginx/error.log # confirm no new 403 lines
Test as the worker user too — that is the check that actually reflects what nginx can do:
sudo -u www-data cat /var/www/html/index.html | head -1
Prevention
- Standardise on
/var/wwwor/srvrather than home directories, so permissions and SELinux labels are right by default. - Set 755/644 on deploy. Bake it into your deployment script so a
git pullor rsync from a developer's laptop cannot bring hostile permissions with it. - Never reach for
chmod 777. If 755 does not work, the problem is ownership, path traversal, or SELinux — and 777 will hide it rather than solve it. - Alert on 403 rates. A sudden burst usually means a deploy changed ownership, and the error log will have said so from the first request — see Where Are Linux System Logs Stored? for centralising it.