A Production Docker Compose Setup for Next.js + Django
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 buildMissing 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 --noinputThe --rm flag removes the throwaway container when it finishes.
Handling secrets
.env must never be committed:
.env
.env.*
!.env.exampleKeep 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 .envPre-launch checklist
- Is
DEBUG = False? - Does
ALLOWED_HOSTSlist the real domain? - Does
SECRET_KEYcome 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.