Musa Yılmaz
·5 dk okuma

Next.js + Django'yu Aynı Origin'de, CORS Derdi Olmadan

nextjsdjangonginxdevops

Next.js frontend'i ile Django backend'ini ayrı servisler olarak çalıştırdığınızda karşınıza hemen CORS (Cross-Origin Resource Sharing) sorunu çıkar. localhost:3000 üzerinde çalışan frontend, localhost:8000'deki API'ye istek attığında tarayıcı bunu farklı bir origin olarak görür ve ekstra header yapılandırması gerektirir. Production'da bu daha da karmaşıklaşır: CORS ayarları, cookie'lerin SameSite davranışı, ve iki ayrı domain/subdomain yönetimi.

Çözüm: Tek origin, nginx ile

Bir müşteri projesi için kurduğum mimaride bu sorunu kökten çözdüm: nginx'i tek giriş noktası yaparak hem Next.js'i hem Django'yu aynı origin üzerinden servis ettim. Aşağıdaki blok fikrin sadeleştirilmiş hâli; gerçek yapılandırmada daha fazlası var, aşağıda anlatıyorum.

server {
    listen 80;
 
    location /api/ {
        proxy_pass http://backend:8000;
        proxy_set_header Host $host;
    }
 
    location /custom-admin-path/ {
        proxy_pass http://backend:8000;
    }
 
    location / {
        proxy_pass http://frontend:3000;
    }
}

Tarayıcı açısından tek bir domain var: /api ile başlayan her şey Django'ya, admin path'i Django admin paneline, geri kalan her şey Next.js'e gidiyor. CORS header'ı yazmaya hiç gerek kalmıyor çünkü tarayıcı zaten hepsinin aynı origin'den geldiğini görüyor.

Production yapılandırması ne ekliyor

Gerçek bir production yapılandırması yukarıdaki taslaktan önemli noktalarda ayrılır:

  • TLS nginx'te sonlanıyor. 80. port yalnızca sertifika doğrulamasına cevap verir ve HTTPS'e yönlendirir. Her şeyi tek sertifika kapsar; "ikinci SSL sertifikası yok" kazancı buradan geliyor.
  • Proxy'lenen her location'da forwarded header'lar. Host, X-Real-IP, X-Forwarded-For ve X-Forwarded-Proto backend'e iletiliyor. Django'nun isteğin aslında HTTPS olduğunu anlaması için sonuncusuna ihtiyacı var.
  • Statik ve medya dosyaları iki uygulamayı da atlıyor. Django'nun topladığı statik dosyaları ve yüklenen medyayı nginx paylaşımlı volume'lerden uzun cache header'larıyla doğrudan servis ediyor. Next'in /_next/static/ altındaki hash'li dosyaları da immutable cache header'ı alıyor.
  • Proxy'de rate limit. nginx'in limit_req'i farklı location'lara farklı limit verebilir. Bunu yalnızca kaba bir sel bariyeri olarak görün; gerçek kötüye kullanım koruması, ihtiyaç duyan uçlardaki uygulama seviyesi throttle'larda olmalı (DRF'te hazır geliyor).
  • Upstream blokları her servisi bir kez adlandırır; her location ona isimle başvurur.

Yerel geliştirme için tek HTTP portu üzerinde çalışan ayrı, daha sade bir nginx yapılandırması tutmak da işe yarar; böylece orada da tarayıcı tek origin görür.

Auth cookie'lerinde tek origin'in karşılığı alınıyor

Tek origin'i bir CORS kısayolundan fazlası yapan kısım burası. Django backend'i JWT'lerle (örneğin rest_framework_simplejwt) doğrulama yapıp token'ları localStorage yerine httpOnly cookie içinde taşıyabilir. Tarayıcı tek origin gördüğü için düz bir SameSite=Lax cookie yetiyor: SameSite=None yok, withCredentials uğraşı yok, cookie domain hesabı yok.

# sadeleştirilmiş örnek
response.set_cookie(
    ACCESS_COOKIE_NAME,
    access,
    path="/",
    httponly=True,
    secure=COOKIE_SECURE,
    samesite="Lax",
)
response.set_cookie(
    REFRESH_COOKIE_NAME,
    str(refresh),
    path="/<auth-uclari>/",   # yalnızca refresh/logout'a gider
    httponly=True,
    secure=COOKIE_SECURE,
    samesite="Lax",
)

Access cookie'nin ömrünü kısa tutun; her istekle gidiyor. Refresh cookie'ye daha uzun ömür verin ve path'ini auth uçlarına kısıtlayın; böylece sıradan API çağrılarında gönderilmez. simplejwt refresh token'ları döndürüp eskilerini kara listeye alabiliyor, ikisini de açmaya değer. Authorization header'ı yoksa küçük, özel bir authentication sınıfı token'ı cookie'den okuyabilir. Secure bayrağını bir ayara bağlayın: production'da açık, yalnızca düz HTTP'li yerel geliştirmede kapalı.

Tuzak: Next'in sunucusu nginx'i atlıyor

Bana en çok zaman kaybettiren hata bu. Production'da Django SECURE_SSL_REDIRECT açık ve SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") ile çalışıyor; yani isteğin HTTPS olup olmadığına proxy'nin header'ına bakarak karar veriyor. Tarayıcı istekleri nginx'ten geçtiği için o header'ı taşıyor.

Ama bir Server Component Django'yu çağırdığında istek Node.js sürecinden çıkıp doğrudan backend'in iç adresine (bir INTERNAL_API_URL ayarı) gidiyor, nginx'e hiç uğramıyor. Header olmayınca Django isteği düz HTTP sanıyor ve HTTPS'e yönlendirme döndürüyor. Node'un fetch'i bu kez yalnızca düz HTTP konuşan gunicorn'a TLS el sıkışması denemeye kalkıyor ve istek bağlantı zaman aşımıyla ölüyor.

Dışarıdan görünüşü sinsi: site ayakta kalıyor, çünkü ISR daha önce önbelleğe alınmış sayfaları servis etmeyi sürdürüyor. Yalnızca önbelleği yenilenen sayfalar sessizce varsayılan içeriğe düşüyor; örneğin dinamik girdiler üretilen sitemap'ten sessizce kaybolabiliyor. Çözüm, header'ı her sunucu taraflı fetch'te kendiniz göndermek:

function internalFetch(path: string, init?: RequestInit) {
  return fetch(`${INTERNAL_API_URL}${path}`, {
    ...init,
    headers: { ...init?.headers, "X-Forwarded-Proto": "https" },
  });
}

Artık tüm sunucu taraflı istekleri böyle tek bir sarmalayıcıdan geçiriyorum; kimse unutamıyor.

Admin path'ini gizlemek

Django admin panelinin varsayılan /admin/ yolunu kullanmak, botların brute-force denemeleri için ilk hedef oluyor. Bunun yerine tahmin edilemez bir path kullanıyorum:

# config/urls.py
urlpatterns = [
    path("custom-admin-path/", admin.site.urls),
    # ...
]

Bu, gerçek bir güvenlik katmanı değil ama otomatik tarama botlarının büyük kısmını eleyen ucuz ve etkili bir önlem. İşe yaraması için arkasında gerçek korumalar durmalı: güçlü parolalar, rate limit ve mümkünse ek bir doğrulama adımı.

Docker Compose ile bir araya getirmek

Gerçek bir kurulumda compose dosyasında genelde iki uygulamadan fazlası olur: bir veritabanı, bir önbellek ya da görev broker'ı ve arka plan worker'ları. Portları nginx yayınlıyor; uygulama konteynerlerine yalnızca iç ağdan ulaşılıyor. Fikrin asgari üç servisli hâli şöyle. Bunun üzerine veritabanı ve sır yönetimi eklenmiş tam bir production kurulumu için Docker Compose ile Next.js + Django production kurulumu yazısına bakabilirsiniz:

services:
  frontend:
    build: ./frontend
    expose:
      - "3000"
  backend:
    build: ./backend
    expose:
      - "8000"
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    depends_on:
      - frontend
      - backend

Sonuç

Bu yaklaşımla CORS yapılandırması, cookie domain sorunları ve iki ayrı SSL sertifikası yönetimi gibi problemlerin hepsi ortadan kalkıyor. Geliştirme ortamında da production'da da aynı mimari çalışıyor, bu da "yerelde çalışıyordu" sürprizlerini büyük ölçüde azaltıyor. Bedeli: yönlendirme, TLS, önbellek ve rate limit'in hepsi ortak nginx'te toplanıyor ve sunucu taraflı isteklerin yukarıda anlatılan özeni görmesi gerekiyor. Bunu çalıştıracak bir sunucu arıyorsanız, AWS Lightsail'de Django deploy'u yazısı sıfırdan nginx ve HTTPS kurulumunu anlatıyor.

Bir istisna daha var: aynı origin yalnızca tarayıcının attığı isteklere yarar. Server Component içindeki fetch Node.js sunucunuzda çalışır; tarayıcıdaki kimlik çerezi (Django oturumunda sessionid, yukarıdaki JWT düzeninde access cookie) bu isteğe eklenmez ve Django kullanıcıyı anonim görür. Çerezi nasıl ileteceğinizi Server Component'te Django oturumu neden boş geliyor yazısında anlattım. Sunucu taraflı çağrılarınız yalnızca herkese açık içerik okuyorsa hiçbirinin çereze ihtiyacı yok.