Fix Django Static Files 404 in Docker

Fix Django static files returning 404 in Docker: collectstatic in the image, shared volumes between Django and Nginx, bind mounts hiding files, and WhiteNoise setup.

Problem statement

The Django app runs in Docker, pages render, but CSS, JavaScript, and admin assets return 404. The same project serves static files correctly outside Docker, or in development with runserver.

Containers add three ways for static files to go missing that do not exist on a normal server:

  • collectstatic ran in one place (a build stage, a different container, a one-off docker compose run) and its output never reached the process that serves files
  • a volume or bind mount is mounted over the directory where the files were collected, hiding them
  • Nginx runs in its own container and has no access to the Django container's filesystem

On top of that, the usual Django causes still apply: DEBUG = False with nothing serving STATIC_ROOT, a wrong STATIC_URL, or missing WhiteNoise middleware.

Static files in containersBuildcollectstaticImage layerholds the filesVolume or mountcan hide themServerWhiteNoise or NginxA 404 means the serving process is looking at a filesystem view without the files.

Quick answer

  1. Decide who serves static files: WhiteNoise inside the Django container (simplest) or an Nginx container.
  2. Run collectstatic during the image build so the files are part of the image.
  3. With WhiteNoise, add the middleware and compressed manifest storage; no volume is needed.
  4. With Nginx in a separate container, share STATIC_ROOT through a named volume populated at container start, or bake static files into the Nginx image.
  5. Remove bind mounts or volumes that cover STATIC_ROOT in production Compose files.
  6. Check from inside each container that the files exist where the serving process expects them.
  7. Verify with curl -I on a hashed asset URL.

Step-by-step solution

1. Look inside the containers

docker compose exec web python manage.py shell -c "from django.conf import settings; print(settings.STATIC_URL, settings.STATIC_ROOT)"
docker compose exec web ls -la /app/staticfiles | head
docker compose exec web python manage.py findstatic admin/css/base.css
  • If STATIC_ROOT is empty or missing inside the running container, the collected files never made it there, or something is mounted over it.
  • If findstatic fails, Django cannot find the source file, which is an INSTALLED_APPS or STATICFILES_DIRS problem rather than a Docker problem.

If Nginx serves the files, check its container too:

docker compose exec nginx ls -la /var/www/static | head

2. Collect static files during the build

FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
RUN DJANGO_SECRET_KEY=build-only python manage.py collectstatic --noinput

RUN useradd --uid 10001 --create-home app && chown -R app:app /app
USER app

CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
collect at build timeCOPY . .RUN DJANGO_SECRET_KEY=build-onlypython manage.pycollectstatic--noinputUSER app1231A throwaway value; the real key never enters the image.2Files land in STATIC_ROOT inside the image.3After collecting, so root wrote the files.Settings must not need a database for this step to workdjango-deployment.com
Every container started from the image then has the same static files.

Collecting at build time makes static files part of the immutable image, so every container from that image has exactly the assets that match its code. Running collectstatic at container start also works, but it slows startup and fails if the filesystem is read-only.

Make sure .dockerignore does not exclude your static source directories (for example a broad static/ rule meant for the output directory).

three ways to serve static filesMoving partsWatch forA: WhiteNoise✓fewestmiddleware orderB: Nginx + shared volumevolume populated on start!stale files in the volumeC: baked into Nginx imagetwo images per releasekeep both in stepWhiteNoise is the simplest fix for most setupsdjango-deployment.com
Pick one; mixing them is how 404s creep back in.

3. Option A: serve with WhiteNoise

STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    # ...
]

STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

The app container serves its own static files with proper cache headers. No shared volume, no Nginx static configuration, and rollbacks automatically use the matching assets. If Nginx still proxies to Django, it simply passes /static/ requests through like any other URL.

WhiteNoise indexes STATIC_ROOT when the app starts. Files added to STATIC_ROOT later (for example by a collectstatic in a running container) are not picked up until restart, which is another reason to collect during the build.

4. Option B: Nginx in a separate container with a shared volume

services:
  web:
    build: .
    volumes:
      - static:/app/staticfiles
    command: >
      sh -c "python manage.py collectstatic --noinput &&
             gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3"

  nginx:
    image: nginx:1.27
    volumes:
      - static:/var/www/static:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "80:80"
    depends_on:
      - web

volumes:
  static:
server {
    listen 80;

    location /static/ {
        alias /var/www/static/;
    }

    location / {
        proxy_pass http://web:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Important details:

  • Named volumes copy image content only when empty. When a new, empty named volume is first mounted at /app/staticfiles, Docker copies the files baked into the image at that path into the volume. On later deploys the volume is no longer empty, so the old files stay and new hashed files are missing. Running collectstatic at container start (as in the command above) refreshes the volume on every deploy.
  • The Nginx mount is read-only (:ro); only Django writes to the volume.
  • The web container's user must be able to write to the volume. If the image switches to a non-root user, create /app/staticfiles owned by that user in the Dockerfile before the first volume use.

5. Option C: bake static files into the Nginx image

A multi-stage build can produce a separate Nginx image containing the collected files:

# Dockerfile.nginx
FROM python:3.13-slim AS static
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN DJANGO_SECRET_KEY=build-only python manage.py collectstatic --noinput

FROM nginx:1.27
COPY --from=static /app/staticfiles /var/www/static
COPY nginx.conf /etc/nginx/conf.d/default.conf

Build both images from the same commit and deploy them together. No volumes are involved, and each release's Nginx image contains exactly its own assets. Keep previous hashed files available if old pages may still be cached by browsers; with this option they disappear when the Nginx image changes.

6. Bind mounts hiding collected files

Development Compose files often mount the source code:

    volumes:
      - .:/app
the mount covers the imagevolumes: - .:/apphost folder mounted over /app✕image’s /app/staticfiles hidden✕404 for every static fileNo code bind mount in production✓image contents used as built✓collected files visible✓dev-only mounts in an override fileA development override leaking into production is the usual causedjango-deployment.com
docker compose config shows the merged file that will really run.

This mount replaces the image's /app directory with your local checkout, including /app/staticfiles. If staticfiles/ does not exist locally (it is usually git-ignored), the files collected in the image are hidden and Django or WhiteNoise finds nothing.

Keep code bind mounts in a development override file (compose.override.yaml) and out of production Compose files, or collect static files after the container starts in development.

7. STATIC_URL and path mismatches

The URL and the served path must line up:

  • STATIC_URL = "/static/" with Nginx location /static/ { alias /var/www/static/; }
  • a request for /static/css/app.3f2a1b.css maps to /var/www/static/css/app.3f2a1b.css

Check a real URL from the rendered HTML:

curl -s http://localhost/admin/login/ | grep -o '/static/[^"]*\.css' | head -1

Then look for that exact file inside the serving container.

8. Verify

css=$(curl -s http://localhost/admin/login/ | grep -o '/static/[^"]*base[^"]*\.css' | head -1)
curl -sI "http://localhost$css" | grep -i -E "^HTTP|content-type|cache-control"
docker compose exec web ls /app/staticfiles/admin/css | head -3

Verification check:

  • the stylesheet URL from the rendered page returns 200 with text/css
  • after a new deploy with changed CSS, the new hashed filename is served
  • no production service mounts source code over /app
  • with WhiteNoise, responses for hashed files include a long Cache-Control max-age

Explanation

In Docker, the filesystem a process sees is the image plus whatever volumes and bind mounts are layered on top at runtime. Static files are generated by collectstatic, so they exist only in the layer or volume where that command ran. A 404 means the serving process is looking at a filesystem view that does not contain them. Collecting during the build and serving from the same container with WhiteNoise removes most of those layering pitfalls; separate Nginx containers work well when the files are explicitly shared or baked into the Nginx image.

Edge cases / notes

Read-only root filesystem

With read_only: true in Compose or Kubernetes, collectstatic at startup fails. Collect during the build, and mount writable volumes only for directories that need writes, such as media or temporary files.

Kubernetes

Pods cannot share a volume with an Nginx sidecar populated from the image automatically. Use an init container that copies files into an emptyDir volume, WhiteNoise, or a CDN.

Media files are different

User uploads must never be baked into images. They need a persistent volume or object storage, separate from static files.

Common mistakes

  • Running collectstatic in a one-off container whose filesystem is discarded.
  • Relying on named volumes to update automatically from new images.
  • Mounting the project directory over /app in production.
  • Excluding static source folders in .dockerignore.

Next steps

FAQ

Should collectstatic run in the Dockerfile or at container start?

In the Dockerfile, when possible. Build-time collection makes assets part of the image, speeds startup, and works with read-only filesystems. Run it at start only when a shared volume must be refreshed on each deploy.

Why do old static files keep being served from my Docker volume?

Docker copies image content into a named volume only when the volume is empty. On later deploys the volume keeps its old files. Refresh it with collectstatic at container start, or avoid volumes by using WhiteNoise or a dedicated Nginx image.

How does Nginx in another container access Django static files?

Through a shared named volume mounted into both containers, or by building an Nginx image that contains the collected files. Containers do not see each other's filesystems otherwise.

Is WhiteNoise good enough for Django static files in Docker?

For most sites, yes. It serves hashed files with long cache headers and compression, needs no shared volumes, and pairs well with a CDN in front for high traffic.

Keep going