Fix Nginx 403 Forbidden on Django Static Files
Fix 403 Forbidden for Django static and media files served by Nginx: directory traversal permissions, file modes, alias paths, SELinux contexts, and verification.
Problem statement
Django pages load, but CSS, JavaScript, images, or uploaded media return 403 Forbidden. Nginx serves these files directly from disk, without Django, so a 403 here is a filesystem or Nginx configuration problem, not a Django permission issue.
The Nginx error log usually contains one of these lines:
open() "/home/deploy/myapp/staticfiles/css/app.css" failed (13: Permission denied)
"/srv/myapp/staticfiles/" is forbidden (13: Permission denied)
directory index of "/srv/myapp/staticfiles/" is forbidden
This is different from:
- 404 Not Found: the path Nginx computed does not exist (wrong
alias,collectstaticnot run) - Admin loads without styling but files return 200: often a MIME type or Content Security Policy problem
- 403 from Django itself: CSRF failures or permission-denied views, which return HTML pages, not static file responses
Quick answer
- Read the exact path and error from
/var/log/nginx/error.log. - Check permissions along the whole path with
namei -l <file>; the Nginx user needs execute (x) on every directory and read (r) on the file. - Move static files out of home directories (for example to
/srv/myapp/staticfiles), or grant traverse permission deliberately. - Fix file modes: directories
755, files644for static assets. - Confirm the
aliaspath ends with a slash when thelocationdoes, and that the request is for a file, not a directory. - On SELinux systems (RHEL, Rocky, Alma, Fedora), set the
httpd_sys_content_tcontext. - Reload Nginx and verify with
curl -I.
Step-by-step solution
1. Find the failing path in the error log
curl -sI https://www.example.com/static/css/app.css | head -1
sudo tail -n 20 /var/log/nginx/error.log | grep -E "forbidden|Permission denied"
The error log shows the exact filesystem path Nginx tried to open, which tells you whether the path mapping is right before you look at permissions.
2. Identify the Nginx worker user
grep -E "^\s*user" /etc/nginx/nginx.conf
ps -o user,cmd -C nginx
On Debian and Ubuntu the workers run as www-data; on RHEL-family systems as nginx. The master process runs as root, but file access happens in the workers.
3. Check every directory on the path
Permission to read a file requires execute permission on every parent directory. namei shows the whole chain:
namei -l /home/deploy/myapp/staticfiles/css/app.css
Example output revealing the problem:
f: /home/deploy/myapp/staticfiles/css/app.css
drwxr-xr-x root root /
drwxr-xr-x root root home
drwx------ deploy deploy deploy
drwxr-xr-x deploy deploy myapp
drwxr-xr-x deploy deploy staticfiles
drwxr-xr-x deploy deploy css
-rw-r--r-- deploy deploy app.css
/home/deploy has mode 700, so www-data cannot enter it, even though every file below is world-readable. Recent Ubuntu releases create home directories with mode 750 by default, which blocks other users in the same way.
Test directly as the Nginx user:
sudo -u www-data stat /home/deploy/myapp/staticfiles/css/app.css
sudo -u www-data head -c 100 /home/deploy/myapp/staticfiles/css/app.css
4. Fix: move static files to a served location (recommended)
Collect static files outside home directories:
STATIC_ROOT = "/srv/myapp/staticfiles"
MEDIA_ROOT = "/srv/myapp/media"
sudo install -d -o deploy -g deploy -m 755 /srv/myapp /srv/myapp/staticfiles
sudo install -d -o deploy -g www-data -m 775 /srv/myapp/media
python manage.py collectstatic --noinput
Update Nginx:
location /static/ {
alias /srv/myapp/staticfiles/;
}
location /media/ {
alias /srv/myapp/media/;
}
This keeps home directories private and makes the web-served tree explicit.
5. Alternative: grant traverse permission only
If files must stay under a home directory, grant execute (traverse) permission without read permission, which lets Nginx pass through the directory without listing it:
sudo chmod o+x /home/deploy
Or, more narrowly, with an ACL for the Nginx user:
sudo setfacl -m u:www-data:x /home/deploy
Anyone who knows a file's exact path can still reach it through the traversed directory, so do not store secrets with world-readable modes below that directory.
6. Fix file and directory modes
collectstatic creates files according to the process umask. A restrictive umask (for example 077 in a hardened shell profile or systemd unit) produces files Nginx cannot read. Django also has FILE_UPLOAD_PERMISSIONS and FILE_UPLOAD_DIRECTORY_PERMISSIONS settings for uploaded media.
Fix existing files:
sudo find /srv/myapp/staticfiles -type d -exec chmod 755 {} +
sudo find /srv/myapp/staticfiles -type f -exec chmod 644 {} +
For uploads, set explicit modes so new files are readable by the web server:
FILE_UPLOAD_PERMISSIONS = 0o644
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o755
7. Check alias and directory requests
A 403 with directory index of ... is forbidden means the request resolved to a directory, and autoindex is off (which is correct). Causes:
- requesting
/static/itself or a directory URL - a mismatched trailing slash between
locationandalias
Correct pairing:
location /static/ {
alias /srv/myapp/staticfiles/;
}
With location /static/ and alias /srv/myapp/staticfiles (no trailing slash), a request for /static/css/app.css maps to /srv/myapp/staticfilescss/app.css, which usually produces a 404, but combinations with try_files or index can produce 403.
Also check for leftover access rules such as deny all; in an included snippet that matches the static path.
8. SELinux on RHEL-family systems
If permissions look correct but the log still says Permission denied, check SELinux:
getenforce
sudo ausearch -m avc -ts recent | grep nginx
ls -Z /srv/myapp/staticfiles | head
Label the directories as web content:
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/myapp/staticfiles(/.*)?"
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/srv/myapp/media(/.*)?"
sudo restorecon -Rv /srv/myapp/staticfiles /srv/myapp/media
Use httpd_sys_content_t for read-only content and httpd_sys_rw_content_t where the application writes. semanage comes from the policycoreutils-python-utils package. Avoid disabling SELinux to solve a labeling problem.
9. Reload and verify
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://www.example.com/static/css/app.css | head -3
curl -sI https://www.example.com/static/admin/css/base.css | head -3
Verification check:
- static files return
200with a CSS or JavaScriptContent-Type sudo -u www-data head -c 1 <file>succeeds for a static and a media file- no new
Permission deniedlines in the Nginx error log - a newly uploaded media file is readable without manual permission changes
Explanation
Nginx workers run as an unprivileged user and access files like any other process. Unix permissions require execute permission on each directory in a path and read permission on the file itself. Deploying from a user's home directory, with modern default home directory modes, is the most common reason static files return 403 after an otherwise successful setup. SELinux adds a second, independent check based on file labels, which explains 403 responses that persist even with correct modes.
Edge cases / notes
Static files served by WhiteNoise instead
If WhiteNoise serves static files, the Gunicorn process user needs read access, and Nginx should not have a /static/ location pointing at the wrong directory. Remove or fix the Nginx location to avoid it shadowing WhiteNoise.
Docker volumes
When Nginx and Django run in separate containers sharing a volume, the numeric UID and GID matter, not names. Make files world-readable or align UIDs between containers.
Private media
Some uploads must not be public. Do not "fix" a 403 on private files by making them world-readable through Nginx; serve them through a Django view that checks permissions, optionally using X-Accel-Redirect.
A permission check script for deploys
Permission problems tend to come back after deploys, OS upgrades, or a change of the user that runs collectstatic. A short script run at the end of each deploy catches them before users do:
#!/bin/bash
# check-static-permissions.sh
set -euo pipefail
WEB_USER="${WEB_USER:-www-data}"
STATIC_ROOT="${STATIC_ROOT:-/srv/myapp/staticfiles}"
MEDIA_ROOT="${MEDIA_ROOT:-/srv/myapp/media}"
status=0
for path in "$STATIC_ROOT" "$MEDIA_ROOT"; do
if ! sudo -u "$WEB_USER" test -x "$path"; then
echo "FAIL: $WEB_USER cannot enter $path"
namei -l "$path"
status=1
fi
done
unreadable=$(sudo -u "$WEB_USER" find "$STATIC_ROOT" -type f ! -readable 2>/dev/null | head -5)
if [ -n "$unreadable" ]; then
echo "FAIL: unreadable static files for $WEB_USER:"
echo "$unreadable"
status=1
fi
sample=$(find "$STATIC_ROOT" -type f -name "*.css" | head -1)
if [ -n "$sample" ]; then
url_path="/static/${sample#"$STATIC_ROOT"/}"
code=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1${url_path}" -H "Host: www.example.com")
echo "HTTP $code for $url_path"
[ "$code" = "200" ] || status=1
fi
exit $status
It checks traversal of the top-level directories, readability of every static file as the web server user (find -readable evaluates access for the user running find), and one real request through Nginx. Wire it into the deploy so a non-zero exit fails the release.
Understanding traverse versus read permissions
Directory permissions are easy to misread:
| Directory mode for Nginx user | Can list files | Can open a known file inside |
|---|---|---|
r-- | yes | no |
--x | no | yes |
r-x | yes | yes |
--- | no | no |
Nginx never needs to list static directories (autoindex should stay off); it only needs to open known paths. That is why o+x on a parent directory is enough, and why a directory with r but no x still produces 403.
Common mistakes
- Running
chmod -R 777on the project directory, which makes code and configuration writable by any process. - Changing file modes but not directory modes, leaving a directory without execute permission.
- Fixing permissions once and having the next
collectstaticrecreate unreadable files because of the process umask. - Pointing
aliasat the app's sourcestatic/directory instead ofSTATIC_ROOT.
Next steps
- If files return
404instead, see Django Static Files Not Loading in Production. - For unstyled admin pages specifically, see Fix Django Admin CSS Missing in Production.
- Review locations and headers in How to Configure Nginx for Django in Production.
- For write errors on uploads, see Fix Permission Denied on Django Media Uploads.
FAQ
Why does Nginx return 403 for Django static files when the files are world-readable?
A parent directory, often a home directory with mode 700 or 750, does not grant execute permission to the Nginx user. Check the full path with namei -l.
Should Django static files live in the home directory?
It is better to collect them into a dedicated directory such as /srv/myapp/staticfiles. It avoids opening home directories and makes the served files explicit.
How do I fix Nginx 403 errors caused by SELinux?
Label the static directory with httpd_sys_content_t using semanage fcontext and apply it with restorecon. Use httpd_sys_rw_content_t for directories the app writes to.
What does "directory index is forbidden" mean for static files?
The request resolved to a directory rather than a file, and directory listing is disabled. Check the requested URL and the trailing slashes in location and alias.