Deploying Next.js to a VPS and Docker: standalone, reverse proxy, Server Actions
The Next.js docs say a Node.js server supports every Next.js feature, and so does Docker. So the question when you move an app to a VPS is not “will it run” but “what do I copy to the server, and what breaks once nginx sits in front”. This page builds a small app on next@16.4.4, runs it three ways, then calls it with the headers a reverse proxy sends. Every number below comes from one run of the measuring script.
The code on this page is an app that runs: 29/29 checks passed on 2026-11-11. Versions run: next@16.4.4, react@19.3.0, Node.js v26.0.0.
The sample app
The app holds exactly what tends to break in a deploy: a server-rendered page that reads headers with headers(), a form that calls a Server Action to set a cookie, a Route Handler that prints request.url, a CSS file and one file in public/. No database, no third-party library.
The config is one line: output: "standalone". With it, next build gathers a server.js and the part of node_modules the app really needs into .next/standalone. Remove the line and the build is still green, but there is no .next/standalone at all: the first check on this page tests exactly that.
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 instead of 379.0 MB, minus two folders
A plain build needs both node_modules and .next (minus its cache folder) on the server: 379.0 MB for this app. The standalone build needs .next/standalone, .next/static and public/: 50.4 MB in all (49.8 MB + 0.6 MB + 4 KB). Of node_modules, it keeps 12 of the 24 top-level entries.
The trap is the two folders it does not copy: public/ and .next/static. Run node .next/standalone/server.js right after the build and the page answers 200, but the CSS file the page links answers 404, and so does the file in public/. The text still shows, only the styling is gone, so it is easy to believe it worked.
The Next.js docs give the reason: these two folders “should ideally be handled by a CDN”. Without a CDN, copy them into place: cp -r public .next/standalone/ and cp -r .next/static .next/standalone/.next/. After the copy, the same server answers 200 for both the CSS and the public/ file.
Do not use next start on a standalone build
With output: "standalone" in the config, next start still runs: Next.js prints "next start" does not work with "output: standalone" configuration and then answers the page with 200. Nothing stops the process.
So you can believe you are running the standalone build while the server still needs the full node_modules. The right command is node server.js inside the standalone folder, with PORT and HOSTNAME set as environment variables, as the docs say.
Docker: a 201 MB image, and the HOSTNAME variable
The Dockerfile below has two stages: the first installs dependencies with npm ci (the project has a package-lock.json) and builds; the second copies only .next/standalone, .next/static and public/ into a clean node:22-alpine image and runs as the node user. The final image is 201 MB, and the container answers 200 for both the page and its CSS.
The line people forget is ENV HOSTNAME=0.0.0.0. server.js listens on the address the HOSTNAME variable names, and Docker sets HOSTNAME to the container's own hostname (12 hex characters). Without that line the container still answers 200 through the -p port, but a call to 127.0.0.1:3000 from inside the container is refused. That is the call a HEALTHCHECK usually makes. The with-docker example in the Next.js repository sets the same line.
# 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"]Behind a reverse proxy: Host, x-forwarded-* and Server Actions
With nginx or Caddy in front, the app sees what the proxy sends, not what the browser typed. The server-rendered page reads the Host header it received, and x-forwarded-host reaches headers() unchanged. request.url in a Route Handler is half and half: the scheme follows x-forwarded-proto (it becomes https), while the host still comes from Host, even with x-forwarded-host present. Do not build a public URL from request.url.
Server Actions are hit harder. Against CSRF, Next.js compares the host in the Origin header with the app's own host, taken from x-forwarded-host or Host. Post the form with Origin: https://rasoat.com while Host is the internal address, and the Server Action answers 500. The browser sees only the 500; the reason (x-forwarded-host header … does not match origin header) is only in the server log.
That is exactly what nginx does if all you write is proxy_pass: the nginx docs give the default as proxy_set_header Host $proxy_host, the address in proxy_pass. There are two fixes, and the script measured 200 for both: pass Host through (proxy_set_header Host $host;), or set x-forwarded-host to the public host. Next.js also has serverActions.allowedOrigins for a proxy on another domain; this page did not run that one.
How this page was measured
Every number here comes from one script. It copies the sample app into a work directory, installs the pinned versions, and builds twice: once without output: "standalone", once with it. On the first build it runs next start and sends requests carrying a proxy's headers; on the second it measures sizes with du and runs server.js before and after copying the two folders. Last, it builds a Docker image from the Dockerfile printed above and calls the container from outside and from inside.
The script does not run nginx: it sends the headers a proxy sends, directly. The results hold only for the version at the top of the page. A new Next.js does not change the page by itself: we move the pin, measure again, and a check that no longer passes keeps the page off the site.
Checked
- 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
Measuring script (not public yet): bun scripts/guides/verify-deploy.ts
Related
- SSR, SSG and ISR: choosing how a page renders in Next.js
- Middleware becomes proxy: a rename that changes the runtime
- Next.js security advisories
- Check the Next.js version of your site
Sources
- 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
Our summary and wording; the code is rewritten from the examples in the Next.js docs. Where they differ, the Next.js docs win.