App Deployment·7 min read·

WhiteNoise vs Nginx for Python Static Files

Decide when WhiteNoise is enough for Django static files and when nginx should serve them, so your Python app stays fast without extra moving parts.

NB

Netbay Developer Relations

Netbay Engineering

On this page

Static files are not Python. CSS, JS, fonts, and collected admin assets do not belong in a Gunicorn worker. You still have two honest options on one Ubuntu VPS: let nginx serve a directory of hashed files, or let WhiteNoise serve those files from the WSGI app with caching headers. Both work. One of them is the default you should pick.

WhiteNoise exists because some hosts give you no nginx, and because Django's runserver static handling is not a production server. You have nginx. That changes the trade-off. Use WhiteNoise when you want a single process to be correct even behind a bad proxy. Use nginx when you already terminate TLS there and want Python workers free.

Collect First, Serve Second

Neither WhiteNoise nor nginx will save you from skipping collectstatic. Django's ManifestStaticFilesStorage hashes filenames so far-future cache headers are safe. Run it on deploy, as the app user, into a directory nginx can read.

bash
sudo -u app -H bash -lc "cd /srv/app; .venv/bin/python manage.py collectstatic --noinput"
ls /srv/app/staticfiles | head

STATIC_ROOT=/srv/app/staticfiles and STATIC_URL=/static/. The directory must be readable by www-data if nginx serves it, writable only by app. A collectstatic that runs as root leaves files nginx cannot read, or worse, files the next deploy cannot overwrite.

WhiteNoise wants the same collectstatic output. It reads the manifest and serves hashed names with Cache-Control: max-age=31536000, immutable. Unhashed names get a short max-age. That is the whole trick.

Nginx Alias Is the Fast Path

If nginx is already in front of Gunicorn, serve /static/ and /media/ from disk.

nginx
location /static/ {
    alias /srv/app/staticfiles/;
    access_log off;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

location /media/ {
    alias /srv/app/media/;
    access_log off;
    expires 7d;
}

alias versus root is a classic footgun. With alias, the prefix is stripped. /static/admin/css/base.css maps to /srv/app/staticfiles/admin/css/base.css. With root, nginx appends the full URI, and you would set root /srv/app/staticfiles and location /static/ plus a rewrite, or root /srv/app and location /static/ mapping to /srv/app/static/. Pick alias, put the trailing slashes on both location and alias, and nginx -t before reload.

This path never enters Python. A 2 KB CSS file should not acquire a GIL, a WSGI worker, and a database connection. On High-Speed SSD the kernel page cache keeps hot assets in RAM after the first hit. Intel Xeon Platinum cycles stay on the API.

User uploads under /media/ are not hashed. Do not stamp immutable on them. They also need a content-type map and, if they are private, a different design: X-Accel-Redirect from Django, not a public alias.

When WhiteNoise Is the Right Call

WhiteNoise is right when any of these are true:

  • You deploy the same unit behind a proxy you do not control, and you want correct cache headers anyway.
  • You have few static files and hate keeping nginx aliases in sync with Django.
  • You serve from a single Gunicorn process on a tiny box and nginx only does TLS.

Wire it in Django as middleware, with compressed files at collect time:

python
# settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
]
STORAGES = {
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}

CompressedManifestStaticFilesStorage writes .gz (and optionally .br) next to the originals. WhiteNoise then serves the precompressed file when the client accepts it, without gzipping on the fly in Python. If nginx also gzips, pick one layer. Double compression wastes CPU.

Do not use WhiteNoise for /media/ user uploads. WhiteNoise is for immutable collected assets. Uploads grow, they are not in the manifest, and they can be hostile content. Serve media from nginx or an object store you actually run; do not invent a marketplace. On one Netbay VPS, nginx alias is enough.

Mixing Them, and What to Measure

You can run both: nginx serves /static/ from disk, WhiteNoise sits in the stack as a fallback for a missed collect. That fallback hides deploy bugs. Prefer nginx-only for static, WhiteNoise-only for the rare no-proxy setup, and a 404 in logs when collectstatic was skipped.

Measure before arguing. curl -I https://app.example.com/static/admin/css/base.HASH.css should show 200 from nginx (Server: nginx) and a long max-age. If you see server: gunicorn, Python is serving CSS. That is the signal to add the location block.

A CDN in front of the Lucknow origin is optional education: the internet is global, the VPS is not. Cache hashed static at the edge if you have users far from India. The origin still needs a correct Cache-Control. WhiteNoise and nginx both can set it; only set it in one place.

L3/L4 DDoS filtering does not distinguish a CSS GET from an API POST. Rate-limit /static/ loosely. The files are public. Save tight limits for login and write endpoints.

Static files: nginx path vs WhiteNoise path browser GET /static/ nginx location /static/ disk alias (preferred) no Python, hashed files WhiteNoise in Gunicorn fallback, uses a worker collectstatic on deploy manifest + optional .gz

Takeaway

collectstatic is mandatory. nginx alias is the right default when nginx already sits in front. WhiteNoise is the right default when Python is the only HTTP server. Do not serve user media through WhiteNoise, and do not spend a worker on style.css.

You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and point nginx at STATIC_ROOT — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance