Next.js #

Next.js is a React framework that brings opinions about rendering — Server Components, Server Actions, file-based routing, and even ISR (Incremental Static Regeneration) are part of the default project. Precisely because these opinions are so strong, the local development setup must follow the same contract: a Node.js server for the SSR runtime, or a static export + nginx for SSG. Ignoring either side makes the Docker container break quickly or kills hot reload.

This article covers a complete Next.js + Docker Compose setup for local development, including multi-stage Dockerfiles separating build and runtime, Postgres/Redis integration, dev vs production image strategies, and best practices often ignored in standard tutorials.

Prerequisites #

Make sure the host has installed:

  • Docker and Docker Compose (latest versions)
  • Node.js 20+ (optional, only for running host tooling like create-next-app)
  • Git for version control

A standard Next.js project (bootstrapped with create-next-app):

my-next-app/
├── app/                  # App Router (Next.js 13+)
│   ├── layout.tsx
│   ├── page.tsx
│   └── api/
├── public/
├── components/
├── package.json
├── package-lock.json
├── next.config.js
├── Dockerfile
├── docker-compose.yml
└── .env.local
This article assumes the App Router (Next.js 13+). If your project still uses the Pages Router, the pages/ structure replaces app/, and conventions like Server Components don’t apply.

Why You Need a Multi-Stage Dockerfile #

Next.js produces two kinds of output:

  1. A standalone Node server (output: 'standalone') — for SSR/ISR. Needs a Node runtime.
  2. A static export (output: 'export') — for pure SSG. Can be served with nginx.

A multi-stage Dockerfile makes the final image contain only the relevant artifacts, without development node_modules or the original source code. This also separates the dev image (with Fast Refresh) from the production image (build artifacts only).


Multi-Stage Dockerfile #

Create three stages: deps to install dependencies, builder to build Next.js, and runner for the slim production image.

# syntax=docker/dockerfile:1.6

# ---- Stage 1: Install dependencies ----
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Copy the lock file for reproducible installs
COPY package.json package-lock.json* ./
RUN npm ci

# ---- Stage 2: Build Next.js ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Disable telemetry during builds
ENV NEXT_TELEMETRY_DISABLED=1

# Build Next.js with standalone output
RUN npm run build

# ---- Stage 3: Production runner ----
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Create a non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Copy standalone artifacts (server.js + a minimal node_modules)
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
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

CMD ["node", "server.js"]

To enable output: 'standalone', add this to next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  reactStrictMode: true,
};

module.exports = nextConfig;

Standalone mode produces a server.js that already includes the full Next.js routing with a subset of the needed node_modules. The final image is only ~150MB (Alpine) vs 500MB+ for a non-standalone image.


docker-compose.yml for Development #

For dev, the image differs from production. You don’t need the builder stage; just node:20-alpine with npm run dev + a bind mount.

# docker-compose.yml
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: my-next-app:dev
    container_name: nextjs-dev
    command: npm run dev
    ports:
      - "3000:3000"
    volumes:
      - ./:/app
      - /app/node_modules
      - /app/.next
    environment:
      - NODE_ENV=development
      - NEXT_TELEMETRY_DISABLED=1
      - DATABASE_URL=postgresql://app:pass@db:5432/myapp
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=myapp
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    ports:
      - "6379:6379"

volumes:
  db-data:

Dockerfile.dev:

FROM node:20-alpine
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm install

COPY . .

EXPOSE 3000

CMD ["npm", "run", "dev"]

Service Explanations #

web — the Next.js dev server service. Uses the simple Dockerfile.dev — all dev dependencies (TypeScript, ESLint, Prettier) get installed. The .:/app volume syncs source code, the /app/node_modules anonymous volume isolates node_modules from the host, and /app/.next holds the Next.js build cache.

db — PostgreSQL for the database. The pg_isready healthcheck ensures web starts after Postgres accepts connections.

cache — Redis for caching, sessions, or rate limiting. The redis-cli ping healthcheck.

Don’t use the production image (multi-stage standalone) for development. That image has no full node_modules, no source code, and no next dev binary. Hot reload will die completely and cryptic errors will appear.

Hot Reload with Fast Refresh #

Next.js has built-in Fast Refresh working through a file watcher. To ensure it works in Docker:

