Vercel zero-config and preview deployments, self-hosting with standalone output, static exports for CDN-only deployments, Docker multi-stage builds, PWA setup, and GitHub Actions CI pipelines.
P-14 — Deployment and CI/CD Pipelines
Who this is for: Practitioners ready to ship. You've built the application — now you need to understand where it can run, what the trade-offs are between deployment targets, and how to build a CI/CD pipeline that catches problems before they reach users. This module covers the full deployment matrix: Vercel, self-hosted Node.js, Docker, and static export.
Three Deployment Targets
Every Next.js application can be deployed in three fundamentally different ways, and the choice affects which features work:
Vercel — zero-configuration, all features supported, preview deployments per PR, built-in ISR/PPR/edge compute. The path of least resistance and the right choice for most applications.
Self-hosted Node.js (standalone output) — full Next.js feature support (ISR, streaming, Server Actions, Route Handlers), requires a persistent Node.js process or container orchestration, more operational overhead.
Static export — pure HTML/CSS/JS files served from any CDN. No server required, no ISR, no Route Handlers that use Node.js APIs, no dynamic routes without workarounds. The right choice when you genuinely need zero server dependency.
The decision is not about preference — it's about which features your application uses.
Vercel Deployment
Vercel is the path with the least distance between code and production. Push to your main branch, it deploys. No configuration required for a Next.js application — Vercel detects it automatically and configures the build correctly.
Environment variables per environment:
bash# Three scopes in Vercel dashboard: Development, Preview, Production # Development: pulled by vercel env pull .env.local # Preview: used for PR preview deployments # Production: used for main branch deploys vercel env add DATABASE_URL production vercel env add DATABASE_URL preview vercel env add DATABASE_URL development
The Vercel CLI lets you pull remote env vars into a local .env.local:
bashnpm install -g vercel vercel link # link local project to Vercel project vercel env pull # writes .env.local from development env vars
Preview deployments are the feature that pays for itself in minutes. Every pull request gets its own live URL — https://my-app-git-feature-branch-team.vercel.app. Your team reviews real running code, not static screenshots. Preview deployments use the Preview environment variables, so they can point at a staging database.
Zero-downtime deploys are automatic on Vercel. The old version continues serving traffic until the new deployment is ready. Instant rollback to any previous deployment via the dashboard or CLI:
bashvercel rollback [deployment-url]
Standalone Output — Self-Hosting
For self-hosted deployments, enable standalone output in next.config.ts:
ts// next.config.ts const config: NextConfig = { output: 'standalone', };
After npm run build, Next.js generates .next/standalone — a minimal self-contained directory with only the Node.js server and its actual runtime dependencies (no node_modules bloat from dev dependencies or unused packages). Typical size reduction: from 500MB to 40–80MB.
The standalone server runs with:
bashnode .next/standalone/server.js # Serves on port 3000 by default # PORT=8080 node .next/standalone/server.js ← custom port
You need to copy two additional directories alongside the standalone output — Next.js doesn't include them automatically:
bash# Required: static files and public assets cp -r .next/static .next/standalone/.next/static cp -r public .next/standalone/public
Docker Multi-Stage Build
The canonical Dockerfile for a standalone Next.js application:
dockerfile# Stage 1: Install dependencies FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --only=production # Stage 2: Build the application FROM node:20-alpine AS builder WORKDIR /app COPY /app/node_modules ./node_modules COPY . . # Build args for public env vars (NEXT_PUBLIC_*) 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 ENV NEXT_TELEMETRY_DISABLED=1 # Non-root user for security RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs # Copy standalone output COPY /app/.next/standalone ./ COPY /app/.next/static ./.next/static COPY /app/public ./public USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" CMD ["node", "server.js"]
Key details:
- Three stages keep the final image small — the builder stage has all dev tools, the runner has only what's needed at runtime.
- Non-root user is a security requirement for most container security policies. The
nextjsuser owns the.nextdirectory so ISR can write revalidated pages. NEXT_PUBLIC_*vars at build time — these are baked into the JavaScript bundle during build, not read at runtime. Pass them as build args.- Server-side env vars at runtime —
DATABASE_URL,AUTH_SECRET, and other server-only vars are injected at container run time via-eor an env file, not baked into the image.
bash# Build docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com -t my-app:latest . # Run docker run -p 3000:3000 \ -e DATABASE_URL=postgresql://... \ -e AUTH_SECRET=your-secret \ my-app:latest
Static Export
For applications with no server-side requirements:
ts// next.config.ts const config: NextConfig = { output: 'export', trailingSlash: true, // generates /about/index.html instead of /about.html };
npm run build generates an out/ directory of plain HTML files. Deploy it to any static host: S3 + CloudFront, Cloudflare Pages, Netlify, GitHub Pages.
Hard constraints with static export:
- No ISR (no server to revalidate)
- No Route Handlers that use Node.js APIs or dynamic request data
- No Middleware
- No
cookies()orheaders()in Server Components - Image optimization needs a custom loader (Cloudinary, imgix, or
unoptimized: true) - All dynamic routes must have
generateStaticParams
Static export is the right choice for marketing sites, documentation, and any application where the content is fully known at build time and a CDN is sufficient.
Environment Variable Validation at Build Time
Deploy with a missing DATABASE_URL and you get a runtime crash. Validate at build time instead:
ts// lib/env.ts import { z } from 'zod'; const serverEnvSchema = z.object({ DATABASE_URL: z.string().url(), AUTH_SECRET: z.string().min(32), STRIPE_SECRET_KEY: z.string().startsWith('sk_'), }); const clientEnvSchema = z.object({ NEXT_PUBLIC_APP_URL: z.string().url(), }); // Validate server env at module load — throws during build if vars are missing export const serverEnv = serverEnvSchema.parse(process.env); export const clientEnv = clientEnvSchema.parse({ NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, });
Import serverEnv from any Server Component or Route Handler. The build fails immediately with a descriptive Zod error if a required variable is absent — much better than discovering the missing variable at runtime after deploy.
GitHub Actions CI Pipeline
yaml# .github/workflows/ci.yml name: CI on: push: branches: [main, develop] pull_request: branches: [main] jobs: test-and-build: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres POSTGRES_DB: testdb options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci - name: Run database migrations run: npx prisma migrate deploy env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb - name: Run tests run: npm test -- --ci --coverage env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb AUTH_SECRET: test-secret-at-least-32-characters-long NEXT_PUBLIC_APP_URL: http://localhost:3000 - name: Build run: npm run build env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb AUTH_SECRET: test-secret-at-least-32-characters-long NEXT_PUBLIC_APP_URL: http://localhost:3000
Deploy to Vercel on merge to main:
yaml# .github/workflows/deploy.yml name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest needs: test-and-build # only deploy if CI passes steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci - run: npm run build env: DATABASE_URL: ${{ secrets.DATABASE_URL }} AUTH_SECRET: ${{ secrets.AUTH_SECRET }} NEXT_PUBLIC_APP_URL: ${{ secrets.NEXT_PUBLIC_APP_URL }} - uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} vercel-args: '--prod'
Health Check Endpoint
Every deployed application needs a health check endpoint — for load balancers, Kubernetes liveness probes, and uptime monitors:
ts// app/api/health/route.ts import { db } from '@/lib/db'; export const dynamic = 'force-dynamic'; export async function GET() { const checks: Record<string, 'ok' | 'error'> = {}; // Database connectivity try { await db.$queryRaw`SELECT 1`; checks.database = 'ok'; } catch { checks.database = 'error'; } const healthy = Object.values(checks).every(v => v === 'ok'); return Response.json( { status: healthy ? 'ok' : 'degraded', checks }, { status: healthy ? 200 : 503 } ); }
Return 200 when healthy, 503 when degraded. Load balancers stop routing to instances that return 503 — this is how you get automatic failover when a database connection fails.
Where We Go From Here
The Practitioner phase is complete. You can build, authenticate, cache, test, configure, and deploy production Next.js applications. The Architect phase begins with A-1 — the RSC internals that explain why the framework behaves the way it does at the protocol level. Understanding the React Flight wire format is what separates engineers who debug Next.js confidently from engineers who guess.