Musa Yılmaz
·5 dk okuma

Docker Compose ile Next.js + Django Production Kurulumu

dockernextjsdjangodevops

Next.js ve Django'yu birlikte kullanan bir projede en can sıkıcı aşama genelde deploy'dur. İki farklı çalışma ortamı, iki farklı bağımlılık yöneticisi, bir veritabanı ve önlerinde bir web sunucusu. Docker Compose bunların hepsini tek bir dosyada tanımlamanızı ve tek komutla ayağa kaldırmanızı sağlar.

Bu yazıda çalışan bir kurulum ve production için önemli olan ayrıntıları anlatıyorum.

Genel yapı

Dört servis olacak:

  • postgres — veritabanı
  • backend — Django (Gunicorn ile)
  • frontend — Next.js
  • nginx — dışarıya açılan tek kapı

Kritik tasarım kararı şu: sadece nginx dışarıya port açar. Diğer üç servis yalnızca iç ağda konuşur. Bu hem güvenlik sağlar hem de CORS sorununu kökten çözer, çünkü tarayıcı açısından tek bir origin vardır.

Django için Dockerfile

Çok aşamalı (multi-stage) build, imaj boyutunu küçültür:

# backend/Dockerfile
FROM python:3.12-slim AS builder
 
WORKDIR /app
RUN pip install --no-cache-dir --upgrade pip
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
 
FROM python:3.12-slim
 
RUN adduser --disabled-password --gecos "" appuser
 
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
 
COPY . .
RUN chown -R appuser:appuser /app
USER appuser
 
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

Üç noktaya dikkat:

Root olmayan kullanıcı. Konteyner içinde root olarak çalışmak, bir güvenlik açığı durumunda saldırganın hareket alanını genişletir. USER appuser satırı bunu engeller.

Gunicorn, runserver değil. Django'nun geliştirme sunucusu tek iş parçacıklıdır ve production için tasarlanmamıştır.

Worker sayısı. Genel kural (2 × CPU çekirdeği) + 1. Küçük bir sunucuda 3 makul bir başlangıçtır.

Next.js için Dockerfile

Next.js'in standalone çıktısı, imajı dramatik şekilde küçültür:

// next.config.js
module.exports = {
  output: "standalone",
};
# frontend/Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
 
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
 
RUN addgroup --system --gid 1001 nodejs \
 && adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
CMD ["node", "server.js"]

standalone modu, sadece gerçekten kullanılan node_modules dosyalarını çıktıya dahil eder. Tipik olarak 1 GB'lık bir imaj 150 MB'a iner.

Next.js ortam değişkenleri Docker'da neden sunucuda boş geliyor?

Next.js'te NEXT_PUBLIC_ ile başlayan değişkenler build sırasında koda gömülür. Bunları sadece environment altında vermek yetmez; build.args olarak da geçmeleri gerekir:

frontend:
  build:
    context: ./frontend
    args:
      NEXT_PUBLIC_SITE_URL: ${SITE_URL}
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN npm run build

Bu ayrımı kaçırmak, "lokalde çalışıyor ama sunucuda değer boş geliyor" hatasının en sık nedenidir.

Buna karşılık sır niteliğindeki değerler (API anahtarları, veritabanı parolası) asla build argümanı olmamalıdır — imaj katmanlarında kalıcı olarak saklanırlar ve imajı eline geçiren herkes okuyabilir. Onlar çalışma zamanında environment ile verilir.

Compose dosyası

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
 
  backend:
    build: ./backend
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
      DJANGO_ALLOWED_HOSTS: ${DOMAIN}
    depends_on:
      postgres:
        condition: service_healthy
    expose:
      - "8000"
    restart: unless-stopped
 
  frontend:
    build:
      context: ./frontend
      args:
        NEXT_PUBLIC_SITE_URL: ${SITE_URL}
    expose:
      - "3000"
    restart: unless-stopped
 
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - frontend
      - backend
    restart: unless-stopped
 
volumes:
  pgdata:

İki ayrıntı kritik:

expose ve ports farkı. ports konteyner portunu ana makineye açar (dışarıdan erişilebilir), expose sadece iç ağa açar. Veritabanı ve uygulama servisleri için expose kullanmak, onları internete kapatır.

condition: service_healthy. Basit depends_on, servisin başlamasını bekler ama hazır olmasını beklemez. PostgreSQL konteyner başladıktan birkaç saniye sonra bağlantı kabul etmeye başlar; healthcheck olmadan Django ilk denemede bağlanamayıp çıkabilir.

nginx yapılandırması

upstream backend  { server backend:8000; }
upstream frontend { server frontend:3000; }
 
server {
    listen 80;
    server_name example.com;
 
    client_max_body_size 20M;
 
    location /api/ {
        proxy_pass http://backend;
        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;
    }
 
    location /static/ { alias /app/staticfiles/; expires 30d; }
    location /media/  { alias /app/media/;       expires 7d;  }
 
    location / {
        proxy_pass http://frontend;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

X-Forwarded-Proto header'ı Django'nun isteğin HTTPS üzerinden geldiğini anlaması için gereklidir. Django tarafında da karşılığını ayarlamayı unutmayın:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

Bu olmadan Django, HTTPS isteği HTTP sanır ve yönlendirme döngüsüne girebilir.

Migration'ları çalıştırmak

Migration'ları uygulama başlangıç komutuna gömmek cazip gelir ama risklidir: birden fazla worker aynı anda başlarsa migration'lar çakışabilir. Ayrı bir komut olarak çalıştırmak daha güvenlidir:

docker compose run --rm backend python manage.py migrate
docker compose run --rm backend python manage.py collectstatic --noinput

--rm bayrağı, iş bitince geçici konteyneri siler.

Sırların yönetimi

.env dosyası asla git'e girmemelidir:

.env
.env.*
!.env.example

Bunun yerine .env.example dosyasında değişken isimlerini boş değerlerle tutun. Yeni bir ortam kurarken hangi değişkenlerin gerektiği belli olur, ama gerçek değerler sızmaz.

Sunucuda .env dosyasının izinlerini kısıtlayın:

chmod 600 .env

Yayına almadan önce kontrol listesi

  • DEBUG = False ayarlı mı?
  • ALLOWED_HOSTS gerçek domain'i içeriyor mu?
  • SECRET_KEY ortam değişkeninden mi geliyor, kodda sabit değil mi?
  • Veritabanı hacmi (volume) tanımlı mı? Tanımlı değilse konteyner silindiğinde veri gider.
  • HTTPS sertifikası kurulu mu? (Let's Encrypt bunun için ücretsizdir)
  • Yedekleme çalışıyor mu?

Son maddeyi vurgulamak isterim: pgdata hacmi konteyner silinse bile kalır, ama sunucu silinirse gider. Düzenli pg_dump alıp başka bir yere kopyalayan basit bir görev, bir gün sizi kurtarır.

Özet

Compose'un asıl faydası, tüm altyapıyı versiyonlanabilir bir dosyada tanımlamasıdır. Yeni bir sunucuya taşınmak, yeni bir geliştiriciyi projeye dahil etmek veya bir servisi güncellemek bu dosyayı düzenlemekten ibaret hale gelir. Geliştirme ve production'ın aynı yapıyı kullanması da "bende çalışıyordu" tartışmalarını büyük ölçüde bitirir.