Chapter 25

Deployment: Vercel, Docker and Self-Hosting

The Core Problem in Deployment

Deploying a Next.js application to production is fundamentally about one thing: your code needs a runtime environment that can continuously serve requests, handle static and dynamic assets, manage environment variables, and support scaling. Different deployment approaches make different trade-offs on these dimensions. Vercel has the lowest friction but costs scale significantly with traffic. Docker self-hosting gives you complete control but puts the operational burden on you. Middle-ground options like Railway and Fly.io offer compromises in between.

Understanding the internals of each approach is what lets you choose correctly โ€” and switch when necessary.

Vercel Deployment

Zero-Config Deployment

Vercel is the hosting platform built by the creators of Next.js, and its Next.js support is native-level. Connect a GitHub repository, click deploy, and Vercel automatically detects the Next.js project, runs next build, and deploys the output.

npm install -g vercel
vercel deploy

Every push to a non-main branch creates a Preview Deployment with its own URL, so the team can review features before merging. Pushing to the main branch triggers a production deployment.

Function Types: Edge vs Serverless

Next.js functions on Vercel default to Serverless Functions (Lambda model). You can specify the runtime per route:

// At the top of a Route Handler or Page
export const runtime = 'edge' // or 'nodejs' (default)
Edge Functions Serverless Functions
Cold start ~0ms (V8 isolate) 100-500ms
Runtime Edge Runtime (limited API surface) Full Node.js
Execution limit 25ms CPU time Up to 60s
Global distribution Yes No (single region)

Edge Functions are right for: auth middleware, simple route rewrites, geo-routing. Serverless Functions are right for: database queries, file processing, anything requiring Node.js native modules.

Environment Variable Management

Vercel manages environment variables through the Dashboard across three environments: Production, Preview, and Development.

# .env.local (local development, never committed to Git)
DATABASE_URL=postgresql://localhost:5432/myapp
NEXTAUTH_SECRET=dev-secret-change-me

# Set in Vercel Dashboard
DATABASE_URL=postgresql://prod-host:5432/myapp
NEXTAUTH_SECRET=production-secret-32-chars-min

Variables prefixed with NEXT_PUBLIC_ are bundled into client-side code. All other variables are server-only. Never put secrets in NEXT_PUBLIC_ variables.

Health Check Endpoint

Production services should expose a health check endpoint for load balancers and monitoring systems:

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

export async function GET() {
  try {
    // Verify database connectivity
    await prisma.$queryRaw`SELECT 1`

    return NextResponse.json({
      status: 'ok',
      timestamp: new Date().toISOString(),
      version: process.env.npm_package_version,
    })
  } catch (error) {
    return NextResponse.json(
      { status: 'error', error: String(error) },
      { status: 503 }
    )
  }
}

Docker Deployment

The Standalone Output Mode

Next.js provides a standalone output mode specifically designed for containerization. When enabled, next build generates a .next/standalone directory containing the minimal Node.js code required to run the application โ€” via static analysis of the node_modules dependency tree, only actually-used modules are copied. The result starts with node server.js and requires no dependency installation.

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

const nextConfig: NextConfig = {
  output: 'standalone',
}

export default nextConfig

This shrinks image size from a typical 800MB+ down to 100-200MB (depending on dependency count).

Multi-Stage Dockerfile

Multi-stage builds are the standard approach for optimizing Docker images: the build stage uses the full toolchain, and the final image contains only what is needed to run:

# Dockerfile
FROM node:22-alpine AS base

# 1. Install dependencies
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Copy package files first to maximize Docker layer caching
COPY package.json package-lock.json* ./
COPY prisma ./prisma/

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

# 2. Build the application
FROM base AS builder
WORKDIR /app

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

# Generate Prisma Client
RUN npx prisma generate

# NEXT_PUBLIC_ variables must be available at build time
# (they are compiled into the client JavaScript bundle)
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL

ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# 3. Final runtime image
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Create a non-root user (security best practice)
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Copy public assets
COPY --from=builder /app/public ./public

# Copy the standalone output
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"]

A few key points:

FROM node:22-alpine: Alpine base images are over 5x smaller than Debian. libc6-compat provides glibc compatibility needed by some npm packages (like sharp).

Stage separation: deps installs dependencies, builder builds the app, runner contains only the runtime output. The final image has no node_modules (standalone inlines the needed dependencies), no source code, and no build tools.

Non-root user: The container runs as nextjs, not root. This is a fundamental container security principle โ€” if the application is compromised, the attacker has limited privileges.

