Containerizing a Next.js application with Docker seems straightforward — until you add an ORM like Prisma or Drizzle into the mix. The build process, binary generation, schema management, and migration steps all need careful handling inside your Dockerfile.
In this guide, I'll walk you through three production-grade Dockerfiles: one for a plain Next.js app without an ORM, one using Prisma ORM, and one using Drizzle ORM. Each uses multi-stage builds for minimal image size and follows security best practices.
Why Does the ORM Matter for Docker?
Without an ORM, your Next.js Dockerfile is simple — install deps, build, copy standalone output, done. But ORMs introduce extra steps:
- Prisma generates a query engine binary during `prisma generate` — this binary must be present in the final image
- Prisma needs the schema file (`prisma/schema.prisma`) at build time and sometimes at runtime
- Drizzle is lighter — no binary generation — but you still need the schema and migration setup
- Database migrations should ideally run before the app starts, not during the Docker build
- Environment variables like `DATABASE_URL` must be available at the right stages
Prerequisites
- Docker installed (Docker Desktop or Docker Engine)
- A Next.js 14+ project with App Router
- next.config.mjs with `output: 'standalone'` enabled
- Prisma or Drizzle set up in your project (for ORM sections)
- Basic understanding of multi-stage Docker builds
Step 1: Configure Next.js Standalone Output
Regardless of whether you use an ORM, always enable standalone output. This creates a self-contained build with only the necessary node_modules:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
export default nextConfigStep 2: Create .dockerignore
node_modules
.next
out
.git
.gitignore
.vscode
.idea
.DS_Store
.env*.local
npm-debug.log*
Dockerfile*
docker-compose*
.dockerignore
README.mdDockerfile WITHOUT ORM (Plain Next.js)
This is the simplest version — no database, no ORM, just a clean Next.js build. Perfect for static sites, marketing pages, or apps that call external APIs.
# ---- Stage 1: Dependencies ----
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---- Stage 2: Build ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# ---- Stage 3: Runner ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN 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
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]This is clean, fast, and produces an image around ~150MB. Three stages: install deps, build, run. No extra complexity.
Dockerfile WITH Prisma ORM
Prisma is the most popular TypeScript ORM. It uses a query engine binary that gets generated during `prisma generate`. This binary is platform-specific — so it must be generated inside the Docker container (Linux), not on your macOS/Windows host.
Key Prisma Docker Considerations
- `prisma generate` must run after `npm ci` to create the query engine for the container's platform (linux-musl for Alpine)
- The Prisma client and engine live in `node_modules/.prisma` — this must be available during the build AND in the runner
- `prisma/schema.prisma` must be copied before running generate
- Migrations should run at container startup, not during build (the DB may not be reachable at build time)
- `DATABASE_URL` is needed at runtime, not build time (use a dummy value during build if Prisma complains)
# ---- Stage 1: Dependencies ----
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
COPY package.json package-lock.json ./
COPY prisma ./prisma/
RUN npm ci
# Generate Prisma Client (must happen inside the container for correct binary)
RUN npx prisma generate
# ---- Stage 2: Build ----
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Prisma needs DATABASE_URL at build time for some setups
# Use a dummy URL if your build doesn't need DB access
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy"
RUN npm run build
# ---- Stage 3: Runner ----
FROM node:20-alpine AS runner
RUN apk add --no-cache openssl
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN 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
# Copy Prisma schema and generated client for runtime
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Run migrations then start the server
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]Understanding the Prisma Dockerfile
- `openssl` package — Prisma's query engine depends on OpenSSL; Alpine doesn't include it by default
- `COPY prisma ./prisma/` in deps stage — the schema must be present for `prisma generate`
- `npx prisma generate` after `npm ci` — generates the linux-musl query engine binary
- Dummy `DATABASE_URL` — some Prisma setups validate the URL at build time; a dummy value prevents errors
- Copy `.prisma` and `@prisma` to runner — the standalone output doesn't include these automatically
- `prisma migrate deploy` at startup — runs pending migrations against the real database before starting
Dockerfile WITH Drizzle ORM
Drizzle ORM is a lightweight, SQL-first TypeScript ORM. Unlike Prisma, Drizzle has no binary generation step — it's pure TypeScript/JavaScript. This makes Docker builds simpler and faster.
Key Drizzle Docker Considerations
- No `generate` step — Drizzle schema is pure TypeScript, no binary to generate
- Migrations are plain SQL files managed by `drizzle-kit` — run them at startup
- `drizzle.config.ts` and the `drizzle/` migration folder must be in the final image if you run migrations at startup
- Much simpler than Prisma — the Dockerfile is nearly identical to the plain version
# ---- Stage 1: Dependencies ----
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---- Stage 2: Build ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# ---- Stage 3: Runner ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN 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
# Copy Drizzle migration files for runtime migrations
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Run Drizzle migrations then start the server
CMD ["sh", "-c", "npx drizzle-kit migrate && node server.js"]Drizzle vs Prisma: Docker Comparison
Feature | Prisma | Drizzle
------------------------|-----------------------------|----------------------------
Binary generation | Yes (prisma generate) | No
Extra OS packages | openssl required | None
Build complexity | Medium | Low
Schema format | .prisma file | TypeScript files
Migration command | prisma migrate deploy | drizzle-kit migrate
Runtime deps in image | .prisma + @prisma folders | drizzle/ folder only
Image size impact | +10-20MB for engine | Negligible
Cold start impact | Slightly slower | MinimalRunning Migrations Properly
A common mistake is running migrations during `docker build`. Don't do this — the database likely isn't reachable during the build. Instead, run migrations at container startup:
# Option 1: CMD in Dockerfile (shown above)
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]
# Option 2: Entrypoint script
# Create a start.sh:
#!/bin/sh
set -e
npx prisma migrate deploy
node server.js
# Option 3: Docker Compose with depends_on
# Run migration as a separate service before the app startsDocker Compose with Database
Here's a full `docker-compose.yml` that runs your Next.js app with a PostgreSQL database — works for both Prisma and Drizzle:
version: '3.8'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myuser -d mydb"]
interval: 5s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://myuser:mypassword@db:5432/mydb
NODE_ENV: production
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
postgres_data:Building and Testing
# Build the image
docker build -t my-nextjs-app .
# Run with Docker Compose (includes database)
docker compose up -d
# Check logs
docker compose logs -f app
# Verify migrations ran
docker compose exec app npx prisma migrate status
# or for Drizzle:
docker compose exec app npx drizzle-kit checkSecurity Best Practices
- Non-root user: Always run as the `nextjs` user, never root
- Alpine base: Smaller attack surface than Debian-based images
- No source code in runner: Only compiled output reaches the final stage
- Secrets management: Never bake `DATABASE_URL` into the image — pass it at runtime via env vars or a secrets manager
- Pin Node.js versions: Use `node:20.11-alpine` instead of `node:20-alpine` for reproducibility
- Scan images: Run `docker scout cve my-nextjs-app` to check for vulnerabilities
Common Issues and Fixes
- Prisma: 'Cannot find module .prisma/client' — You forgot to copy `node_modules/.prisma` to the runner stage
- Prisma: 'Query engine binary not found' — `prisma generate` wasn't run inside the Docker container (Alpine needs linux-musl binary)
- Prisma: 'Error validating datasource' — Set a dummy `DATABASE_URL` env during the build stage
- Drizzle: 'Migration folder not found' — Copy the `drizzle/` folder to the runner stage
- Both: 'ECONNREFUSED connecting to database' — Database isn't ready yet; use healthchecks and `depends_on` in Compose
- Standalone output missing — Ensure `output: 'standalone'` is in `next.config.mjs`
Conclusion
The key takeaway is simple: without an ORM, your Dockerfile is clean and minimal. With Prisma, you need extra steps for binary generation and copying runtime files. With Drizzle, it's almost as simple as no ORM — just copy migration files.
- Always use `output: 'standalone'` for Docker builds
- Without ORM: simplest Dockerfile, ~150MB image
- Prisma: add `prisma generate`, copy `.prisma` + `@prisma` to runner, install `openssl`
- Drizzle: copy `drizzle/` folder to runner — no binary generation needed
- Run migrations at container startup, not during build
- Use Docker Compose with healthchecks for database dependencies
Resources
- Prisma Docker Documentation: https://www.prisma.io/docs/guides/deployment/deployment-guides/deploying-to-docker
- Drizzle ORM Docs: https://orm.drizzle.team/docs/overview
- Next.js Standalone Output: https://nextjs.org/docs/app/api-reference/config/next-config-js/output
- Docker Multi-Stage Builds: https://docs.docker.com/build/building/multi-stage/
- Next.js Docker Example: https://github.com/vercel/next.js/tree/canary/examples/with-docker