Django, React, and Docker Walk Into a Bar — Only Nginx Gets Served
Documendous

Documendous @documendous

About: Documendous, the creator of DocrepoX. DocrepoX is OSS (LGPL-v3) Enterprise Content Management (ECM) and Digital Asset Management (DAM) platform for storing, organising and retrieving digital assets.

Location:
Colorado USA
Joined:
Apr 16, 2024

Django, React, and Docker Walk Into a Bar — Only Nginx Gets Served

Publish Date: May 29
0 0

Subtitle: Or, how I lost my patience debugging staticfiles in production and came out stronger for it.

So you want Django REST Framework in the back, React in the front, and everything neatly dockerized like a perfect startup pitch deck. Great. But once you drop Gunicorn, Nginx, and Vite into the pot, what you get is less a gourmet app stack and more a soup of HTTP 400s, 403s, and 404s.

I recently went through the joyless gauntlet of getting Django admin to load properly behind a React/Nginx frontend in Docker. Here’s what worked — and all the dumb mistakes I made along the way so you don’t have to.


🔧 The Setup (a.k.a. “This Should Just Work, Right?”)

Here's what I started with:

  • Django backend with Django REST Framework and gunicorn
  • React frontend built with Vite
  • Docker Compose for everything
  • Nginx acting as the single entry point in production
  • Static and media volumes shared between Django and Nginx

My Docker Compose looked like this:

services:
  web:
    build:
      context: ./magsite_backend
      dockerfile: Dockerfile.prod
    command: gunicorn magsite_backend.wsgi:application --bind 0.0.0.0:8000
    expose:
      - 8000
    volumes:
      - static_volume:/home/magsite_backend/web/staticfiles
      - media_volume:/home/magsite_backend/web/mediafiles
    env_file:
      - .env.prod
    depends_on:
      - db

  db:
    image: postgres:16
    volumes:
      - prod_postgres_data:/var/lib/postgresql/data/
    env_file:
      - .env.prod.db

  frontend:
    build:
      context: ./magsite-ui
      dockerfile: Dockerfile.prod
    ports:
      - 80:80
    depends_on:
      - web
    volumes:
      - static_volume:/home/magsite_backend/web/staticfiles
      - media_volume:/home/magsite_backend/web/mediafiles

volumes:
  static_volume:
  media_volume:
  prod_postgres_data:
Enter fullscreen mode Exit fullscreen mode

🪵 Step 1: Static Files Are Real, But Nobody Believes in Them

Everything built. Containers were up. Admin page loaded.

But CSS? Not so much. The page looked like early 2000s Craigslist.

Diagnosis: Django’s collectstatic was dumping files into STATIC_ROOT, but Nginx wasn’t serving them.

The screw-up: I had set:

STATICFILES_DIRS = [BASE_DIR / "staticfiles"]
STATIC_ROOT = BASE_DIR / "staticfiles"
Enter fullscreen mode Exit fullscreen mode

Classic rookie move. Django won’t let you collect static files into the same place it reads them from.

The fix: Nuke STATICFILES_DIRS entirely unless you actually have extra static folders outside your apps. Then:

STATIC_ROOT = BASE_DIR / "staticfiles"
Enter fullscreen mode Exit fullscreen mode

🚧 Step 2: Nginx Serves Nothing

My Nginx config had this gem:

location /static/ {
    proxy_pass http://web:8000/static/;
}
Enter fullscreen mode Exit fullscreen mode

That works great in dev. In production? Gunicorn doesn’t serve static files. It’s a web server, not your butler.

The fix: Replace that with a direct file alias:

location /static/ {
    alias /home/magsite_backend/web/staticfiles/;
}
Enter fullscreen mode Exit fullscreen mode

And while you're at it, do the same for /media/.


🔐 Step 3: Hello Darkness My Old Friend (403 CSRF Errors)

So now the admin loads and I try logging in — only to be met by Django’s infamous CSRF slap.

The issue: Nginx wasn't forwarding all the headers Django expects.

The fix: Update /admin/ and /api/ proxy blocks:

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
Enter fullscreen mode Exit fullscreen mode

Bonus fix: In settings.py, trust your reverse proxy:

CSRF_TRUSTED_ORIGINS = ['http://localhost']
Enter fullscreen mode Exit fullscreen mode

🧱 Step 4: Bad Request (400) — Django’s Favorite Passive Aggression

Turns out even if your containers are hugging each other lovingly over an internal Docker network, Django still throws a tantrum if the Host header doesn’t match what's in ALLOWED_HOSTS.

The fix:

ALLOWED_HOSTS = ['localhost', '127.0.0.1']
Enter fullscreen mode Exit fullscreen mode

Or, if you’re still debugging:

ALLOWED_HOSTS = ['*']
Enter fullscreen mode Exit fullscreen mode

Yes, it’s insecure. So is walking barefoot into prod. You’ll put shoes on later.


✅ Recap: Make It Work Every Time

Your key config checkpoints:

  • STATIC_ROOT is not in STATICFILES_DIRS
  • collectstatic writes to a volume that Nginx can access
  • ✅ Nginx uses alias for static/media, not proxy_pass
  • ✅ Nginx forwards critical headers (Host, X-Forwarded-*)
  • ALLOWED_HOSTS includes what’s actually hitting Django
  • CSRF_TRUSTED_ORIGINS includes your Nginx URL
  • ✅ Browser cache cleared (or just use Incognito)

Final Thoughts

Django and React can co-exist in Docker with Nginx, but they do so reluctantly. Like British roommates who share a flat but text each other from separate rooms.

If you're going this route for your own project or your boss's next pie-in-the-sky full-stack SPA, expect to spend some time unraveling these little devops lies.

You're welcome.


Tags:
#django #react #docker #nginx #webdev #csrf #fullstack


Comments 0 total

    Add comment