--chown=nextjs:nodejs: The standalone directory must be owned by the nextjs user, otherwise the USER nextjs switch will prevent reading the files.

Build and Run

# Build the image (pass build-time arguments)
docker build \
  --build-arg NEXT_PUBLIC_APP_URL=https://example.com \
  -t myapp:latest .

# Run the container (pass runtime environment variables)
docker run -d \
  -p 3000:3000 \
  --env-file .env.production \
  --name myapp \
  myapp:latest

# View logs
docker logs -f myapp

# Health check
curl http://localhost:3000/api/health

There is a critical distinction between build-time variables (ARG) and runtime variables (ENV / --env-file): NEXT_PUBLIC_ variables must be passed at build time because they are compiled into the JavaScript bundle. All other server-side variables are passed at runtime and are never embedded in the image.

Docker Compose

Docker Compose orchestrates local development and single-machine deployments:

# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      args:
        NEXT_PUBLIC_APP_URL: http://localhost:3000
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:password@db:5432/myapp
      - NEXTAUTH_URL=http://localhost:3000
      - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

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

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - app

volumes:
  postgres_data:

Nginx Reverse Proxy Configuration

In self-hosted deployments, Nginx typically sits in front of Next.js to handle TLS termination, compression, and static file caching:

# nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;

events {
  worker_connections 1024;
}

http {
  include /etc/nginx/mime.types;
  default_type application/octet-stream;

  gzip on;
  gzip_vary on;
  gzip_min_length 1024;
  gzip_types text/plain text/css application/json application/javascript text/xml;

  upstream nextjs {
    server app:3000;
    keepalive 32;
  }

  server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
  }

  server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate /etc/nginx/certs/cert.pem;
    ssl_certificate_key /etc/nginx/certs/key.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # Security headers
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy strict-origin-when-cross-origin;

    # Next.js static assets: long-term cache (content hashes ensure invalidation on update)
    location /_next/static/ {
      proxy_pass http://nextjs;
      proxy_cache_valid 200 365d;
      add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # Next.js image optimization endpoint
    location /_next/image {
      proxy_pass http://nextjs;
      proxy_cache_valid 200 1d;
    }

    # SSE routes: disable buffering (required for real-time event delivery)
    location /api/events {
      proxy_pass http://nextjs;
      proxy_http_version 1.1;
      proxy_set_header Connection '';
      proxy_buffering off;
      proxy_cache off;
      chunked_transfer_encoding on;
    }

    # All other requests
    location / {
      proxy_pass http://nextjs;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
      proxy_cache_bypass $http_upgrade;
    }
  }
}

SSE routes need special treatment: proxy_buffering off ensures Nginx does not buffer the Next.js response, and Connection '' combined with proxy_http_version 1.1 enables keepalive so the event stream reaches the client in real time.

PM2 vs Docker: Process Management

For direct VPS deployment without containers, PM2 is the standard Node.js process manager:

npm install -g pm2
// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'nextjs',
    script: '.next/standalone/server.js',
    instances: 'max',         // Use all CPU cores
    exec_mode: 'cluster',     // Cluster mode for automatic load balancing
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000,
    },
    error_file: 'logs/err.log',
    out_file: 'logs/out.log',
    max_memory_restart: '1G', // Auto-restart on memory limit
  }],
}
pm2 start ecosystem.config.js --env production
pm2 save      # Persist the process list
pm2 startup   # Configure as a system service for automatic restart on reboot

Docker's advantages: complete environment isolation (independent of the server's Node.js version), easier horizontal scaling (Kubernetes / Docker Swarm), and more reliable rollbacks (pull an old image and restart). PM2's advantages: lighter weight (no container runtime), simpler debugging, suitable for single-machine small-scale deployments.

Database Migration Strategy

In production deployments, database migrations must run before the application starts:

# In the Dockerfile or a deploy script
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]

Or override via Docker Compose command:

app:
  command: sh -c "npx prisma migrate deploy && node server.js"

prisma migrate deploy only runs pending migrations and never re-runs already-applied ones. In a CI/CD pipeline you can also run migrations as a separate step before the application deployment โ€” this way a failed migration prevents the new application version from going live, avoiding a situation where new code runs against an unprepared database schema.

There is no single correct deployment answer. Vercel is the optimal choice at early and medium scale, eliminating most operational overhead. Docker is irreplaceable when you need full control or have specific compliance requirements (data residency, specific cloud providers). Understanding the mechanics of each approach is what lets you make the right choice โ€” and switch confidently when your requirements change.

Rate this chapter
4.7  / 5  (4 ratings)

๐Ÿ’ฌ Comments