Next.js Deployment
Deploy Next.js applications to production with Docker, CI/CD pipelines, and comprehensive monitoring.
Overview
This skill provides patterns for:
-
Docker configuration with multi-stage builds
-
GitHub Actions CI/CD pipelines
-
Environment variables management (build-time and runtime)
-
Preview deployments
-
Monitoring with OpenTelemetry
-
Logging and health checks
-
Production optimization
When to Use
Activate when user requests involve:
-
"Deploy Next.js", "Dockerize Next.js", "containerize"
-
"GitHub Actions", "CI/CD pipeline", "automated deployment"
-
"Environment variables", "runtime config", "NEXT_PUBLIC"
-
"Preview deployment", "staging environment"
-
"Monitoring", "OpenTelemetry", "tracing", "logging"
-
"Health checks", "readiness", "liveness"
-
"Production build", "standalone output"
-
"Server Actions encryption key", "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY"
Quick Reference
Output Modes
Mode Use Case Command
standalone
Docker/container deployment output: 'standalone'
export
Static site (no server) output: 'export'
(default) Node.js server deployment next start
Environment Variable Types
Prefix Availability Use Case
NEXT_PUBLIC_
Build-time + Browser Public API keys, feature flags
(no prefix) Server-only Database URLs, secrets
Runtime Server-only Different values per environment
Key Files
File Purpose
Dockerfile
Multi-stage container build
.github/workflows/deploy.yml
CI/CD pipeline
next.config.ts
Build configuration
instrumentation.ts
OpenTelemetry setup
src/app/api/health/route.ts
Health check endpoint
Instructions
Configure Standalone Output
Enable standalone output for optimized Docker deployments:
// next.config.ts import type { NextConfig } from 'next'
const nextConfig: NextConfig = { output: 'standalone', poweredByHeader: false, generateBuildId: async () => { // Use git hash for consistent builds across servers return process.env.GIT_HASH || process.env.GITHUB_SHA || 'build' }, }
export default nextConfig
Create Multi-Stage Dockerfile
Build optimized Docker image with minimal footprint:
syntax=docker/dockerfile:1
FROM node:20-alpine AS base
Install dependencies only when needed
FROM base AS deps RUN apk add --no-cache libc6-compat WORKDIR /app
Install dependencies
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
RUN
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile;
elif [ -f yarn.lock ]; then yarn --frozen-lockfile;
elif [ -f package-lock.json ]; then npm ci;
else echo "Lockfile not found." && exit 1;
fi
Rebuild the source code only when needed
FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . .
Set build-time environment variables
ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production
Generate build ID from git (set during build)
ARG GIT_HASH ENV GIT_HASH=${GIT_HASH}
Server Actions encryption key (CRITICAL for multi-server deployments)
ARG NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ENV NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
RUN
if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build;
elif [ -f yarn.lock ]; then yarn build;
elif [ -f package-lock.json ]; then npm run build;
else npm run build;
fi
Production image, copy all the files and run next
FROM base AS runner WORKDIR /app
ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs
Copy standalone output
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
Copy public files if they exist
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"
CMD ["node", "server.js"]
Set Up GitHub Actions CI/CD
Create automated build and deployment pipeline:
.github/workflows/deploy.yml
name: Build and Deploy
on: push: branches: [main, develop] pull_request: branches: [main]
env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }}
jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Generate Server Actions Key
id: generate-key
run: |
KEY=$(openssl rand -base64 32)
echo "key=$KEY" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
GIT_HASH=${{ github.sha }}
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${{ steps.generate-key.outputs.key }}
deploy-staging: needs: build if: github.ref == 'refs/heads/develop' runs-on: ubuntu-latest environment: name: staging url: https://staging.example.com
steps:
- name: Deploy to staging
run: |
echo "Deploying to staging..."
# Add your deployment commands here
# e.g., kubectl, helm, or platform-specific CLI
deploy-production: needs: build if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: name: production url: https://example.com
steps:
- name: Deploy to production
run: |
echo "Deploying to production..."
# Add your deployment commands here
Manage Environment Variables
Build-Time Variables (next.config.ts)
// next.config.ts import type { NextConfig } from 'next'
const nextConfig: NextConfig = { env: { // These are inlined at build time APP_VERSION: process.env.npm_package_version || '1.0.0', BUILD_DATE: new Date().toISOString(), }, // Public runtime config (available on server and client) publicRuntimeConfig: { apiUrl: process.env.NEXT_PUBLIC_API_URL, featureFlags: { newDashboard: process.env.NEXT_PUBLIC_FF_NEW_DASHBOARD === 'true', }, }, }
export default nextConfig
Runtime Environment Variables
For runtime variables with Docker, use a single image across environments:
// src/lib/env.ts export function getEnv() { return { // Server-only (read at request time) databaseUrl: process.env.DATABASE_URL!, apiKey: process.env.API_KEY!,
// Public (must be prefixed with NEXT_PUBLIC_ at build time)
publicApiUrl: process.env.NEXT_PUBLIC_API_URL!,
} }
// Validate required environment variables export function validateEnv() { const required = ['DATABASE_URL', 'API_KEY', 'NEXT_PUBLIC_API_URL'] const missing = required.filter((key) => !process.env[key])
if (missing.length > 0) {
throw new Error(Missing required environment variables: ${missing.join(', ')})
}
}
Environment Variable Files
.env.local (development - never commit)
DATABASE_URL=postgresql://localhost:5432/mydb API_KEY=dev-key NEXT_PUBLIC_API_URL=http://localhost:3000/api
.env.production (production defaults)
NEXT_PUBLIC_API_URL=https://api.example.com
.env.example (template for developers)
DATABASE_URL= API_KEY= NEXT_PUBLIC_API_URL=
Implement Health Checks
Create a health check endpoint for load balancers and orchestrators:
// src/app/api/health/route.ts import { NextResponse } from 'next/server'
export const dynamic = 'force-dynamic'
export async function GET() { const checks = { status: 'healthy', timestamp: new Date().toISOString(), version: process.env.npm_package_version || 'unknown', buildId: process.env.GIT_HASH || 'unknown', uptime: process.uptime(), checks: { memory: checkMemory(), // Add database, cache, etc. checks here }, }
const isHealthy = Object.values(checks.checks).every((check) => check.status === 'ok')
return NextResponse.json(checks, { status: isHealthy ? 200 : 503 }) }
function checkMemory() { const used = process.memoryUsage() const threshold = 1024 * 1024 * 1024 // 1GB
return {
status: used.heapUsed < threshold ? 'ok' : 'warning',
heapUsed: ${Math.round(used.heapUsed / 1024 / 1024)}MB,
heapTotal: ${Math.round(used.heapTotal / 1024 / 1024)}MB,
}
}
Set Up OpenTelemetry Monitoring
Add observability with OpenTelemetry:
// instrumentation.ts import { registerOTel } from '@vercel/otel'
export function register() { registerOTel({ serviceName: process.env.OTEL_SERVICE_NAME || 'next-app', serviceVersion: process.env.npm_package_version, }) }
// instrumentation.node.ts import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' import { NodeSDK } from '@opentelemetry/sdk-node' import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node' import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' import { resourceFromAttributes } from '@opentelemetry/resources' import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'
const sdk = new NodeSDK({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'next-app', [ATTR_SERVICE_VERSION]: process.env.npm_package_version || '1.0.0', }), spanProcessor: new SimpleSpanProcessor( new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, }) ), metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ url: process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, }), }), })
sdk.start()
// Graceful shutdown process.on('SIGTERM', () => { sdk.shutdown() .then(() => console.log('OpenTelemetry terminated')) .catch((err) => console.error('OpenTelemetry termination error', err)) .finally(() => process.exit(0)) })
// src/lib/logger.ts interface LogEntry { level: string message: string timestamp: string requestId?: string [key: string]: unknown }
export function createLogger(requestId?: string) { const base = { timestamp: new Date().toISOString(), ...(requestId && { requestId }), }
return { info: (message: string, meta?: Record<string, unknown>) => { log({ level: 'info', message, ...base, ...meta }) }, warn: (message: string, meta?: Record<string, unknown>) => { log({ level: 'warn', message, ...base, ...meta }) }, error: (message: string, error?: Error, meta?: Record<string, unknown>) => { log({ level: 'error', message, error: error?.message, stack: error?.stack, ...base, ...meta }) }, } }
function log(entry: LogEntry) {
// In production, send to structured logging service
// In development, pretty print
if (process.env.NODE_ENV === 'production') {
console.log(JSON.stringify(entry))
} else {
console.log([${entry.level.toUpperCase()}] ${entry.message}, entry)
}
}
Configure Preview Deployments
Set up preview environments for pull requests:
.github/workflows/preview.yml
name: Preview Deployment
on: pull_request: types: [opened, synchronize, closed]
jobs: deploy-preview: if: github.event.action != 'closed' runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NEXT_PUBLIC_API_URL: https://staging-api.example.com
NEXT_PUBLIC_PREVIEW: 'true'
- name: Deploy to Preview
run: |
# Example: Deploy to Vercel, Netlify, or your platform
# npx vercel --token=${{ secrets.VERCEL_TOKEN }} --prebuilt
echo "Deploying preview for PR #${{ github.event.number }}"
cleanup-preview: if: github.event.action == 'closed' runs-on: ubuntu-latest
steps:
- name: Cleanup Preview
run: |
echo "Cleaning up preview for PR #${{ github.event.number }}"
Handle Server Actions Encryption
CRITICAL: For multi-server deployments, set a consistent encryption key:
Generate a key locally
openssl rand -base64 32
Set in GitHub Actions (Secret)
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
Or generate in workflow (see GitHub Actions example above)
In Dockerfile - pass as build arg
ARG NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ENV NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
Without this key, Server Actions will fail with "Failed to find Server Action" errors in multi-server deployments.
Best Practices
Docker Best Practices
-
Use multi-stage builds to minimize final image size
-
Enable standalone output for production deployments
-
Set proper permissions with non-root user
-
Include health checks for orchestration platforms
-
Cache dependencies in CI/CD for faster builds
Security Checklist
-
Never commit .env.local or secrets
-
Use NEXT_PUBLIC_ prefix only for truly public values
-
Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY for multi-server deployments
-
Disable telemetry in production with NEXT_TELEMETRY_DISABLED=1
-
Remove poweredByHeader in production
Performance Optimization
-
Use output: 'standalone' for minimal Docker images
-
Enable compression at the reverse proxy level
-
Configure CDN for static assets
-
Use next/image for optimized images
Environment Management
-
Use same Docker image across all environments
-
Inject runtime configuration via environment variables
-
Use different tags for different environments (latest , staging )
-
Implement feature flags for gradual rollouts
Examples
Example 1: Complete Production Setup
Input: Set up production deployment with Docker and GitHub Actions
Output:
// next.config.ts import type { NextConfig } from 'next'
const nextConfig: NextConfig = { output: 'standalone', poweredByHeader: false, compress: true, generateBuildId: async () => process.env.GIT_HASH || 'build', env: { APP_VERSION: process.env.npm_package_version, }, }
export default nextConfig
Dockerfile
FROM node:20-alpine AS base
FROM base AS deps RUN apk add --no-cache libc6-compat WORKDIR /app COPY package.json package-lock.json* ./ RUN npm ci
FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production ARG GIT_HASH ENV GIT_HASH=${GIT_HASH} ARG NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ENV NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY} RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"
CMD ["node", "server.js"]
.github/workflows/deploy.yml
name: Deploy on: push: branches: [main] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - id: key run: echo "key=$(openssl rand -base64 32)" >> $GITHUB_OUTPUT - uses: docker/build-push-action@v5 with: push: true tags: ${{ steps.meta.outputs.tags }} build-args: | GIT_HASH=${{ github.sha }} NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${{ steps.key.outputs.key }}
Example 2: Runtime Environment Variables
Input: Configure different API URLs for staging and production
Output:
// src/lib/env.ts const envSchema = { server: { DATABASE_URL: process.env.DATABASE_URL!, API_SECRET: process.env.API_SECRET!, }, public: { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL!, NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME || 'MyApp', }, }
export function getServerEnv() { return envSchema.server }
export function getPublicEnv() { return envSchema.public }
// Use in Server Components import { getServerEnv } from '@/lib/env'
async function fetchData() { const env = getServerEnv() // Use env.DATABASE_URL }
// Use in Client Components import { getPublicEnv } from '@/lib/env'
function ApiClient() { const env = getPublicEnv() // Use env.NEXT_PUBLIC_API_URL }
docker-compose.yml for local development
version: '3.8' services: app: build: . ports: - "3000:3000" environment: - DATABASE_URL=postgresql://db:5432/myapp - NEXT_PUBLIC_API_URL=http://localhost:3000/api
Example 3: OpenTelemetry Integration
Input: Add distributed tracing to Next.js application
Output:
// instrumentation.ts export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { await import('./instrumentation.node') } }
// instrumentation.node.ts import { NodeSDK } from '@opentelemetry/sdk-node' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { resourceFromAttributes } from '@opentelemetry/resources' import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'
const sdk = new NodeSDK({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'next-app', }), spanProcessor: new SimpleSpanProcessor( new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, }) ), })
sdk.start()
// src/app/api/users/route.ts import { trace } from '@opentelemetry/api'
export async function GET() { const tracer = trace.getTracer('next-app')
return tracer.startActiveSpan('fetch-users', async (span) => { try { const users = await db.user.findMany() span.setAttribute('user.count', users.length) return NextResponse.json(users) } catch (error) { span.recordException(error as Error) throw error } finally { span.end() } }) }
Constraints and Warnings
Constraints
-
Standalone output requires Node.js 18+
-
Server Actions encryption key must be consistent across all instances
-
Runtime environment variables only work with output: 'standalone'
-
Health checks need explicit route handler
-
OpenTelemetry requires instrumentation.ts at project root
Warnings
-
Never use NEXT_PUBLIC_ prefix for sensitive values
-
Always set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY for multi-server deployments
-
Without health checks, orchestrators may send traffic to unhealthy instances
-
Runtime env vars don't work with static export (output: 'export' )
-
Cache build artifacts in CI/CD to speed up builds
References
Consult these files for detailed patterns:
-
references/docker-patterns.md - Advanced Docker configurations, multi-arch builds, optimization
-
references/github-actions.md - Complete CI/CD workflows, testing, security scanning
-
references/monitoring.md - OpenTelemetry, logging, alerting, dashboards
-
references/deployment-platforms.md - Platform-specific guides (Vercel, AWS, GCP, Azure)