Self-Hosted Docker and Deployment: Deploy Your Next.js Application Independently
Self-hosted deployments give you complete control over your application’s runtime environment—when compliance, cost, or network requirements prevent you from using a cloud platform, Docker is your most reliable partner.
1. What You'll Learn
- Understanding the core use cases for self-hosting: data compliance / cost control / on-premises deployment
- Configure
next.config.jsinoutput: 'standalone'standalone deployment mode - Write a multi-stage Dockerfile (dependencies → build → run) to build a minimal image
- Using Nginx as a reverse proxy and static content server
- Implementing process daemoning and automatic restart using PM2
- Use Docker Compose to orchestrate three containers: App, Nginx, and PostgreSQL
2. A True Story of a DevOps Engineer
(1) Pain Point: The client requires that data not be transferred outside the country
Charlie works at a SaaS company that serves financial institutions in the Middle East. Their TaskFlow product needs to be deployed in a local data center in Saudi Arabia—the client requires that all user data be physically stored within Saudi Arabia.
However, Vercel does not have a data center in Saudi Arabia. The problem Charlie faces:
| Issue | Impact |
|---|---|
| Data Sovereignty Compliance | Saudi Arabia’s Financial Regulatory Requirements Prohibit Data Transfer Abroad |
| Network Latency | Latency when accessing from European servers > 200 ms |
| Vendor Lock-in | Vercel Monthly Bill of $2,000+ |
| Internal Network Requirements | The customer wishes to deploy the solution on the corporate internal network |
(2) Solutions for Self-Hosted Docker
Charlie built portable deployment packages using Docker:
# Build Once,Running everywhere
docker build -t taskflow:latest .
docker run -p 3000:3000 \
-e DATABASE_URL="postgresql://..." \
-e AUTH_SECRET="..." \
taskflow:latest
(3) Revenue
| Dimension | Vercel | Self-hosted Docker |
|---|---|---|
| Data Location | Vercel region only | Any data center |
| Monthly Costs | $2,000+ | $300 (server) |
| Deployment Latency | Global ~100 ms | Local < 20 ms |
| Vendor Lock-in | High | Low (Migrable) |
3. output: 'standalone' Configuration
The output: 'standalone' mode in Next.js 16 creates a standalone Node.js server that contains all the files needed to run the application.
graph TB
A[next.config.js] --> B[output: 'standalone']
B --> C[Build Process]
C --> D[.next/standalone/ Table of Contents]
D --> E[server.js — Independence HTTP Server]
D --> F[.next/static — Static Resources]
D --> G[node_modules — Minimal Dependencies]
D --> H[package.json — Input Configuration]
style A fill:#cce5ff
style D fill:#d4edda
(1) Configuring next.config.js
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
// Dependencies that require external processing
serverExternalPackages: ['@prisma/client'],
// Production Environment Optimization
productionBrowserSourceMaps: false,
swcMinify: true,
// Image Optimization Retained
images: {
unoptimized: false
}
}
module.exports = nextConfig
(2) standalone output directory structure
.next/standalone/
├── server.js # Independence HTTP Server(Entrance)
├── package.json # Runtime Dependency Declarations
├── node_modules/ # Build-only dependencies
├── .next/
│ ├── server/ # Server-side code
│ ├── static/ # Static Resources
│ ├── build-manifest.json
│ └── ...
├── public/ # Public Static Resources
└── trace # Build and Track
▶ Example: Verifying a standalone build
# Build Project
npm run build
# View standalone Directory Size
du -sh .next/standalone/
# Start a Dedicated Server
node .next/standalone/server.js
# Verify on another terminal
curl http://localhost:3000
.next/standalone/ 358M # Total Size
.next/standalone/server.js # Input File(Automatically Generated)
4. Multi-stage Docker Builds
Multi-stage builds divide the image into three stages: dependency installation → application build → minimal runtime environment.
# ============================================
# Dockerfile — Next.js 16 Multi-stage Construction
# ============================================
# --- Phase 1: Dependency Installation ---
FROM node:20-alpine AS deps
LABEL stage=deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json pnpm-lock.yaml ./
RUN npm ci --only=production && \
npm cache clean --force
# --- Phase 2: Build ---
FROM node:20-alpine AS build
LABEL stage=build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# --- Phase 3: Run ---
FROM node:20-alpine AS runner
LABEL stage=runner
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
WORKDIR /app
# Copy the build artifacts
COPY --from=build --chown=nextjs:nodejs \
/app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs \
/app/.next/static ./.next/static
COPY --from=build --chown=nextjs:nodejs \
/app/public ./public
# Health Checkup
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NODE_ENV=production
CMD ["node", "server.js"]
(1) Building and Running
# Build an image
docker build -t taskflow:latest .
# View Image Size
docker images taskflow:latest
# Run a Container
docker run -d \
--name taskflow-app \
-p 3000:3000 \
-e DATABASE_URL="postgresql://user:pass@host:5432/taskflow" \
-e AUTH_SECRET="your-secret-key" \
-e NEXT_PUBLIC_API_URL="https://api.taskflow.local" \
--restart unless-stopped \
taskflow:latest
(2) Comparison of Image Sizes Across Stages
| Stage | Base Image | Size | Contents |
|---|---|---|---|
| deps | node:20-alpine | ~150 MB | node_modules + system dependencies |
| build | node:20-alpine | ~450 MB | source code + node_modules + build artifacts |
| runner | node:20-alpine | ~358 MB | standalone + production dependencies |
| Bare node:20-alpine | — | ~126 MB | Base system |
▶ Example: Environment Variable Injection in Docker
# Usage .env Injecting Environment Variables into a File
cat > .env.production << EOF
DATABASE_URL=postgresql://user:pass@db:5432/taskflow
AUTH_SECRET=super-secret-key
NEXT_PUBLIC_API_URL=https://api.taskflow.local
NEXT_PUBLIC_POSTHOG_KEY=phc_xxxx
REDIS_URL=redis://redis:6379
EOF
docker run -d \
--name taskflow-app \
--env-file .env.production \
-p 3000:3000 \
--network taskflow-net \
taskflow:latest
5. Nginx Reverse Proxy
Nginx handles SSL termination, static resource caching, and load balancing, making it an essential component in production environments.
graph LR
A[User's browser] --> B[Nginx :443]
B --> C{Path Matching}
C -->|/_next/static/*| D[Nginx Direct Services<br/>Cache 1 year]
C -->|/api/health| E[Next.js :3000]
C -->|/*| E
B --> F[SSL Termination<br/>Let's Encrypt]
style B fill:#cce5ff
style D fill:#d4edda
Nginx configuration
# nginx/nginx.conf
upstream nextjs_upstream {
server app:3000;
keepalive 64;
}
server {
listen 80;
server_name taskflow.local;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name taskflow.local;
# SSL Certificate
ssl_certificate /etc/nginx/ssl/taskflow.crt;
ssl_certificate_key /etc/nginx/ssl/taskflow.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Safety Head
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Log
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Caching of Static Resources (processed by Next.js)
location /_next/static/ {
proxy_pass http://nextjs_upstream;
expires 365d;
add_header Cache-Control "public, immutable";
}
location /static/ {
proxy_pass http://nextjs_upstream;
expires 30d;
add_header Cache-Control "public";
}
# Health Check Endpoints
location /api/health {
proxy_pass http://nextjs_upstream;
access_log off;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# All other requests are forwarded to Next.js
location / {
proxy_pass http://nextjs_upstream;
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_read_timeout 60s;
proxy_send_timeout 60s;
}
}
6. PM2 Process Manager
PM2 ensures that Node.js processes automatically restart after a crash and provides log management and cluster mode.
(1) PM2 Configuration
// ecosystem.config.js
module.exports = {
apps: [{
name: 'taskflow',
script: 'server.js',
cwd: '/app',
// Cluster Mode(Use all CPU Core)
exec_mode: 'cluster',
instances: 'max',
// Environment Variables
env: {
NODE_ENV: 'production',
PORT: 3000,
HOSTNAME: '0.0.0.0'
},
// Log Configuration
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: '/var/log/pm2/taskflow-error.log',
out_file: '/var/log/pm2/taskflow-out.log',
merge_logs: true,
// Automatic Restart
max_restarts: 10,
restart_delay: 1000,
min_uptime: 5000,
// Memory Monitoring
max_memory_restart: '500M',
// Health Checkup
listen_timeout: 3000,
kill_timeout: 5000
}]
}
(2) PM2 Docker Integration
# Install PM2 in runner stage
FROM node:20-alpine AS runner
RUN npm install -g pm2 && \
addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
WORKDIR /app
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=build --chown=nextjs:nodejs /app/public ./public
COPY --chown=nextjs:nodejs ecosystem.config.js ./
USER nextjs
EXPOSE 3000
# Usage PM2 Enable Cluster Mode
CMD ["pm2-runtime", "start", "ecosystem.config.js"]
▶ Example: Common PM2 Commands
# View All Processes
pm2 list
# View Logs
pm2 logs taskflow
pm2 logs taskflow --lines 100
# Monitor Resources
pm2 monit
# Reload(Zero Downtime)
pm2 reload taskflow
# Stop/Restart
pm2 stop taskflow
pm2 restart taskflow
# Save the current list of processes
pm2 save
pm2 startup
7. Docker Compose: Orchestrating Three Containers
Docker Compose orchestrates three containers—App, Nginx, and PostgreSQL—to launch the entire environment with a single click.
# docker-compose.yml
version: '3.8'
networks:
taskflow-net:
driver: bridge
volumes:
postgres-data:
driver: local
nginx-logs:
driver: local
services:
# === 1. PostgreSQL Database ===
db:
image: postgres:16-alpine
container_name: taskflow-db
restart: unless-stopped
networks:
- taskflow-net
volumes:
- postgres-data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d
environment:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: taskflow
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskflow"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"
# === 2. Next.js Applications ===
app:
build:
context: .
dockerfile: Dockerfile
target: runner
image: taskflow:latest
container_name: taskflow-app
restart: unless-stopped
networks:
- taskflow-net
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3000
HOSTNAME: "0.0.0.0"
DATABASE_URL: postgresql://taskflow:${DB_PASSWORD}@db:5432/taskflow
AUTH_SECRET: ${AUTH_SECRET}
AUTH_URL: ${AUTH_URL}
NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL}
NEXT_PUBLIC_POSTHOG_KEY: ${POSTHOG_KEY:-}
env_file:
- .env.production
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 3s
retries: 3
# === 3. Nginx Reverse Proxy ===
nginx:
image: nginx:alpine
container_name: taskflow-nginx
restart: unless-stopped
networks:
- taskflow-net
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- nginx-logs:/var/log/nginx
depends_on:
app:
condition: service_healthy
(1) Environment Variable File
# .env.production(Do not submit to Git)
DB_PASSWORD=StrongPassword123!
AUTH_SECRET=your-auth-secret-key-min-32-chars
AUTH_URL=https://auth.taskflow.local
PUBLIC_API_URL=https://api.taskflow.local
POSTHOG_KEY=phc_exampleKey123
(2) Startup and Management
# First Launch
docker compose up -d
# View Logs
docker compose logs -f app
docker compose logs -f nginx
# Rebuild the application
docker compose build app
docker compose up -d app
# Update Database Migration
docker compose exec app npx prisma migrate deploy
# View Operating Status
docker compose ps
# Stop all services
docker compose down
# Completely Clean Up(Han Juan)
docker compose down -v
▶ Example: docker-compose.override.yml (development environment)
# docker-compose.override.yml
version: '3.8'
services:
app:
build:
target: build # For use during the development phase build rather than runner
environment:
NODE_ENV: development
volumes:
- ./src:/app/src:ro
- ./public:/app/public:ro
command: npm run dev # Using the Development Server
db:
ports:
- "5432:5432" # Exposing the Database Port During Development
nginx:
ports:
- "3000:80" # Simplify Port Mapping During Development
8. Runtime Environment Variable Injection
Environment variables for Docker containers are injected at runtime, not during the build process—this allows a single image to be deployed to multiple environments.
graph LR
A[Docker Build] --> B[Image<br/>(No environment variables)]
B --> C[Runtime Injection]
C --> D[Development Environment .env.dev]
C --> E[Test Environment .env.test]
C --> F[Production Environment .env.prod]
D --> G[Container Startup]
E --> G
F --> G
G --> H[server.js Read process.env]
style B fill:#cce5ff
style G fill:#d4edda
(1) Build-time vs. Runtime Variables
| Variable Type | Injection Timing | Example | Storage Location |
|---|---|---|---|
| During Build | docker build |
NEXT_PUBLIC_*, Version Number |
Dockerfile ARG |
| Runtime | docker run |
DATABASE_URL、AUTH_SECRET |
docker compose env_file |
| Mixed | Both are required | NEXT_PUBLIC_API_URL |
Injected into the front end during build time, and into the back end at runtime |
(2) Variable Injection During Build
# Dockerfile Usage ARG Passing Build-Time Variables
FROM node:20-alpine AS build
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ARG SENTRY_DSN
ENV SENTRY_DSN=$SENTRY_DSN
RUN npm run build
# Variables passed during construction
docker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.taskflow.com \
--build-arg SENTRY_DSN=https://xxx@sentry.io/123 \
-t taskflow:latest .
▶ Example: Runtime Environment Validation Script
// src/lib/env.ts
// Runtime Environment Variable Validation
function getRequiredEnvVar(name: string): string {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
export const env = {
databaseUrl: getRequiredEnvVar('DATABASE_URL'),
authSecret: getRequiredEnvVar('AUTH_SECRET'),
authUrl: process.env.AUTH_URL || 'http://localhost:3000',
publicApiUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
posthogKey: process.env.NEXT_PUBLIC_POSTHOG_KEY,
nodeEnv: process.env.NODE_ENV || 'development',
isProduction: process.env.NODE_ENV === 'production',
port: parseInt(process.env.PORT || '3000', 10)
}
9. Complete Example: TaskFlow Docker Deployment
# ============================================
# Production Deployment Scripts:deploy.sh
# Features:Build → Migration → Start → Health Checkup
# ============================================
#!/bin/bash
set -euo pipefail
echo "=== TaskFlow Production Deployment ==="
# 1. Load Environment Variables
if [ ! -f .env.production ]; then
echo "ERROR: .env.production not found"
exit 1
fi
source .env.production
# 2. Build Docker Image
echo "Building Docker image..."
docker compose build app
# 3. Start the database(If it is not running)
echo "Starting database..."
docker compose up -d db
echo "Waiting for database to be ready..."
sleep 5
# 4. Run the database migration
echo "Running database migrations..."
docker compose run --rm app npx prisma migrate deploy
# 5. Launch the app and Nginx
echo "Starting application and Nginx..."
docker compose up -d app nginx
# 6. Health Checkup
echo "Running health check..."
for i in {1..10}; do
if curl -s -o /dev/null -w "%{http_code}" http://localhost:80/api/health | grep -q 200; then
echo "Health check passed!"
break
fi
echo "Waiting... ($i/10)"
sleep 3
done
# 7. Clean up old images
echo "Cleaning up old images..."
docker image prune -f
# 8. Deployment Information
echo ""
echo "=== Deployment Complete ==="
echo "App: http://localhost:80"
echo "API: http://localhost:80/api/health"
echo "DB: postgresql://taskflow@localhost:5432/taskflow"
echo "Logs: docker compose logs -f app"
echo "Restart: docker compose restart app"
// src/app/api/health/route.ts
// ============================================
// Health Check API: called by Docker HEALTHCHECK
// ============================================
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
const checks = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
checks: {} as Record<string, boolean>
}
try {
// Check the database connection
await prisma.$queryRaw`SELECT 1`
checks.checks.database = true
} catch {
checks.checks.database = false
checks.status = 'degraded'
}
try {
// Inspection Redis(If configured)
// await redis.ping()
checks.checks.redis = true
} catch {
checks.checks.redis = false
if (!checks.checks.database) {
checks.status = 'unhealthy'
}
}
const statusCode = checks.status === 'healthy' ? 200 : 503
return NextResponse.json(checks, { status: statusCode })
}
❓ FAQ
output: 'standalone' and output: 'export'?standalone generates a standalone package that includes a Node.js server and supports all Next.js features, such as SSR, ISR, and API Routes. export generates pure static HTML (with SSR disabled), which is suitable for CDN hosting. For self-hosting with Docker, you must use the standalone mode.restart: unless-stopped handles container-level crashes (such as OOM), while PM2 handles Node.js process-level crashes (such as uncaught exceptions). PM2 also provides features that Docker itself does not offer, such as log rotation, cluster mode, and zero-downtime restarts.📖 Summary
- The
next.config.jsconfiguration foroutput: 'standalone'is a prerequisite for self-hosted Docker and generates a standalone Node.js server - A multi-stage Docker build (deps → build → runner) compresses the image to ~358MB, reducing transmission and storage costs
- Nginx acts as a reverse proxy, providing SSL termination, static caching, and secure header injection, making it essential for production environments
- PM2 provides process daemons, cluster mode, zero-downtime restarts, and log management to enhance application reliability
- Docker Compose orchestrates three containers: App, Nginx, and PostgreSQL;
docker compose up -done-click startup - Runtime environment variables are injected via
docker run -eorenv_file, enabling a single image to be deployed across multiple environments.
📝 Exercises
-
Basic Problem (⭐): Create a
next.config.jsthat containsoutput: 'standalone', write a multi-stage Dockerfile, successfully build and rundocker run, and then verify it usingcurl localhost:3000. -
Advanced Exercise (⭐⭐): Add an Nginx reverse proxy container to Docker Compose: (1) Configure a self-signed SSL certificate; (2) Add caching rules for static resources; (3) Configure
/_next/staticto be cached for 365 days; (4) Verify that HTTPS access works properly. -
Challenge (⭐⭐⭐): Build a complete self-hosted CI/CD + Docker pipeline: (1) Use GitHub Actions to automatically build Docker images and push them to GHCR; (2) Pull the new images to the target server via SSH; (3) Use Docker Compose for zero-downtime updates (
docker compose up -d --no-deps --build app); (4) Configure PM2 cluster mode and log rotation.



