DeployEasy
Menu
Next.jsTrung bình

Deploy Next.js standalone bằng Docker và Nginx trên VPS

Hướng dẫn self-host Next.js production với output standalone, Docker multi-stage, biến môi trường, static files, Nginx và healthcheck.

· 9 phút đọc · 1.794 từ
Mục lục bài viết

Next.js có thể deploy trên nền tảng managed hoặc self-host trên Node.js server, Docker image và static export. Khi tự host ứng dụng có SSR hoặc Route Handler, output: "standalone" giúp tạo một thư mục runtime tối giản hơn cho production.

Bài viết này trình bày mô hình:

Internet

Nginx :443

Next.js container :3000

Khi nào cần self-host Next.js?

Self-host phù hợp khi:

  • Cần chạy trên VPS riêng.
  • Có hạ tầng Docker hiện có.
  • Cần kiểm soát runtime và network.
  • Muốn đặt frontend gần backend/database.
  • Có yêu cầu riêng về dữ liệu hoặc triển khai.

Vercel có thể đơn giản hơn nếu bạn không muốn tự quản lý:

  • Server.
  • Reverse proxy.
  • Cache.
  • Scaling.
  • Log.
  • Backup.
  • Security update.

Self-host nghĩa là bạn chịu trách nhiệm vận hành những lớp đó.

Static export và standalone khác nhau

Static export

Dùng khi mọi trang có thể tạo thành HTML/CSS/JS tĩnh.

const nextConfig = {
  output: "export",
};

Kết quả thường nằm trong:

out/

Không phù hợp với tính năng cần server runtime.

Standalone

const nextConfig = {
  output: "standalone",
};

Next.js tạo:

.next/standalone/

Thư mục này chứa server tối giản và file dependency cần thiết được trace.

Standalone vẫn là ứng dụng Node.js, không phải website tĩnh.

Cấu hình Next.js

next.config.ts:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

Build:

npm ci
npm run build

Kiểm tra:

ls -la .next/standalone

Những file standalone không tự copy

Theo hướng dẫn Next.js, .next/standalone có thể không tự chứa public.next/static. Khi tạo image runtime, bạn cần copy chúng vào vị trí phù hợp.

Các phần thường cần:

.next/standalone
.next/static
public

Dockerfile multi-stage

FROM node:22-bookworm-slim AS deps

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-bookworm-slim AS builder

WORKDIR /app

ENV NEXT_TELEMETRY_DISABLED=1

COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

FROM node:22-bookworm-slim AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

RUN groupadd --system --gid 1001 nodejs \
  && useradd --system --uid 1001 --gid nodejs nextjs

COPY --from=builder /app/public ./public

COPY --from=builder \
  --chown=nextjs:nodejs \
  /app/.next/standalone \
  ./

COPY --from=builder \
  --chown=nextjs:nodejs \
  /app/.next/static \
  ./.next/static

USER nextjs

EXPOSE 3000

CMD ["node", "server.js"]

Vì sao đặt HOSTNAME=0.0.0.0?

Bên trong container, server cần lắng nghe trên interface có thể nhận traffic từ Docker network.

0.0.0.0:3000

Nếu chỉ bind 127.0.0.1 bên trong container, port mapping có thể không truy cập được từ bên ngoài container.

Trên host, vẫn có thể chỉ publish localhost:

ports:
  - "127.0.0.1:3000:3000"

.dockerignore

node_modules
.next
out
.git
.github
.env
.env.*
*.log
coverage
README.md
compose*.yaml

Không copy .env vào image.

Build image

docker build \
  -t deployeasy-web:1.0.0 \
  .

Kiểm tra:

docker image ls deployeasy-web

Chạy thử

docker run \
  --rm \
  --name deployeasy-web-test \
  -p 127.0.0.1:3000:3000 \
  --env-file .env.production \
  deployeasy-web:1.0.0

Test:

curl -I http://127.0.0.1:3000

Biến môi trường build-time và runtime

Đây là phần dễ sai trong Next.js.

Biến public

Biến có prefix:

NEXT_PUBLIC_

thường được inline vào JavaScript client tại thời điểm build.

Ví dụ:

NEXT_PUBLIC_API_URL=https://api.deployeasy.vn

Nếu đổi giá trị sau khi image đã build, client bundle có thể vẫn giữ giá trị cũ. Bạn cần build lại.

Không đặt secret trong NEXT_PUBLIC_*.

Biến server-only

Ví dụ:

DATABASE_URL=...
INTERNAL_API_KEY=...

Chỉ được đọc trong server code và không gửi xuống client, nếu bạn không vô tình expose.

Runtime env có thể inject lúc container start.

Không copy .env.production vào image

Sai:

COPY .env.production .env.production

Image có thể được export hoặc inspect.

Dùng Compose:

services:
  web:
    image: deployeasy-web:1.0.0
    env_file:
      - .env.production

Hoặc:

environment:
  INTERNAL_API_URL: ${INTERNAL_API_URL}

Compose production

name: deployeasy-web

services:
  web:
    image: deployeasy-web:1.0.0
    restart: unless-stopped

    env_file:
      - .env.production

    ports:
      - "127.0.0.1:3000:3000"

    healthcheck:
      test:
        [
          "CMD",
          "node",
          "-e",
          "fetch('http://127.0.0.1:3000').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Chạy:

docker compose up -d

Kiểm tra:

docker compose ps
docker compose logs -f web

Nginx reverse proxy

server {
    listen 80;
    listen [::]:80;

    server_name deployeasy.vn www.deployeasy.vn;

    location / {
        proxy_pass http://127.0.0.1:3000;

        proxy_http_version 1.1;
        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;
    }
}

Kiểm tra:

sudo nginx -t
sudo systemctl reload nginx

Sau đó cài HTTPS.

WebSocket và streaming

Một số tính năng streaming hoặc upgrade connection cần proxy config phù hợp.

Với WebSocket:

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

Không thêm thiếu kiểm soát cho mọi location nếu app không cần.

Nginx buffering có thể ảnh hưởng streaming. Cần test tính năng thực tế và tham khảo hướng dẫn self-hosting Next.js hiện hành.

Cache khi self-host

Next.js có nhiều lớp cache. Khi chạy nhiều instance, cache local của mỗi instance có thể không đồng bộ.

Các tình huống cần chú ý:

  • Incremental cache.
  • Image optimization cache.
  • Data cache.
  • On-demand revalidation.
  • Multiple replicas.
  • Rolling deploy.

Nếu chỉ một instance trên VPS, mô hình đơn giản hơn nhưng vẫn cần hiểu cache khi cập nhật nội dung.

Build ID và rolling deployment

Khi deploy nhiều instance khác version cùng lúc, HTML từ instance mới có thể tham chiếu asset không tồn tại trên instance cũ.

Giải pháp có thể gồm:

  • Shared/static asset strategy.
  • Immutable asset.
  • Build ID nhất quán khi phù hợp.
  • Load balancer session/staging.
  • Blue-green.
  • Không xóa asset cũ quá sớm.

Với một VPS một container, restart tạo downtime ngắn nhưng ít vấn đề version chéo hơn.

Image optimization

Self-host Next.js cần các dependency và tài nguyên phù hợp cho image optimization.

Nếu dùng loader bên ngoài hoặc CDN, cấu hình sẽ khác.

Test:

  • AVIF/WebP.
  • Remote image.
  • Cache.
  • Memory.
  • Native dependency.

Không giả định image optimization hoạt động giống nền tảng managed.

public bị thiếu

Triệu chứng:

/favicon.ico 404
/images/logo.png 404

Kiểm tra Dockerfile có:

COPY --from=builder /app/public ./public

Nếu project không có public, lệnh copy có thể fail tùy Docker/build context. Có thể tạo thư mục hoặc điều chỉnh Dockerfile.

CSS/JS 404

Kiểm tra:

COPY --from=builder \
  /app/.next/static \
  ./.next/static

Nếu thiếu .next/static, HTML có thể tải nhưng CSS/JS lỗi.

Monorepo và file tracing

Trong monorepo, file tracing root mặc định có thể không bao phủ file bên ngoài app directory.

Next.js cung cấp cấu hình liên quan output file tracing root. Cần kiểm tra tài liệu version hiện tại nếu app phụ thuộc package trong workspace ngoài root.

Ví dụ khái niệm:

outputFileTracingRoot

Không copy toàn monorepo vào runtime chỉ để sửa nhanh nếu chưa cần.

Health endpoint riêng

Trang chủ có thể phụ thuộc API hoặc dữ liệu nặng. Nên tạo endpoint health nhẹ.

App Router:

// app/api/health/route.ts
export function GET() {
  return Response.json({
    status: "ok",
  });
}

Healthcheck:

curl -f http://127.0.0.1:3000/api/health

Compose:

healthcheck:
  test:
    [
      "CMD",
      "node",
      "-e",
      "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
    ]

Graceful shutdown

Container gửi SIGTERM khi stop.

docker stop deployeasy-web

Next.js server và Node runtime cần thời gian để đóng connection.

Compose:

stop_grace_period: 30s

Không dùng docker kill trừ khi process không dừng.

Logging

Xem:

docker compose logs -f web

Ứng dụng nên log ra stdout/stderr.

Không log:

  • Cookie.
  • Access token.
  • Secret.
  • Dữ liệu cá nhân không cần thiết.

Deploy image có version

Build:

docker build \
  -t ghcr.io/username/deployeasy-web:git-a1b2c3d \
  .

Push:

docker push \
  ghcr.io/username/deployeasy-web:git-a1b2c3d

VPS:

docker compose pull web
docker compose up -d web

Không chỉ dùng latest.

Rollback

Đổi tag về image cũ:

image: ghcr.io/username/deployeasy-web:git-OLD

Sau đó:

docker compose pull web
docker compose up -d web

Rollback frontend vẫn có thể lỗi nếu API/database đã thay đổi không tương thích.

Zero-downtime cơ bản

Một container duy nhất có downtime ngắn khi thay container.

Để giảm downtime:

  • Chạy web-blue port 3001.
  • Chạy web-green port 3002.
  • Healthcheck green.
  • Đổi Nginx upstream.
  • Reload Nginx.
  • Giữ blue để rollback.

Cần quản lý env, cache và asset nhất quán.

Lỗi build bị killed 137

Next.js build dùng nhiều RAM.

Kiểm tra:

free -h
dmesg --ctime | tail -n 50

Giải pháp tốt:

  • Build trong CI.
  • Push image lên registry.
  • VPS chỉ pull.
  • Tăng RAM.
  • Tối ưu build.

Swap chỉ là hỗ trợ, không thay RAM thật.

Lỗi server.js không tồn tại

Kiểm tra:

ls -la .next/standalone

Nguyên nhân:

  • Chưa bật output: "standalone".
  • Build fail.
  • Copy sai path.
  • Monorepo output khác.
  • Docker context sai.

Lỗi hydration không phải lỗi deploy duy nhất

Hydration mismatch thường do HTML server khác render client:

  • Dùng Date.now().
  • Random.
  • Browser API trong server render.
  • Locale/timezone khác.
  • Extension sửa DOM.
  • Conditional render theo window.

Đừng chỉ restart container; sửa nguyên nhân render.

Checklist

[ ] output standalone
[ ] npm ci + build thành công
[ ] .next/standalone tồn tại
[ ] Copy public
[ ] Copy .next/static
[ ] Runtime non-root
[ ] HOSTNAME=0.0.0.0 trong container
[ ] Host chỉ publish 127.0.0.1
[ ] Không copy secret
[ ] NEXT_PUBLIC hiểu là build-time
[ ] Health endpoint
[ ] Log rotation
[ ] Nginx proxy đúng
[ ] HTTPS
[ ] Image tag có version
[ ] Rollback image cũ
[ ] Build trong CI nếu VPS yếu

Câu hỏi thường gặp

Standalone có phải static export không?

Không. Standalone vẫn chạy Node.js server.

Có cần PM2 trong Docker không?

Thường không. Docker quản lý process/container; app nên là process chính.

Có cần Nginx không?

Không bắt buộc, nhưng Nginx hữu ích cho HTTPS, domain, proxy và nhiều app trên VPS.

Có thể đổi NEXT_PUBLIC_* lúc container start không?

Giá trị client thường đã được inline lúc build. Thay runtime env chưa chắc thay bundle client.

Kết luận

Deploy Next.js standalone cần copy đúng runtime, public, .next/static, quản lý build-time env và chạy container non-root. Phần khó không chỉ là build image mà còn cache, asset version, healthcheck và rollback.

Nguồn tham khảo

Đọc tiếp