Deploy Next.js lên VPS và Docker: standalone, reverse proxy, Server Actions
Tài liệu Next.js nói một máy chủ Node.js hỗ trợ mọi tính năng của Next.js, Docker cũng vậy. Vậy câu hỏi khi đưa ứng dụng lên VPS không phải “chạy được không” mà “phải chép những gì lên máy chủ, và cái gì hỏng khi có nginx đứng trước”. Trang này dựng một ứng dụng nhỏ trên next@16.4.4, chạy nó theo ba cách, rồi gọi nó qua các header mà một reverse proxy gửi. Mọi con số dưới đây lấy từ một lần chạy script đo.
Mã trên trang này là một ứng dụng chạy được: 29/29 kiểm tra đạt ngày 11/11/2026. Phiên bản đã chạy thử: next@16.4.4, react@19.3.0, Node.js v26.0.0.
Ứng dụng mẫu
Ứng dụng có đúng những thứ hay hỏng khi deploy: một trang server render đọc header bằng headers(), một form gọi Server Action để ghi cookie, một Route Handler in lại request.url, một tệp CSS và một tệp trong public/. Không có cơ sở dữ liệu, không có thư viện ngoài.
Cấu hình chỉ có một dòng: output: "standalone". Với dòng đó, next build gom vào .next/standalone một server.js và phần node_modules mà ứng dụng thật sự cần. Bỏ dòng đó đi thì build vẫn xanh, nhưng không có thư mục .next/standalone nào cả: phép đo đầu tiên của trang kiểm đúng điều này.
import type { NextConfig } from "next";
// Bản dựng này chỉ chép phần chạy được: .next/standalone, không có node_modules đầy đủ.
const nextConfig: NextConfig = { output: "standalone" };
export default nextConfig;import { cookies, headers } from "next/headers";
import { guiTen } from "./actions";
export default async function Trang() {
const h = await headers();
const ten = (await cookies()).get("ten")?.value ?? "(chưa gửi)";
return (
<main>
<h1>Ứng dụng mẫu để deploy</h1>
<p>{`Tên đã gửi: ${ten}`}</p>
<p>{`Host nhận được: ${h.get("host") ?? "(không có)"}`}</p>
<p>{`x-forwarded-host: ${h.get("x-forwarded-host") ?? "(không có)"}`}</p>
<form action={guiTen}>
<input name="ten" defaultValue="Rà Soát" />
<button type="submit">Gửi tên</button>
</form>
<p>
<a href="/tai-nguyen.txt">tệp trong public/</a>
</p>
</main>
);
}"use server";
import { cookies } from "next/headers";
/** Server Action: ghi tên vào cookie để trang đọc lại ở lần tải sau. */
export async function guiTen(formData: FormData) {
const ten = String(formData.get("ten") ?? "");
(await cookies()).set("ten", ten, { httpOnly: true, sameSite: "lax" });
}import type { NextRequest } from "next/server";
/** Route Handler đọc lại địa chỉ và header proxy đặt, để so với trang server render. */
export function GET(request: NextRequest) {
return Response.json({
url: request.url,
host: request.headers.get("host"),
forwardedHost: request.headers.get("x-forwarded-host"),
forwardedProto: request.headers.get("x-forwarded-proto"),
});
}Standalone: 50,4 MB thay vì 379,0 MB, nhưng thiếu hai thư mục
Bản build thường cần cả node_modules lẫn .next (trừ thư mục cache) trên máy chủ: 379,0 MB với ứng dụng này. Bản standalone cần .next/standalone, .next/static và public/: tổng 50,4 MB (49,8 MB + 0,6 MB + 4 KB). Trong node_modules, nó giữ 12 trên 24 mục ở cấp đầu.
Cái bẫy nằm ở hai thư mục nó không chép: public/ và .next/static. Chạy node .next/standalone/server.js ngay sau khi build thì trang trả 200, nhưng tệp CSS mà trang trỏ tới trả 404, tệp trong public/ cũng 404. Trang vẫn hiện chữ, chỉ mất kiểu dáng, nên rất dễ tưởng là đã chạy đúng.
Tài liệu Next.js giải thích lý do: hai thư mục này “nên được CDN phục vụ”. Nếu không có CDN, chép chúng vào đúng chỗ: cp -r public .next/standalone/ và cp -r .next/static .next/standalone/.next/. Sau khi chép, cùng máy chủ đó trả 200 cho cả CSS lẫn tệp trong public/.
Đừng dùng next start cho bản standalone
Nếu cấu hình có output: "standalone" mà bạn vẫn chạy next start, Next.js in cảnh báo "next start" does not work with "output: standalone" configuration rồi vẫn trả trang 200. Không có lỗi nào dừng tiến trình lại.
Hệ quả là bạn có thể tưởng mình đang chạy bản standalone, trong khi thật ra máy chủ vẫn cần đủ node_modules. Lệnh đúng là node server.js trong thư mục standalone, với PORT và HOSTNAME đặt bằng biến môi trường như tài liệu ghi.
Docker: ảnh 201 MB, và biến HOSTNAME
Dockerfile dưới đây có hai bước: bước đầu cài phụ thuộc bằng npm ci (dự án có package-lock.json) và build; bước sau chỉ chép .next/standalone, .next/static và public/ sang một ảnh node:22-alpine sạch, chạy bằng user node. Ảnh cuối nặng 201 MB, và container trả 200 cho cả trang lẫn CSS.
Dòng dễ quên là ENV HOSTNAME=0.0.0.0. server.js lắng nghe ở địa chỉ mà biến HOSTNAME chỉ định, còn Docker tự đặt HOSTNAME bằng tên máy của container (12 ký tự hex). Thiếu dòng đó, container vẫn trả 200 qua cổng -p, nhưng một lệnh gọi 127.0.0.1:3000 từ bên trong container thì bị từ chối. Đó chính là kiểu gọi mà một HEALTHCHECK hay dùng. Ví dụ with-docker trong kho mã Next.js cũng đặt dòng này.
# Bước 1: cài phụ thuộc và build (cần cả devDependencies: typescript).
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Bước 2: ảnh chạy chỉ có .next/standalone, .next/static và public/.
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
# server.js nghe theo HOSTNAME; Docker tự đặt HOSTNAME là id container, nên phải ghi đè.
ENV HOSTNAME=0.0.0.0
COPY --from=build /app/public ./public
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]Sau reverse proxy: Host, x-forwarded-* và Server Actions
Khi nginx hay Caddy đứng trước, ứng dụng thấy những gì proxy gửi, không phải những gì trình duyệt gõ. Trang server render đọc đúng header Host mà nó nhận được, và x-forwarded-host đi tới headers() nguyên vẹn. Nhưng request.url trong Route Handler thì nửa nọ nửa kia: phần scheme lấy theo x-forwarded-proto (thành https), còn phần host vẫn lấy từ Host, dù x-forwarded-host có mặt. Muốn dựng URL công khai, đừng tin request.url.
Server Actions bị ảnh hưởng nặng hơn. Để chống CSRF, Next.js so host trong header Origin với host của chính ứng dụng, lấy từ x-forwarded-host hoặc Host. Gửi form với Origin: https://rasoat.com trong khi Host là địa chỉ nội bộ thì Server Action trả 500. Trình duyệt chỉ thấy 500; lý do (x-forwarded-host header … does not match origin header) chỉ có trong log máy chủ.
Đó đúng là điều nginx làm nếu bạn chỉ viết proxy_pass: tài liệu nginx ghi giá trị mặc định là proxy_set_header Host $proxy_host, tức địa chỉ trong proxy_pass. Có hai cách sửa, và script đo cả hai đều trả 200: cho Host đi nguyên (proxy_set_header Host $host;), hoặc đặt x-forwarded-host bằng host công khai. Next.js còn có serverActions.allowedOrigins cho trường hợp proxy đứng ở một tên miền khác; trang này không chạy cách đó.
Cách đo trang này
Mọi con số trên trang đến từ một script. Script chép ứng dụng mẫu vào một thư mục làm việc, cài đúng phiên bản đã ghim, rồi build hai lần: một lần không có output: "standalone", một lần có. Với bản đầu, nó chạy next start và gửi các request mang header của proxy; với bản sau, nó đo dung lượng bằng du, chạy server.js trước và sau khi chép hai thư mục. Cuối cùng nó build ảnh Docker từ đúng Dockerfile in ở trên và gọi container từ ngoài lẫn từ trong.
Script không chạy nginx: nó gửi thẳng các header mà một proxy gửi. Kết quả chỉ đúng với phiên bản ghi ở đầu trang. Next.js ra bản mới thì trang không tự đổi: chúng tôi phải đổi phiên bản đã ghim và đo lại, và phép đo nào không đạt thì trang không được lên site.
Đã kiểm tra
- a default build:
exit 0 - a default build writes no .next/standalone:
no such directory - next start serves the page, its CSS and a file in public/:
200 200 200 - the Host header is what the page reads:
localhost:3800 - x-forwarded-host reaches headers():
rasoat.com - request.url in a Route Handler with no proxy headers:
http://localhost:3800/api/chan-doan - …and with x-forwarded-host / x-forwarded-proto set:
https://localhost:3800/api/chan-doan - the forwarded headers arrive unchanged beside it:
rasoat.com / https - a Server Action POST whose Origin matches the Host:
200, set-cookie: ten=Rà Soát - the same POST with Origin https://rasoat.com while the Host is localhost:
500: `x-forwarded-host` header with value `localhost:3800` does not match `origin` header with value `rasoat.com` from a forwarded Server Actions request. Aborting the action. - …and with x-forwarded-host naming the proxy's host:
200, set-cookie: present - the standalone build:
exit 0 - what .next/standalone holds:
server.js - what it leaves out: public/:
absent - what it leaves out: .next/static:
absent - how much of node_modules it carries:
12 of 24 - what you copy to the server, standalone + .next/static + public/, against node_modules + .next (no cache) + public/:
50.4 MB vs 379.0 MB - the standalone payload in pieces:
49.8 MB + 0.6 MB + 4 KB - node .next/standalone/server.js answers the page:
200 - …and the CSS it links:
404 - …and the file in public/:
404 - after copying both, the same server serves them:
200 200 - next start on a standalone build:
200, our page: ⚠ "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead. - docker build:
exit 0 in 0 s - the image size:
201 MB - the container serves the page and its CSS:
200 200 - with ENV HOSTNAME=0.0.0.0: 127.0.0.1:3000 from inside the container:
answers - HOSTNAME that Docker itself sets in a node:22-alpine container:
the container's hostname, 12 hex characters - with HOSTNAME left to Docker: through -p, then 127.0.0.1:3000 from inside:
200 through -p, refused inside
Script đo (chưa công khai): bun scripts/guides/verify-deploy.ts
Liên quan
- SSR, SSG, ISR: chọn cách render trong Next.js
- Middleware thành proxy: đổi tên, và đổi cả runtime
- Lỗ hổng bảo mật Next.js
- Kiểm tra phiên bản Next.js của trang bạn
Nguồn
- Next.js docs — Deploying
- Next.js docs — Self-hosting
- Next.js docs — output (standalone)
- Next.js docs — serverActions.allowedOrigins
- Next.js example — with-docker (Dockerfile)
- nginx docs — proxy_set_header (default Host $proxy_host)
- npm registry — next dist-tags
Tóm tắt và diễn giải bằng lời của chúng tôi; mã viết lại từ ví dụ trong tài liệu Next.js. Khi có khác biệt, tài liệu Next.js là chuẩn.