Django Static and Media Files in Docker: Why nginx 404s
If you've wired Django and nginx together in a Docker Compose setup — Django behind Gunicorn in one container, nginx in front as reverse proxy and static file server — collectstatic runs cleanly, migrations apply, and the site loads. Then one of two things happens: an image a user uploaded last week is gone after the next deploy, or nginx returns 404 on every CSS file the moment static serving moves into its own container.
Neither is a Django bug. Both come from a detail that's easy to skip: static and media files are files on a filesystem, and in Compose, "the filesystem" is not shared between containers unless you say so.
Static vs. media: two settings, two lifecycles
Django separates these deliberately:
STATIC_ROOT— wherecollectstaticgathers your app's CSS, JS, and packaged assets. You control every byte; the content is fixed at deploy time.MEDIA_ROOT— Django's own documentation describes it as the absolute filesystem path holding user-uploaded files. Content arrives at runtime, from users, and Django enforces thatMEDIA_ROOTandSTATIC_ROOTcan't be the same path — mixing the two has real security implications.
collectstatic never touches MEDIA_ROOT. It only knows about static assets. If your mental model is "collectstatic handles the files," that model is already wrong for anything a user uploads — including images added through Django admin inlines.
Why the dev server hides the problem
In development, runserver serves static files for you automatically — but only when DEBUG=True. Django's own documentation calls this method "grossly inefficient and probably insecure" and says outright that it's unsuitable for production. Once DEBUG=False, that automatic serving stops, and nothing replaces it unless you've configured something to.
Media files never had that convenience in the first place. The static() helper some tutorials add to urls.py for serving uploads locally also works only in debug mode — it's a development shortcut, not a production plan.
So going to production isn't "turn off debug mode and ship." It's "you now own two file-serving problems Django used to hide from you."
The container detail that actually breaks things
Say your Compose setup follows the common pattern: a backend container running Django, and an nginx container in front, configured to serve /static/ and /media/ directly instead of routing every asset request through Gunicorn:
location /static/ { alias /app/staticfiles/; expires 30d; }
location /media/ { alias /app/media/; expires 7d; }This is the right idea — nginx serving files directly is much faster than proxying them through Django. But alias /app/staticfiles/ means "look in this path inside the nginx container." collectstatic wrote those files inside the backend container. Two different containers, two different filesystems. Unless something explicitly connects them, nginx's /app/staticfiles/ is just an empty directory, and every asset request 404s.
The fix is a named volume, mounted into both services:
volumes:
staticfiles:
media:
services:
backend:
volumes:
- staticfiles:/app/staticfiles
- media:/app/media
nginx:
volumes:
- staticfiles:/app/staticfiles:ro
- media:/app/media:roNow both containers see the same directory. backend writes to it, nginx only reads (:ro).
Why redeploys silently delete uploads
The 404 case is loud — it fails immediately, you notice, you fix it. The disappearing-uploads case is quieter and worse: it works for weeks, then a routine deploy wipes user data.
Docker's own documentation is direct about this: by default, files created inside a container live on a writable layer sitting on top of the read-only image layers, and that data does not persist once the container is destroyed. docker compose up --build — or any workflow that recreates the backend container — removes the old container and starts a fresh one from the image. If MEDIA_ROOT was never mounted as a volume, every file a user uploaded between the last two deploys is gone. Not archived, not moved — gone, because it only ever existed inside a container that no longer exists.
This is exactly why the media volume matters more than the staticfiles one. Losing generated CSS costs you a rebuild. Losing a user's uploaded photo costs an unhappy user, and if it was their only copy, that photo permanently.
Cache headers aren't interchangeable
The two expires values in the nginx block above aren't a stylistic choice — they reflect a real difference in how the two file types change.
Django's ManifestStaticFilesStorage appends an MD5 hash of each file's contents to its filename — styles.css becomes something like styles.55e7cbb9ba48.css. Change the file, get a new filename. That's specifically designed to let you set aggressive, long-lived cache headers safely: browsers and CDNs can cache the file "forever" because a content change produces a different URL, never the same URL with different content.
Media filenames aren't hashed by default. If a user replaces a profile photo and the URL stays /media/avatars/42.jpg, a 30-day expires header means some visitors keep seeing the old photo for up to a month. A shorter expiry on /media/ — or no long caching at all, unless you add hashing yourself — avoids that.
Permissions are the third failure mode
Less common, but worth checking if the files exist on disk and nginx still can't read them: Django's FILE_UPLOAD_PERMISSIONS setting defaults to 0o644 — world-readable — specifically so a web server running as a different user than Django can still serve the file. If a Dockerfile runs Django as a dedicated non-root user (a good practice on its own) and something in your setup tightens that default — a custom storage class, a restrictive umask, a locked-down base image — nginx's user can lose read access to a file Django's own user sees just fine. If uploads exist on disk but nginx returns 403 rather than 404, this is where to look, along with the matching FILE_UPLOAD_DIRECTORY_PERMISSIONS setting for the containing directories.
Media needs backups too, not just the database
A volume solves the "gone on redeploy" failure, but it doesn't solve "gone because a disk failed." A named volume still lives on one server. If your backup routine only covers pg_dump on the database — a common setup, and the right first step — user-uploaded files are still a single point of failure with zero copies elsewhere.
The fix is the same shape as the database backup, just aimed at a directory instead of a table: sync the media volume to object storage (or another host) on a schedule, the same way you'd cron a database dump. Unlike the database, media files rarely change once uploaded, so an incremental sync (only copying new or changed files) is usually cheap enough to run daily without a noticeable cost.
What to actually do
- Declare a named volume for
MEDIA_ROOT, mounted read-write in the backend service and read-only in nginx, before real users start uploading anything. Retrofitting it later means migrating existing files into the volume by hand. - Declare a separate named volume for
STATIC_ROOTwhenever nginx and the backend are different containers, for the same reason. - Keep static file caching aggressive — it's safe, because of content hashing — and keep media file caching shorter, unless you're hashing media filenames too.
- If files exist but aren't served, check ownership and permissions before assuming it's a missing-volume problem.
- Back up the media volume on the same kind of schedule as the database — a volume protects against a bad redeploy, not against a lost disk.
None of this is exotic. It's the same lesson as the database volume in a production Compose file: anything you can't afford to lose needs a volume, not just a working directory inside a container that will, sooner or later, be recreated.