nginx 403 Forbidden: Diagnose and Fix the 6 Root Causes in 5 Minutes
"Why is a page that was working a minute ago returning 403?"
You just finished a deploy and the browser shows 403 Forbidden. A lot of people reflexively blast chmod 777 at this point. If you're lucky it unblocks, but you've only left a security hole—and the next time 403 shows up, you'll wander the same maze.
The key point first: nginx 403 is not just a permissions problem. There are at least six causes, and each leaves a different string in error_log. The right approach is not guessing at permissions—it's pinpointing the cause from a single log line.
Don't panic. Look at the logs first.
# run — check
sudo tail -n 30 /var/log/nginx/error.logGet oriented in 5 seconds: branch on the error_log string
When you open the log, you'll usually see one of the three strings below. That's your diagnostic branch point.
| Representative error_log string | Likely cause |
|---|---|
directory index of "/var/www/html/" is forbidden | ② Missing index file (autoindex off) |
access forbidden by rule | ⑤ deny rule |
Permission denied (errno 13) | ① File/directory permissions or ④ SELinux |
Real log samples look like this.
2026/06/14 10:21:33 [error] 812#812: *5 directory index of "/var/www/html/" is forbidden, client: 10.0.0.5 ...
2026/06/14 10:24:01 [error] 812#812: *7 access forbidden by rule, client: 10.0.0.5 ...
2026/06/14 10:27:18 [error] 812#812: *9 open() "/var/www/html/index.html" failed (13: Permission denied) ...Seeing Permission denied but permissions look fine? Then it's almost certainly SELinux. (Especially common on RHEL/CentOS/Rocky families.)
Classification table: the 6 causes of 403
| # | Cause | error_log string | One-line diagnostic command | Core fix |
|---|---|---|---|---|
| ① | File/directory permissions | Permission denied (13) | namei -l /var/www/html/index.html | Directories 755, files 644, matching owner |
| ② | Missing index | directory index ... is forbidden | ls -la /var/www/html | Place an index file or autoindex on |
| ③ | Misconfigured root/alias path | open() ... No such file or 403 | nginx -T | grep -E 'root|alias' | Check the path; fix alias trailing slashes |
| ④ | SELinux context | Permission denied (13) | getenforce + ausearch -m avc | chcon -t httpd_sys_content_t |
| ⑤ | deny rule | access forbidden by rule | nginx -T | grep -E 'deny|allow' | Revisit allow/deny order and target IPs |
| ⑥ | try_files / location conflict | (403 with no explicit error) | Review location blocks with nginx -T | Sort out try_files and location precedence |
Copy-paste diagnostic and fix commands
ls -la /var/www/html # file/ · check
namei -l /var/www/html/index.html # path
sudo chown -R www-data:www-data /var/www/html # Debian/Ubuntu (RHEL nginx:nginx)
sudo chmod -R 755 /var/www/html # (file after 644 )
getenforce # SELinux check (Enforcing )
sudo chcon -R -t httpd_sys_content_t /var/www/html # nginx
sudo ausearch -m avc -ts recent # SELinux (AVC) check
nginx -t && systemctl reload nginx # settings after⚠️ Owner caveat: Debian/Ubuntu uses
www-data; RHEL/CentOS/Rocky usesnginxas the default worker user. Confirm the actual worker user withps aux | grep nginx.
Practical fixes by scenario
Even for the same 403, the likely causes differ by how you serve content.
Static sites — ② and ⑥ are common
server {
root /var/www/html;
index index.html;
location / {
try_files $uri $uri/ =404; # index when 403 404
}
}If try_files includes $uri/, nginx tries directory auto-lookup and returns 403 when there's no index. Explicitly using =404 makes the intent clear.
Reverse proxy — missing location match
location /api/ {
proxy_pass http://127.0.0.1:8080/;
}
# location / root 403/404If you get a 403 on a reverse proxy, it may be a 403 returned by the backend (upstream), not nginx. If error_log has no nginx-specific string, suspect the upstream response.
PHP-FPM — try_files + fastcgi pattern
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}If you omit try_files $uri =404, a 403 can occur for PHP paths that don't exist.
A field note: these days, containers are the real trap
The #1 cause of the recent surge in 403 tickets in the field is, surprisingly, UID/GID mismatch on Docker/k8s volume mounts. Host files owned by 1000:1000 can't be read by nginx (UID 101) inside the container, so you get Permission denied. In that case, check the worker UID with id inside the container before you chmod on the host. And on RHEL-family systems, setenforce 0 because "just turn off SELinux" is only a stopgap. Fixing the context is the right answer, and if you codify permissions and context with Ansible, recurrence almost disappears.
5-minute diagnostic checklist
- Check the last 30 lines of
error_log→ branch on the string index/directorystring → autoindex or place an indexaccess forbidden by rule→ inspect deny/allow orderPermission denied→ trace path permissions withnamei -l- Denied even though permissions look fine → check SELinux with
getenforce+ausearch - Finish with
nginx -t && systemctl reload nginx
Related reading: if uploads are blocked, see 413 Request Entity Too Large; if the backend connection drops, see the 502/504 Gateway troubleshooting guide.
References: official docs
The primary source for the behavior, settings, and errors covered in this post is the official documentation below. Check it for version-specific options and exact behavior.
Frequently asked questions (FAQ)
Q. It was fixed with chmod 777 — why shouldn't I use that? A. Anyone can read, write, and execute, so the risk of webshell upload and tampering is high. The standard is 755 for directories and 644 for files; grant write only on directories that actually need it.
Q. File permissions are all correct, but I still get 403.
A. It's often blocked at a parent directory (e.g. a home directory with 700). Tracing the full path with namei -l /home/user/site/index.html shows in one line which hop is blocking. For the nginx worker to reach the end of the path, every parent directory needs execute (x) permission.
Q. Can't I just turn SELinux off?
A. setenforce 0 drops an entire security layer. On RHEL-family systems the recommended path is to stay Enforcing: fix only the context with chcon -t httpd_sys_content_t, and make it persistent with semanage fcontext.
Q. Why does a trailing slash on alias flip between 403 and 404?
A. alias substitutes the location path, so slash alignment matters. Match slashes on both sides, as in location /img/ { alias /data/img/; }. If only one side has the slash, the path goes wrong and you get 403/404.
Q. I'm on a reverse proxy and getting 403 — is it an nginx problem?
A. Not necessarily. nginx may have proxy_passed correctly and the backend (upstream) returned 403. If error_log has no nginx-specific string, check the backend application logs.
Sources and checks vary by article. Only articles with a recorded check date, scope and sources below were checked, and only within that scope. For other articles, verify against the current docs for your version before applying anything, and email us about errors. Editorial policy · email us
This article has no recorded human check (date and scope) yet.
Comments
Be the first to comment.