50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
deploymentMay 18, 2026·8 min read

Docker and Next.js: A Practical Guide to Containerizing for Non-Vercel Deployments

Vercel is great until it isn't. Here's how to properly containerize a Next.js app with Docker so you can deploy anywhere without surprises.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Docker and Next.js: A Practical Guide to Containerizing for Non-Vercel Deployments

Vercel is genuinely excellent for deploying Next.js. We use it ourselves. But there are real reasons to run your own containers: a client who insists on AWS, a need to colocate with other services, cost at scale, or simply wanting to understand what's actually running in production. We've hit all of these. The first time we had to containerize a Next.js app for a client's private cloud, we spent an embarrassing amount of time getting it right. This is what we wish someone had written down.

Why Next.js + Docker Is Slightly Annoying

Next.js isn't a simple static site generator or a plain Node.js server. Depending on how you use it, a single app might need a standalone server, a static export, edge functions, and a separate image optimization service. Dockerizing it requires you to actually understand what mode you're running. Most tutorials skip this and give you a Dockerfile that works for their specific case, which then mysteriously fails for yours.

The three deployment modes you need to know: 'export' (fully static, no server needed), 'standalone' (self-contained Node.js server, smallest image), and the default 'server' mode (requires node_modules, bigger image). For most SaaS apps with dynamic routes, auth, and API routes, you want standalone. Full stop.

Step 1: Enable Standalone Output

Before touching Docker, tell Next.js to produce a standalone build. This bundles only what's necessary to run the app — no full node_modules folder, no dev dependencies. It traces your code and copies just the files actually used. The resulting image can be 5-10x smaller than the naive approach.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'standalone',
  // If you're behind a reverse proxy (nginx, traefik, etc.)
  // you probably want this too:
  // poweredByHeader: false,
}

export default nextConfig

After building, you'll find a .next/standalone directory. That's your entire runnable app. It includes a server.js file and a trimmed-down node_modules. You still need to copy .next/static and public separately — standalone doesn't include static assets because in production you'd typically serve them from a CDN anyway.

The Dockerfile That Actually Works

Here's the multi-stage Dockerfile we use. Multi-stage means the build dependencies never end up in the final image. Your production container doesn't need TypeScript, ESLint, or the 400MB of build tooling you installed.

# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app

# Copy package files
COPY package.json package-lock.json* ./
# If you use pnpm:
# COPY pnpm-lock.yaml ./
# RUN corepack enable && pnpm install --frozen-lockfile

RUN npm ci

# Stage 2: Build the app
FROM node:20-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Build args for environment variables needed at build time
# (NEXT_PUBLIC_* vars are baked in at build time)
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

RUN npm run build

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

ENV NODE_ENV=production
# Disable Next.js telemetry
ENV NEXT_TELEMETRY_DISABLED=1

# Create a non-root user (don't run as root in production)
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Copy static assets
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"]
Don't skip the non-root user. Running containers as root is a security risk and some orchestrators (like OpenShift) will reject your deployment outright. We learned this at 11pm before a client demo.

The NEXT_PUBLIC_ Variable Trap

This one gets everyone. NEXT_PUBLIC_ environment variables are embedded into the JavaScript bundle at build time, not at runtime. This means if you pass them via docker run -e or a Kubernetes ConfigMap, they will be silently ignored. The browser-side code already has whatever value (or empty string) was there when you ran npm run build.

You have two options. First, pass them as build args in your CI pipeline (as shown in the Dockerfile above with ARG). This works but means you need a different image per environment, which is annoying. Second, use a runtime configuration approach — expose non-sensitive config through an API route or inject it into a window variable from a server component. For most things, build args are fine. For truly environment-agnostic images, the runtime approach is cleaner.

// app/api/config/route.ts
// One way to expose runtime config to the client
import { NextResponse } from 'next/server'

export const dynamic = 'force-dynamic'

export async function GET() {
  // Only expose what's safe to be public
  return NextResponse.json({
    apiUrl: process.env.API_URL,
    featureFlags: process.env.FEATURE_FLAGS?.split(',') ?? [],
  })
}

Server-side environment variables (without the NEXT_PUBLIC_ prefix) work exactly as you'd expect. They're read at runtime from process.env, so you can pass them however your infra injects them — Docker secrets, Kubernetes secrets, .env files mounted as volumes, whatever. No tricks needed.

