DH
8 min read

Containerizing Next.js 15 (App Router) for Local and Production

Dockerize a Next.js 15 project created with the App Router, using multi-stage builds and Docker Compose.

dockernextjs

Next.js 15 brings a stable App Router, React 19 support, and first-class TypeScript config (next.config.ts). In this tutorial you'll containerize a Next.js 15 App Router project for both local development (with hot reloading) and lean production builds using Docker multi-stage builds and the official standalone output mode.


1. Creating a Next.js 15 Project

Use the official CLI options:

npx create-next-app@latest my-next-app \
--app \
--src-dir \
--typescript

This instructs the CLI to:

  • --app: Use the App Router (app/ directory).
  • --src-dir: Place source files inside src/ for cleaner project structure.
  • --typescript: Enable TypeScript from the start.

You'll end up with a folder structure like:

my-next-app/
├─ src/
│ ├─ app/
│ │ ├─ layout.tsx
│ │ └─ page.tsx
├─ package.json
├─ tsconfig.json
├─ next.config.ts
└─ ...

Verify everything runs locally first:

cd my-next-app
npm run dev

Visit http://localhost:3000 to see the starter page.

Node.js requirement: Next.js 15 requires Node.js 18.17 or later. All Dockerfiles in this guide use node:23-alpine, which satisfies this requirement and ships the latest LTS-era active release.


2. next.config.ts

Your new Next.js project includes a TypeScript-based config (next.config.ts). A typical minimal configuration looks like:

const nextConfig: NextConfig = {
reactStrictMode: true,
};

When you run npm run build or npm run dev, Next.js automatically detects and uses next.config.ts.

Enabling standalone Output (Recommended for Docker)

For the smallest possible production image, add the output: "standalone" option. Next.js will trace only the files your app actually needs and bundle them into .next/standalone:

const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
};

This is the approach used in Vercel's official with-docker example and is recommended for self-hosted Docker deployments.


3. Dockerfile for Production (Multi-Stage)

3a. Standard Multi-Stage Build

Create a Dockerfile in your project root:

# Stage 1: Install dependencies and build
FROM node:23-alpine AS builder

# Create and set working directory
WORKDIR /app

# Copy dependency manifests
COPY package*.json ./

# Install all dependencies (including devDependencies needed for build)
RUN npm ci

# Copy the source code (including next.config.ts)
COPY . .

# Build the Next.js app
RUN npm run build

# Stage 2: Lean production runner
FROM node:23-alpine AS runner

# Set the working directory
WORKDIR /app

# Copy only necessary artifacts from the builder
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.ts ./

# Install only production dependencies
RUN npm ci --omit=dev

# Expose port 3000
EXPOSE 3000

# Start the Next.js server
CMD ["npm", "start"]

3b. Standalone Output Build (Smallest Image)

If you added output: "standalone" to next.config.ts, use this leaner Dockerfile instead. The standalone build traces every import and bundles only what is needed — no npm install required in the runner stage:

# Stage 1: Build
FROM node:23-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# Stage 2: Minimal runner (no npm install needed)
FROM node:23-alpine AS runner

WORKDIR /app

# Copy the standalone server bundle
COPY --from=builder /app/.next/standalone ./
# Copy static assets
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

EXPOSE 3000

# The standalone build outputs a minimal server.js
CMD ["node", "server.js"]

Why node server.js instead of npm start? The standalone output generates a self-contained server.js at the project root of .next/standalone. It does not need next in node_modules, making the final image significantly smaller.

Highlights

ApproachProsCons
Standard multi-stageSimple, zero config changesLarger image; copies all node_modules
standalone outputSmallest image; no prod npm installRequires output: "standalone" in config
  • npm ci vs npm install: npm ci is preferred in CI/Docker builds — it installs exact versions from package-lock.json and errors on mismatches, making builds reproducible.
  • App Router: .next includes server/client components built for Next.js 15's new rendering model.
  • next.config.ts: Copied so the server can reference it at runtime.

4. Optional Dockerfile for Local Development

If you want to develop inside Docker with hot reloading, create a Dockerfile.dev:

FROM node:23-alpine

WORKDIR /app

COPY package*.json ./

RUN npm install

COPY . .

EXPOSE 3000

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

Running this container with volume mounts ensures that changes on your host will trigger fast-refresh reloads in the container.

Performance note: On macOS and Windows, Docker Desktop's file-sync overhead can make hot-reload noticeably slower than running npm run dev natively. For day-to-day development on these platforms, consider running Next.js locally and only using Docker for production parity checks. Linux hosts generally do not have this overhead.


5. Docker Compose Configuration

5.1 Local Development

Create or update docker-compose.yml:

version: '3.8'
services:
nextjs-dev:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- ./:/app
- /app/node_modules
environment:
- WATCHPACK_POLLING=true # Needed for file-watch to work inside some Docker environments
  • ./:/app: Maps your local directory into the container, enabling hot reload.
  • /app/node_modules: Anonymous volume prevents the container's node_modules from being overwritten by an empty host directory.
  • WATCHPACK_POLLING=true: Forces polling-based file watching. Required on some Docker Desktop setups (especially Windows with WSL2) where inotify events do not propagate correctly through the volume mount.

Run:

docker compose up --build

Then visit http://localhost:3000.

5.2 Production with Docker Compose

version: '3.8'
services:
nextjs-prod:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: production
restart: unless-stopped

Note: docker-compose (v1 plugin) is deprecated. Use docker compose (v2, built into Docker CLI) going forward.


6. Build and Run in Production (Without Compose)

If you prefer to manage containers manually:

  1. Build:
    docker build -t my-next15-app:latest .
  2. Run:
    docker run -p 3000:3000 --env NODE_ENV=production my-next15-app:latest

Open http://localhost:3000.


7. Common Pitfalls and Edge Cases

Missing public/ directory

Static assets (/public) must be explicitly copied into the final image. Both Dockerfiles above include this step — skip it and images, fonts, and other static files will 404 in production.

.dockerignore — don't skip this

Without a .dockerignore, Docker will COPY your local node_modules and .next cache into the build context, inflating build times and risking stale artifacts. Create .dockerignore at the project root:

node_modules
.next
.git
*.md

Environment variables and secrets

Next.js distinguishes between build-time (NEXT_PUBLIC_*) and runtime environment variables. Variables prefixed with NEXT_PUBLIC_ are inlined at build time — they must be present as ARG/ENV in the builder stage:

ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build

Runtime-only variables (without the NEXT_PUBLIC_ prefix) can be injected at docker run time via -e or --env-file and do not need to be present during the build.

Hot-reload performance on macOS/Windows

On Mac and Windows Docker Desktop, volume-mounted file watching relies on polling or kernel event translation that can add latency. If WATCHPACK_POLLING=true doesn't help, consider Mutagen for faster sync or simply run npm run dev natively.

App Router vs Pages Router

The new App Router structure places compiled server and client components inside .next/server/app/. If you maintain a hybrid app (both app/ and pages/ directories), both are bundled into .next — no extra copy steps needed.

next.config.ts in the runner stage

TypeScript config files (next.config.ts) are read by the Next.js server at runtime. If you use the standalone output, this is already bundled. For the standard build, explicitly copy next.config.ts into the runner as shown above.

Non-root user for security

Running containers as root is a security risk. Add a non-root user to your production Dockerfile:

# After copying files in the runner stage:
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
USER nextjs

8. Conclusion

By Dockerizing your Next.js 15 project (created with --app, --src-dir, and --typescript):

  • You guarantee a consistent environment across dev, staging, and production.
  • Multi-stage builds keep images lightweight by excluding build-only tools and dev dependencies.
  • The standalone output mode produces the smallest possible runtime image — no node_modules needed in production.
  • Docker Compose simplifies orchestration for both hot-reload development and production deployments.
  • Proper .dockerignore, non-root users, and correct NEXT_PUBLIC_* handling keep your containerized app fast, small, and secure.

With this setup you'll have a smooth, reproducible workflow that leverages all of Next.js 15's App Router features — free from environment mismatches or manual deployment surprises.

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps