Docker Self-Hosting & Deployment
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)
Output:
.next/standalone/ 358M
node .next/standalone/server.js
▲ Next.js 16.0.0
- Local: http://localhost:3000
✓ Ready in 1.2s
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
Output:
Sending build context to Docker daemon 4.096kB
Step 1/8 : FROM node:20-alpine AS deps
---> 1dd67de0f936
...
Successfully built a7b3c2d1e0f9
Successfully tagged shophub:latest
Docker command completed successfully.
d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4
# 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
Output:
Docker operation completed.
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
Output:
Docker image built and container started successfully.
# 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
Output:
PM2 command executed successfully.
PM2 command executed successfully.
PM2 command executed successfully.
PM2 command executed successfully.
PM2 command executed successfully.
PM2 command executed successfully.
┌────┬──────────┬─────────┬─────────┐
│ id │ name │ status │ cpu │
├────┼──────────┼─────────┼─────────┤
│ 0 │ shophub │ online │ 0% │
└────┴──────────┴─────────┴─────────┘
PM2 command executed successfully.
┌────┬──────────┬─────────┬─────────┐
│ id │ name │ status │ cpu │
├────┼──────────┼─────────┼─────────┤
│ 0 │ shophub │ online │ 0% │
└────┴──────────┴─────────┴─────────┘
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)
Output:
Save the above YAML configuration to the specified file path. The settings will take effect on the next server restart.
# 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
Output:
Save the above YAML configuration to the specified file path. The settings will take effect on the next server restart.
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
Output:
Sending build context to Docker daemon 4.096kB
Step 1/8 : FROM node:20-alpine AS deps
---> 1dd67de0f936
...
Successfully built a7b3c2d1e0f9
Successfully tagged shophub:latest
// 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)
}
Output:
Exports: env.
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.