Docker Compose for Local Development

Running the production container locally is useful for testing, but day-to-day development with hot reload still works better with the dev server. Docker Compose lets you run both the app and its dependencies (Postgres, Redis, etc.) together without installing everything on your machine.

# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        NEXT_PUBLIC_API_URL: http://localhost:3000
    ports:
      - '3000:3000'
    environment:
      - DATABASE_URL=postgresql://postgres:password@db:5432/myapp
      - NEXTAUTH_SECRET=dev-secret-change-in-production
      - NEXTAUTH_URL=http://localhost:3000
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

The healthcheck on the database is important. Without it, your app container might start before Postgres is ready to accept connections, and you'll get connection errors on startup. The depends_on with condition: service_healthy waits for the healthcheck to pass before starting the app.

Image Optimization Outside Vercel

Vercel handles Next.js image optimization transparently. Self-hosted, you need to think about this. The built-in sharp-based optimizer works fine in Docker, but you need to make sure sharp is installed.

Sharp is a native module that sometimes doesn't survive being copied in the standalone bundle. The fix: explicitly install it in your runner stage.

# Add this to the runner stage, before the USER directive
# Install sharp for image optimization
RUN npm install sharp --ignore-scripts=false --foreground-scripts
# Or if you need a specific version matching your Next.js:
# RUN npm install sharp@0.33.4

If you're running behind a CDN (which you should be for static assets and images in production), you can also offload image optimization to the CDN entirely and set images.unoptimized: true in next.config.ts. This simplifies the setup but you lose automatic format conversion (WebP, AVIF) unless your CDN handles it.

Deploying the Container: The Minimal Setup

Once you have a working Docker image, you need something to run it. Here's the realistic minimum for a production deployment that isn't Vercel:

  • A container registry (AWS ECR, GitHub Container Registry, Docker Hub) to store your images
  • A compute target: ECS Fargate, a plain EC2 or VPS with Docker installed, Railway, Render, Fly.io, or a Kubernetes cluster
  • A reverse proxy (nginx or Traefik) for SSL termination and routing if you're on raw VMs
  • A health check endpoint so your orchestrator knows when the app is ready

For the health check, add a simple route to your app:

// app/api/health/route.ts
import { NextResponse } from 'next/server'

export const dynamic = 'force-dynamic'

export async function GET() {
  // You can add DB connectivity checks here if you want
  return NextResponse.json(
    { status: 'ok', timestamp: new Date().toISOString() },
    { status: 200 }
  )
}

Then reference it in your Docker Compose or Kubernetes deployment: healthcheck path /api/health, interval 30s, timeout 10s, start period 60s (Next.js cold starts can be slow). This ensures load balancers only route traffic to healthy instances.

Fly.io and Railway both support deploying directly from a Dockerfile, which makes them a good middle ground — you get the portability of containers without managing your own servers. If you're moving away from Vercel for cost reasons rather than control reasons, they're worth a look before going full Kubernetes.

Common Issues and How to Fix Them

  • Build fails with 'Cannot find module' — your next.config.ts imports something that isn't in dependencies (only devDependencies). Move it to dependencies.
  • Container starts but app returns 502 — check that HOSTNAME is set to 0.0.0.0, not localhost. A container listening on localhost is not accessible from outside.
  • Images don't load in production — you're missing the COPY for .next/static or the sharp module isn't installed in the runner stage.
  • Database connections fail at startup — add a retry loop or use a proper migration runner that waits for DB readiness before the app starts.
  • Build takes forever in CI — layer cache isn't working. Make sure COPY package.json comes before COPY . . so npm ci is cached unless dependencies change.

If you're starting a new project and want to skip the 'figure out all this infrastructure stuff from scratch' phase, our templates at peal.dev come with production-ready Dockerfiles and docker-compose setups already configured — including the standalone output, health endpoints, and proper environment variable handling. But if you're retrofitting an existing app, everything above should get you there.

The honest truth is that once you've set this up once and it's working, it's remarkably low-maintenance. The Dockerfile barely changes between projects. Copy it, adjust the build args for your NEXT_PUBLIC_ variables, make sure your env vars are passed through at runtime, and you're done. The tricky part is understanding the why — which hopefully this post covered. Go build something and ship it somewhere other than Vercel just to say you did it.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders