What I Was Trying to Do
I reconfigured nginx on a VPS to serve a static site, and suddenly every request — from the browser and from curl — came back with 403 Forbidden. The config was a copy of one that had been working fine the day before, so I had no idea where to even start, and I ended up rereading the same server block for nearly an hour.
$ curl -I https://example.com/
HTTP/1.1 403 Forbidden
Server: nginx/1.24.0
Date: Mon, 20 Jul 2026 09:12:03 GMT
Content-Type: text/html
Content-Length: 153
Connection: keep-alive
Environment
- OS: Ubuntu 22.04 LTS (Sakura VPS)
- nginx: 1.24.0 (Ubuntu’s stock package)
- Deploy method:
rsyncpushing a prebuilt static bundle to/var/www/example.com/html - Running as: a non-root deploy user,
deploy
What I Tried
Since 403 usually means a permissions problem, I first suspected the root path itself and ran ls -la on the directory → the file was there and looked fine → so a wrong path wasn’t the issue, which left me stuck on what actually was.
ls -la /var/www/example.com/html
total 12
drwxr-xr-x 2 deploy deploy 4096 Jul 20 09:00 .
drwxr-xr-x 3 deploy deploy 4096 Jul 19 22:10 ..
-rw-r--r-- 1 deploy deploy 612 Jul 20 09:00 index.html
Next, after confirming index.html itself was 644 and readable, I just restarted nginx and tried again — still 403. That’s when it clicked that the file’s own permissions were fine and something else in the path was blocking access.
systemctl restart nginx
curl -I https://example.com/
HTTP/1.1 403 Forbidden
Root Cause
For nginx to serve a file, it’s not enough for the file itself to be readable — every ancestor directory on the way to it needs the execute bit (x) set for whichever user nginx runs as, usually www-data. In this case the deploy script had rsynced into a path under the deploy user’s home directory, /home/deploy/www, and /home/deploy itself still had its default 750 permissions. www-data couldn’t traverse into that directory at all, so it never even reached the file. Checking only the file’s own permissions completely missed this.
The Fix
Walk the whole path with namei
namei -l /var/www/example.com/html/index.html
f: /var/www/example.com/html/index.html
drwxr-xr-x root root /
drwxr-xr-x root root var
drwxr-xr-x root root www
drwxr-xr-x deploy deploy example.com
drwxr-xr-x deploy deploy html
-rw-r--r-- deploy deploy index.html
namei -l prints the permissions and ownership of every directory from the root down to the target file in one go. In this deploy, /var/www itself was fine — the actual gap was in a separate /home/deploy/www setup used on a staging box.
Add the execute bit on the blocking directory
chmod o+x /home/deploy
(no output — confirmed with namei -l afterward: 750 became 755)
The directory execute bit (x) means “permission to enter this directory and look up entries inside it,” which is independent of the write bit. Adding just o+x is enough to let nginx traverse the directory without also letting other users write to it.
Confirm the cause in the error log
tail -n 5 /var/log/nginx/error.log
2026/07/20 09:11:58 [error] 812#812: *3 open() "/home/deploy/www/index.html" failed (13: Permission denied), client: 203.0.113.5, server: example.com, request: "GET / HTTP/1.1"
Seeing (13: Permission denied) confirms it’s a permissions issue rather than a bad path (which would show is not found instead). Checking this log first would have saved the detour of second-guessing the root path.
Where I Got Tripped Up
- I checked
index.html’s own permissions withls -la, saw they looked fine, and stopped there — completely missing that the ancestor directories mattered just as much. That cost me over 30 minutes. - The public directory lived under a home directory (
/home/deploy/www), and Ubuntu’s default750on home directories was still in place, blockingwww-datafrom reading anything under it. - I almost ran
chmod -R 755on the target directory itself, which wouldn’t have fixed anything — the blocking directory was further up the tree, so each ancestor neededo+xindividually. - I kept restarting nginx before ever looking at the error log, which just wasted time without giving me any new information.
FAQ
Q: What’s the difference between nginx 403 Forbidden and a missing index.html?
403 means nginx confirmed the file exists but was denied access to it; a missing file instead produces 404. The error log spells out which one you’re dealing with — Permission denied points to a 403-style permissions issue, while No such file or directory points to a 404-style path problem.
Q: What about the “directory index of … is forbidden” error?
That happens when autoindex is off and a directory has no index.html for nginx to fall back to, so it refuses to list the directory contents instead. That’s a missing-index-file issue, not a permissions issue — check with ls whether index.html actually exists in that directory before touching permissions.
Q: Does this happen if I keep everything under /var/www?
It’s less likely, since /var/www and its parents are usually 755 by default. The trouble mostly shows up when you serve files from inside a home directory like /home/username/..., because Ubuntu’s default home directory permission of 750 blocks www-data from traversing it. Keeping public content under /var/www avoids this entirely, and it also matches the paths most tutorials and package defaults already assume, so there’s less custom configuration to get wrong later.