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.
Mục lục bài viết
VPS & Hosting cho dự án của bạn
Khám phá các gói máy chủ và lưu trữ phù hợp từ website cá nhân đến ứng dụng production.
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 và .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
Bài viết liên quan
Cách deploy Next.js lên VPS Ubuntu
Hướng dẫn build và chạy ứng dụng Next.js production bằng PM2, Nginx, domain và HTTPS trên VPS Ubuntu theo từng bước kiểm tra được.
Đọc bài →Hướng dẫn deploy REMAP bằng Docker lên VPS
Triển khai monorepo REMAP gồm NestJS, Next.js, React và PostgreSQL lên VPS Ubuntu bằng Docker Compose, Nginx, domain và SSL.
Đọc bài →Hướng dẫn deploy REMAP lên VPS Ubuntu
Triển khai monorepo REMAP gồm NestJS, Next.js, React và PostgreSQL lên VPS Ubuntu bằng PM2, Nginx, domain và chứng chỉ SSL.
Đọc bài →