Next.js: Docker 自托管与部署
最后更新:2026-08-26
自托管部署让你完全掌控应用的运行环境——当合规、成本或网络要求让你无法使用云平台时,Docker 是你最可靠的伙伴。
1. 你将学到
- 理解自托管的核心场景:数据合规 / 成本控制 / 内网部署
- 配置
next.config.js的output: 'standalone'独立部署模式 - 编写多阶段 Dockerfile(依赖 → 构建 → 运行)构建最小化镜像
- 使用 Nginx 作为反向代理和静态资源服务
- 通过 PM2 实现进程守护和自动重启
- 利用 Docker Compose 编排 App + Nginx + PostgreSQL 三容器
2. 一个 DevOps 工程师的真实故事
(1) 痛点:客户要求数据不能出境
Charlie 在一家服务于中东金融机构的 SaaS 公司工作。他们的 TaskFlow 产品需要部署在沙特阿拉伯的本地数据中心——客户要求所有用户数据必须物理存储在沙特境内。
但 Vercel 没有沙特区域的数据中心。Charlie 面临的问题:
| 问题 | 影响 |
|---|---|
| 数据主权合规 | 沙特金融监管要求数据不出境 |
| 网络延迟 | 从欧洲服务器访问延迟 > 200ms |
| 供应商锁定 | Vercel 每月账单 $2,000+ |
| 内网要求 | 客户希望部署在企业内网 |
(2) Docker 自托管的解法
Charlie 用 Docker 构建了可移植的部署包:
# 一次构建,到处运行
docker build -t taskflow:latest .
docker run -p 3000:3000 \
-e DATABASE_URL="postgresql://..." \
-e AUTH_SECRET="..." \
taskflow:latest
(3) 收益
| 维度 | Vercel | Docker 自托管 |
|---|---|---|
| 数据位置 | 仅限 Vercel 区域 | 任意数据中心 |
| 月度成本 | $2,000+ | $300(服务器) |
| 部署延迟 | 全球 ~100ms | 本地 < 20ms |
| 供应商锁定 | 高 | 低(可迁移) |
3. output: 'standalone' 配置
Next.js 16 的 output: 'standalone' 模式会创建一个独立的 Node.js 服务器,包含所有运行所需的文件。
graph TB
A[next.config.js] --> B[output: 'standalone']
B --> C[构建过程]
C --> D[.next/standalone/ 目录]
D --> E[server.js — 独立 HTTP 服务器]
D --> F[.next/static — 静态资源]
D --> G[node_modules — 最小依赖]
D --> H[package.json — 入口配置]
style A fill:#cce5ff
style D fill:#d4edda
(1) 配置 next.config.js
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
// 需要外部化处理的依赖
serverExternalPackages: ['@prisma/client'],
// 生产环境优化
productionBrowserSourceMaps: false,
swcMinify: true,
// 图片优化保留
images: {
unoptimized: false
}
}
module.exports = nextConfig
(2) standalone 输出目录结构
.next/standalone/
├── server.js # 独立 HTTP 服务器(入口)
├── package.json # 运行时依赖声明
├── node_modules/ # 仅生产依赖
├── .next/
│ ├── server/ # 服务端代码
│ ├── static/ # 静态资源
│ ├── build-manifest.json
│ └── ...
├── public/ # 公共静态资源
└── trace # 构建追踪
▶ 示例:验证 standalone 构建
# 构建项目
npm run build
# 查看 standalone 目录大小
du -sh .next/standalone/
# 启动独立服务器
node .next/standalone/server.js
# 在另一个终端验证
curl http://localhost:3000
.next/standalone/ 358M # 总大小
.next/standalone/server.js # 入口文件(自动生成)
4. 多阶段 Docker 构建
多阶段构建将镜像分为三个阶段:依赖安装 → 应用构建 → 最小化运行环境。
# ============================================
# Dockerfile — Next.js 16 多阶段构建
# ============================================
# --- 阶段 1: 依赖安装 ---
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
# --- 阶段 2: 构建 ---
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
# --- 阶段 3: 运行 ---
FROM node:20-alpine AS runner
LABEL stage=runner
RUN 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
# 健康检查
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) 构建与运行
# 构建镜像
docker build -t taskflow:latest .
# 查看镜像大小
docker images taskflow:latest
# 运行容器
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) 各阶段镜像大小对比
| 阶段 | 基础镜像 | 大小 | 包含内容 |
|---|---|---|---|
| deps | node:20-alpine | ~150 MB | node_modules + 系统依赖 |
| build | node:20-alpine | ~450 MB | 源码 + node_modules + 构建产物 |
| runner | node:20-alpine | ~358 MB | standalone + 生产依赖 |
| 裸 node:20-alpine | — | ~126 MB | 基础系统 |
▶ 示例:Docker 运行环境变量注入
# 使用 .env 文件注入环境变量
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 反向代理
Nginx 处理 SSL 终止、静态资源缓存和负载均衡,是生产环境的必备组件。
graph LR
A[用户浏览器] --> B[Nginx :443]
B --> C{路径匹配}
C -->|/_next/static/*| D[Nginx 直接服务<br/>缓存 1 年]
C -->|/api/health| E[Next.js :3000]
C -->|/*| E
B --> F[SSL 终止<br/>Let's Encrypt]
style B fill:#cce5ff
style D fill:#d4edda
(1) 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 证书
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;
# 安全头
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;
# 日志
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# 静态资源缓存(由 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";
}
# 健康检查端点
location /api/health {
proxy_pass http://nextjs_upstream;
access_log off;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# 所有其他请求转发到 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 进程守护
PM2 确保 Node.js 进程在崩溃后自动重启,并提供日志管理和集群模式。
(1) PM2 配置
// ecosystem.config.js
module.exports = {
apps: [{
name: 'taskflow',
script: 'server.js',
cwd: '/app',
// 集群模式(使用所有 CPU 核心)
exec_mode: 'cluster',
instances: 'max',
// 环境变量
env: {
NODE_ENV: 'production',
PORT: 3000,
HOSTNAME: '0.0.0.0'
},
// 日志配置
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,
// 自动重启
max_restarts: 10,
restart_delay: 1000,
min_uptime: 5000,
// 内存监控
max_memory_restart: '500M',
// 健康检查
listen_timeout: 3000,
kill_timeout: 5000
}]
}
(2) PM2 Docker 集成
# 在 runner 阶段安装 PM2
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
# 使用 PM2 启动集群模式
CMD ["pm2-runtime", "start", "ecosystem.config.js"]
▶ 示例:PM2 常用命令
# 查看所有进程
pm2 list
# 查看日志
pm2 logs taskflow
pm2 logs taskflow --lines 100
# 监控资源
pm2 monit
# 重新加载(零停机)
pm2 reload taskflow
# 停止/重启
pm2 stop taskflow
pm2 restart taskflow
# 保存当前进程列表
pm2 save
pm2 startup
7. Docker Compose 三容器编排
Docker Compose 编排 App + Nginx + PostgreSQL 三个容器,一键启动完整环境。
# docker-compose.yml
version: '3.8'
networks:
taskflow-net:
driver: bridge
volumes:
postgres-data:
driver: local
nginx-logs:
driver: local
services:
# === 1. PostgreSQL 数据库 ===
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 应用 ===
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 反向代理 ===
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) 环境变量文件
# .env.production(不要提交到 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) 启动与管理
# 首次启动
docker compose up -d
# 查看日志
docker compose logs -f app
docker compose logs -f nginx
# 重新构建应用
docker compose build app
docker compose up -d app
# 更新数据库迁移
docker compose exec app npx prisma migrate deploy
# 查看运行状态
docker compose ps
# 停止所有服务
docker compose down
# 完全清理(含卷)
docker compose down -v
▶ 示例:docker-compose.override.yml(开发环境)
# docker-compose.override.yml
version: '3.8'
services:
app:
build:
target: build # 开发阶段用 build 而非 runner
environment:
NODE_ENV: development
volumes:
- ./src:/app/src:ro
- ./public:/app/public:ro
command: npm run dev # 使用开发服务器
db:
ports:
- "5432:5432" # 开发时暴露数据库端口
nginx:
ports:
- "3000:80" # 开发时简化端口映射
8. 运行时环境变量注入
Docker 容器的环境变量在运行时注入,而非构建时——这样可以一份镜像部署到多个环境。
graph LR
A[Docker 构建] --> B[镜像<br/>(无环境变量)]
B --> C[运行时注入]
C --> D[开发环境 .env.dev]
C --> E[测试环境 .env.test]
C --> F[生产环境 .env.prod]
D --> G[容器启动]
E --> G
F --> G
G --> H[server.js 读取 process.env]
style B fill:#cce5ff
style G fill:#d4edda
(1) 构建时 vs 运行时变量
| 变量类型 | 注入时机 | 示例 | 存储位置 |
|---|---|---|---|
| 构建时 | docker build |
NEXT_PUBLIC_*、版本号 |
Dockerfile ARG |
| 运行时 | docker run |
DATABASE_URL、AUTH_SECRET |
docker compose env_file |
| 混合 | 两者都需要 | NEXT_PUBLIC_API_URL |
构建时注入给前端,运行时给后端 |
(2) 构建时变量注入
# Dockerfile 使用 ARG 传递构建时变量
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
# 构建时传入变量
docker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.taskflow.com \
--build-arg SENTRY_DSN=https://xxx@sentry.io/123 \
-t taskflow:latest .
▶ 示例:运行时环境验证脚本
// src/lib/env.ts
// 运行时环境变量验证
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. 完整示例:TaskFlow Docker 部署
# ============================================
# 生产部署脚本:deploy.sh
# 功能:构建 → 迁移 → 启动 → 健康检查
# ============================================
#!/bin/bash
set -euo pipefail
echo "=== TaskFlow Production Deployment ==="
# 1. 加载环境变量
if [ ! -f .env.production ]; then
echo "ERROR: .env.production not found"
exit 1
fi
source .env.production
# 2. 构建 Docker 镜像
echo "Building Docker image..."
docker compose build app
# 3. 启动数据库(如果未运行)
echo "Starting database..."
docker compose up -d db
echo "Waiting for database to be ready..."
sleep 5
# 4. 运行数据库迁移
echo "Running database migrations..."
docker compose run --rm app npx prisma migrate deploy
# 5. 启动应用和 Nginx
echo "Starting application and Nginx..."
docker compose up -d app nginx
# 6. 健康检查
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. 清理旧镜像
echo "Cleaning up old images..."
docker image prune -f
# 8. 部署信息
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
// ============================================
// 健康检查 API:被 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 {
// 检查数据库连接
await prisma.$queryRaw`SELECT 1`
checks.checks.database = true
} catch {
checks.checks.database = false
checks.status = 'degraded'
}
try {
// 检查 Redis(如果配置了)
// 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 })
}
❓ 常见问题
output: 'standalone' 和 output: 'export' 有什么区别?standalone 生成一个包含 Node.js 服务器的独立包,支持 SSR/ISR/API Routes 等所有 Next.js 特性。export 生成纯静态 HTML(禁用 SSR),适用于 CDN 托管。Docker 自托管必须使用 standalone 模式。restart: unless-stopped 处理容器级别的崩溃(如 OOM),PM2 处理 Node.js 进程级别的崩溃(如未捕获异常)。PM2 还提供日志轮转、集群模式、零停机重启等 Docker 自身不具备的功能。📖 小节
next.config.js的output: 'standalone'配置是 Docker 自托管的前提,生成独立 Node.js 服务器- 多阶段 Docker 构建(deps → build → runner)将镜像压缩到 ~358MB,降低传输和存储成本
- Nginx 作为反向代理提供 SSL 终止、静态缓存和安全头注入,是生产环境必备
- PM2 提供进程守护、集群模式、零停机重启和日志管理,提升应用可靠性
- Docker Compose 编排 App + Nginx + PostgreSQL 三容器,
docker compose up -d一键启动 - 运行时环境变量通过
docker run -e或env_file注入,实现一份镜像部署多环境
📝 作业
-
基础题(⭐):创建一个包含
output: 'standalone'的next.config.js,编写多阶段 Dockerfile,成功构建并运行docker run后通过curl localhost:3000验证。 -
进阶题(⭐⭐):在 Docker Compose 中添加 Nginx 反向代理容器:(1) 配置 SSL 自签名证书;(2) 添加静态资源缓存规则;(3) 配置
/_next/static缓存 365 天;(4) 验证 HTTPS 访问正常。 -
挑战题(⭐⭐⭐):构建完整的 CI/CD + Docker 自托管流水线:(1) GitHub Actions 自动构建 Docker 镜像并推送到 GHCR;(2) 在目标服务器上通过 SSH 拉取新镜像;(3) 使用 Docker Compose 零停机更新(
docker compose up -d --no-deps --build app);(4) 配置 PM2 集群模式和日志轮转。