404 Not Found

404 Not Found


nginx

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


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:

BASH
# 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.

100%
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

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

TEXT
.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

BASH
# 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
💻 Output:

TEXT
.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
# ============================================
# 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

BASH
# 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

BASH
# 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.

100%
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/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

JS
// 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

DOCKERFILE
# 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

BASH
# 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.

YAML
# 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

BASH
# .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

BASH
# 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)

YAML
# 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.

100%
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_URLAUTH_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
# 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
BASH
# 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

TS
// 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

BASH
# ============================================
# 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"
TS
// 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

Q What is the difference between output: 'standalone' and output: 'export'?
A 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.
Q Why is a multi-stage build better than a single-stage build?
A (1) Smaller images—the runtime stage contains only the minimum files required for execution (358 MB vs. 1.2 GB); (2) More secure—build tools and source code are not included in the final image; (3) More efficient build caching—since the dependency layers rarely change, Docker cache layers can be reused.
Q Is Nginx required or optional?
A Nginx is strongly recommended for production environments: (1) SSL termination—handling HTTPS certificates; (2) static resource caching—reducing the load on Node.js; (3) Security header injection—XSS, CSP, HSTS, etc.; (4) Load balancing—distributing requests across multiple instances. In simple internal network environments, you can skip this step and expose the Next.js port directly.
Q Do I need to use both PM2 and Docker’s restart policy?
A We recommend using both. Docker’s 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.
Q Which should I choose, Docker Compose or Kubernetes?
A For single-server deployments, choose Docker Compose (simple configuration, low learning curve); for multi-server clusters, automatic scaling, and service discovery, choose Kubernetes. For small to medium-sized teams (1–5 servers), Docker Compose in Swarm mode is sufficient for most scenarios.

📖 Summary


📝 Exercises

  1. Basic Problem (⭐): Create a next.config.js that contains output: 'standalone', write a multi-stage Dockerfile, successfully build and run docker run, and then verify it using curl localhost:3000.

  2. 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/static to be cached for 365 days; (4) Verify that HTTPS access works properly.

  3. 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.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