Musa Yılmaz
·5 min read

A Production Docker Compose Setup for Next.js + Django

dockernextjsdjangodevops

On a project that pairs Next.js with Django, deployment is usually the annoying part. Two runtimes, two dependency managers, a database, and a web server in front. Docker Compose lets you declare all of it in one file and bring it up with a single command.

Here's a working setup and the details that matter in production.

The shape of it

Four services:

  • postgres — the database
  • backend — Django behind Gunicorn
  • frontend — Next.js
  • nginx — the only door to the outside

The key design decision: only nginx publishes a port. The other three talk on the internal network only. That gives you security and eliminates CORS entirely, because from the browser's point of view there's a single origin.

Dockerfile for Django

A multi-stage build keeps the image small:

# 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"]

Three things worth noting:

Non-root user. Running as root inside a container widens what an attacker can do if they find a vulnerability. USER appuser closes that off.

Gunicorn, not runserver. Django's development server is single-threaded and was never meant for production.

Worker count. The usual rule is (2 × CPU cores) + 1. On a small server, 3 is a sane start.

Dockerfile for Next.js

Next.js's standalone output shrinks the image dramatically:

// 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 mode ships only the node_modules files actually used. A typical 1 GB image drops to around 150 MB.

Why do Next.js environment variables come back empty on the server?

In Next.js, variables prefixed NEXT_PUBLIC_ are inlined at build time. Declaring them under environment alone is not enough; they must also be passed as build args:

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

Missing this distinction is the single most common cause of "works locally, empty on the server."

Conversely, secret values (API keys, database passwords) must never be build args — they persist in image layers and anyone with the image can read them. Those belong in environment at runtime.

The Compose file

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:

Two details are critical:

expose vs ports. ports publishes a container port to the host (reachable from the internet); expose only opens it on the internal network. Using expose for the database and app services keeps them off the public internet.

condition: service_healthy. A plain depends_on waits for a container to start, not to be ready. PostgreSQL accepts connections a few seconds after boot; without the healthcheck, Django fails on its first attempt and exits.

nginx configuration

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;
    }
}

The X-Forwarded-Proto header lets Django know the request arrived over HTTPS. Set the matching option on the Django side:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

Without it, Django treats HTTPS requests as HTTP and can end up in a redirect loop.

Running migrations

Baking migrations into the app's start command is tempting but risky: if several workers start at once they can collide. Run them as a separate step:

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

The --rm flag removes the throwaway container when it finishes.

Handling secrets

.env must never be committed:

.env
.env.*
!.env.example

Keep an .env.example with the variable names and blank values instead. New environments know what to fill in, and nothing real leaks.

On the server, restrict its permissions:

chmod 600 .env

Pre-launch checklist

  • Is DEBUG = False?
  • Does ALLOWED_HOSTS list the real domain?
  • Does SECRET_KEY come from the environment rather than source?
  • Is a database volume declared? Without one, deleting the container destroys the data.
  • Is an HTTPS certificate in place? (Let's Encrypt is free.)
  • Are backups running?

That last point deserves emphasis: the pgdata volume survives container removal, but not server removal. A simple scheduled pg_dump copied somewhere else will save you one day.

Summary

The real value of Compose is that your whole infrastructure lives in a version-controlled file. Moving to a new server, onboarding a developer, or upgrading a service becomes an edit to that file. Running the same topology in development and production also puts an end to most "works on my machine" arguments.