nextjs-deployment
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 /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 /app/.next/standalone ./
COPY /app/.next/static ./.next/static
# Copy public files if they exist
COPY /app/public ./public
USER nextjs
EXPOSE 3000
# Health check
HEALTHCHECK \
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.localor secrets - Use
NEXT_PUBLIC_prefix only for truly public values - Set
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYfor multi-server deployments - Disable telemetry in production with
NEXT_TELEMETRY_DISABLED=1 - Remove
poweredByHeaderin production
Performance Optimization
- Use
output: 'standalone'for minimal Docker images - Enable compression at the reverse proxy level
- Configure CDN for static assets
- Use
next/imagefor 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 /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 /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/public ./public
USER nextjs
EXPOSE 3000
HEALTHCHECK \
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_KEYfor 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)