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, thepages/structure replacesapp/, and conventions like Server Components don’t apply.
Why You Need a Multi-Stage Dockerfile #
Next.js produces two kinds of output:
- A standalone Node server (
output: 'standalone') — for SSR/ISR. Needs a Node runtime. - 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 fullnode_modules, no source code, and nonext devbinary. 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 --> EServer 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:
| Mode | Trigger | Output | Runtime |
|---|---|---|---|
| SSR | Default (no export const dynamic) | Render per request | Node server |
| SSG | export const dynamic = 'force-static' | HTML at build time | Static hosting (nginx) |
| ISR | export const revalidate = 60 | HTML + revalidation | Node server |
| CSR | 'use client' + useEffect | Empty HTML | Static 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), addoutput: 'export'innext.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. Thedepsstage installs dependencies,builderbuilds Next.js,runnerserves withnode 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_modulesand.nextprevent host conflicts and speed up container restarts.- Healthchecks and resource limits are mandatory for the
db,cache, andwebservices. Set a realistic memory limit (512MB is enough for Next.js dev).- Environment variables via
.env.localand Composeenvironment. DisableNEXT_TELEMETRY_DISABLEDfor faster builds.- Common troubleshooting: dead Fast Refresh (check volumes), port conflicts (change mappings), failed Server Actions (check the
outputconfig).- Dev best practices: bind-mount source, anonymous cache volumes, healthchecks,
.env.local, non-root users in production.