// next.config.js
module.exports = {
  // Allow Fast Refresh for HMR
  reactStrictMode: true,
  // Watch extra folders if in a monorepo
  outputFileTracingRoot: process.cwd(),
};

The Compose Dockerfile above already bind-mounts source code, so every IDE save triggers Fast Refresh. The .next cache is isolated in an anonymous volume so it doesn’t conflict with the host.

# Daily workflow
docker compose up -d
# Edit a file in app/ or components/
# The browser auto-updates via Fast Refresh
docker compose logs -f web   # view request logs

App Router and Server Components #

The App Router (default since Next.js 13) brings new concepts important for Docker:

flowchart LR
    A[Browser] --> B[Next.js Server]
    B --> C[Server Component]
    B --> D[Client Component]
    C --> E[(Database)]
    C --> F[(Cache)]
    D --> G[Browser Hydration]
    B --> H[Server Action]
    H --> E

Server Component (default, without 'use client'):

// app/page.tsx — Server Component
import { db } from '@/lib/db';

export default async function HomePage() {
  // Can query the database directly, no API route needed
  const posts = await db.post.findMany();
  
  return (
    <main>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </main>
  );
}

Client Component (opt-in with 'use client'):

// components/Counter.tsx
'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

Server Action (direct mutation from a form):

// app/posts/new/page.tsx
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

export default function NewPostPage() {
  async function createPost(formData: FormData) {
    'use server';
    const title = formData.get('title') as string;
    await db.post.create({ data: { title } });
    revalidatePath('/posts');
  }
  
  return (
    <form action={createPost}>
      <input name="title" required />
      <button type="submit">Create Post</button>
    </form>
  );
}

In local development, Server Actions run through Next.js’s internal endpoint. In production with standalone output, this endpoint is automatically bundled into server.js. No extra Docker configuration needed.


Routing with the App Router #

The App Router uses folder-based file routing:

app/
├── layout.tsx          # Root layout (required)
├── page.tsx            # / (root)
├── about/
│   └── page.tsx        # /about
├── posts/
│   ├── page.tsx        # /posts
│   └── [id]/
│       └── page.tsx    # /posts/:id
├── dashboard/
│   ├── layout.tsx      # Nested layout
│   └── settings/
│       └── page.tsx    # /dashboard/settings
└── api/
    └── users/
        └── route.ts    # /api/users endpoint

Dynamic route with params:

// app/posts/[id]/page.tsx
export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await db.post.findUnique({ where: { id } });
  
  if (!post) notFound();
  return <article>{post.content}</article>;
}

API Route with a handler:

// app/api/users/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function GET() {
  const users = await db.user.findMany();
  return NextResponse.json(users);
}

export async function POST(request: Request) {
  const body = await request.json();
  const user = await db.user.create({ data: body });
  return NextResponse.json(user, { status: 201 });
}

Rendering Strategy: SSR vs SSG vs ISR #

Next.js supports four rendering modes — the choice affects the Dockerfile and the serving strategy:

ModeTriggerOutputRuntime
SSRDefault (no export const dynamic)Render per requestNode server
SSGexport const dynamic = 'force-static'HTML at build timeStatic hosting (nginx)
ISRexport const revalidate = 60HTML + revalidationNode server
CSR'use client' + useEffectEmpty HTMLStatic hosting

Examples of each:

// app/page.tsx — SSR (default)
export const dynamic = 'force-dynamic';

export default async function Page() {
  const data = await fetch('https://api.example.com', { cache: 'no-store' });
  return <div>{(await data.json()).title}</div>;
}

// app/blog/page.tsx — SSG
export const dynamic = 'force-static';

export default async function BlogPage() {
  const posts = await db.post.findMany();
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// app/products/[id]/page.tsx — ISR
export const revalidate = 3600; // Regenerate every 1 hour

export default async function ProductPage({ params }) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });
  return <h1>{product.name}</h1>;
}
For projects that are purely SSG (e.g. blogs or landing pages), add output: 'export' in next.config.js. The built image can be served with nginx without a Node runtime. This is very different from the default mode (SSR/ISR), which needs Node.

Static Export with Nginx #

If the project is fully SSG, the build produces an out/ folder of static HTML. No Node needed in production:

# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# Serve the static export with nginx
FROM nginx:1.27-alpine
COPY --from=builder /app/out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

nginx.conf:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Cache static assets aggressively
    location /_next/static/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA fallback for client-side routing
    location / {
        try_files $uri $uri/ /index.html;
    }
}
The output: 'export' mode has limitations: no ISR, no Server Actions, no API Routes running on the server. Choose this mode only if the project is truly static.

Build and Run #

# Development
docker compose up --build
# Access: http://localhost:3000

# View Next.js logs
docker compose logs -f web

# Production build
docker build -t my-next-app:prod -f Dockerfile .
docker run -p 3000:3000 my-next-app:prod

# Stop everything
docker compose down

For a full reset including the database:

docker compose down -v   # Remove volumes (including db-data)
docker compose up -d

Best Practices #

1. Separate Dev and Prod Dockerfiles #

Use Dockerfile.dev for development (large image, full source, dev server) and Dockerfile (multi-stage) for production. Avoid one Dockerfile handling both — too many conflicts (layer caches, CMD, user permissions).

2. Use output: 'standalone' #

Standalone mode produces a slim production image. Without it, the image needs the full node_modules, which is hundreds of MB.

3. Don’t Build .next in the Dev Container #

In dev, let next dev generate the .next cache in an anonymous volume. Don’t run next build in the dev container — that’s the production image’s job.

4. Disable Telemetry #

Set NEXT_TELEMETRY_DISABLED=1 in the Dockerfile and .env.local. Next.js sends anonymous data to Vercel during builds, which slows builds on restricted networks.

5. Healthchecks and Resource Limits #

Add a healthcheck and memory limit:

services:
  web:
    deploy:
      resources:
        limits:
          memory: 512M
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"]
      interval: 30s
      timeout: 10s
      retries: 3

6. Environment Variables with .env.local #

Next.js automatically reads .env.local (which must be in .gitignore). In Compose, mount this file:

volumes:
  - ./.env.local:/app/.env.local:ro

7. Cache .next in a Volume #

An anonymous volume for .next speeds up container restarts:

volumes:
  - /app/.next

Without the volume, every docker compose restart rebuilds the .next cache, slowing startup.


Troubleshooting #

Fast Refresh Not Working #

Edit file → browser doesn't auto-update

Make sure the bind mount volume is active and the .next cache doesn’t conflict. Check with docker compose exec web ls -la .next — the folder should contain a cache.

Build Fails: “Cannot find module” #

Usually because the host’s node_modules got copied along. Make sure the /app/node_modules anonymous volume is set, or add node_modules to .dockerignore.

Port 3000 Already in Use #

lsof -i :3000   # macOS/Linux

Change the port mapping in Compose: "3001:3000". Access via http://localhost:3001.

Server Actions Not Executing #

Make sure output: 'standalone' is in next.config.js. Server Actions need a Node runtime, not a static export.


Summary #

  • Next.js is ideal for SSR/ISR development — Postgres, Redis, and other services run as separate containers.
  • Multi-stage Dockerfiles with output: 'standalone' produce a slim production image. The deps stage installs dependencies, builder builds Next.js, runner serves with node server.js.
  • Separate the dev Dockerfile (simple, npm run dev) from production (multi-stage). Fast Refresh hot reload only works in the dev image.
  • The App Router with Server Components + Server Actions allows direct database queries from components, without traditional API Routes.
  • Rendering modes (SSR/SSG/ISR/CSR) determine the serving strategy: SSG uses static nginx, SSR/ISR needs a Node server.
  • Anonymous volumes for node_modules and .next prevent host conflicts and speed up container restarts.
  • Healthchecks and resource limits are mandatory for the db, cache, and web services. Set a realistic memory limit (512MB is enough for Next.js dev).
  • Environment variables via .env.local and Compose environment. Disable NEXT_TELEMETRY_DISABLED for faster builds.
  • Common troubleshooting: dead Fast Refresh (check volumes), port conflicts (change mappings), failed Server Actions (check the output config).
  • Dev best practices: bind-mount source, anonymous cache volumes, healthchecks, .env.local, non-root users in production.

← Previous: Fastify   Next: Nuxt 3 →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact