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, collectstatic not 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
Reading a static fileNginx workerwww-dataDirectory pathneeds traverseFileneeds readResponse200 or 403Execute permission on every parent directory matters as much as the file mode.

Quick answer

  1. Read the exact path and error from /var/log/nginx/error.log.
  2. 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.
  3. Move static files out of home directories (for example to /srv/myapp/staticfiles), or grant traverse permission deliberately.
  4. Fix file modes: directories 755, files 644 for static assets.
  5. Confirm the alias path ends with a slash when the location does, and that the request is for a file, not a directory.
  6. On SELinux systems (RHEL, Rocky, Alma, Fedora), set the httpd_sys_content_t context.
  7. 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
one directory breaks the chaindrwxr-xr-x root   root   homedrwx------deploy deploy deploydrwxr-xr-xdeploy deploy myapp-rw-r--r--deploy deploy app.css1231No x for others: www-data cannot pass through.2Fine, but unreachable behind the home directory.3The file itself is readable.Readable files are useless behind a directory Nginx cannot traversedjango-deployment.com
Ubuntu creates new home directories as 750, which causes this.

/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

Collect static files outside home directories:

STATIC_ROOT = "/srv/myapp/staticfiles"
MEDIA_ROOT = "/srv/myapp/media"
serve static files from outside home/home/deploy/myapp/staticfiles✕home directory is private✕opening it exposes everything✕breaks on the next useradd default/srv/myapp/static✓STATIC_ROOT points here✓readable by www-data✓home stays 750Change STATIC_ROOT and rerun collectstaticdjango-deployment.com
Update the Nginx alias to the new path at the same time.
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 location and alias
alias details that cause 403Result for /static/css/app.cssalias /srv/static/;✓/srv/static/css/app.cssalias /srv/static;✕/srv/staticcss/app.css: 404URL ends in /!directory listing denied: 403A 403 on a directory URL is Nginx refusing to list it, not a permission bugdjango-deployment.com
Match the trailing slash on both the location and the alias.

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 200 with a CSS or JavaScript Content-Type
  • sudo -u www-data head -c 1 <file> succeeds for a static and a media file
  • no new Permission denied lines 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 userCan list filesCan open a known file inside
r--yesno
--xnoyes
r-xyesyes
---nono

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 777 on 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 collectstatic recreate unreadable files because of the process umask.
  • Pointing alias at the app's source static/ directory instead of STATIC_ROOT.

Next steps

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.

Keep going