Self-hosting Next.js
Run Next.js on your own servers: the standalone output, a production Dockerfile, a CDN and proxy in front, one cache for many instances, and version skew.
- 01Build and run the standalone outputSay what .next/standalone holds, copy in the two folders it leaves out, and package it in a multi-stage Dockerfile that runs as a user that isn't root.
- 02Put a CDN and a proxy in frontRead the Cache-Control Next.js sends for each kind of answer, keep streaming working through nginx, and trust forwarded headers only from a proxy that checks the host and sets them itself.
- 03Run many instances safelyShare both caches with a tag index, keep the same encryption key across builds, give each deployment its own deploymentId, and know what image optimization costs each server.
The tea shop on servers you run
Self-hosting means running the app on servers you manage: your own computers, or virtual machines and container services you rent. Deploying Next.js showed that a Node.js server or a Docker container runs every feature. Running one in production takes a few more decisions:
- what goes onto the server, and how to pack it into an image;
- what sits in front of the server: a CDN, and a reverse proxy;
- what several copies of the server must share, so they agree with each other;
- what happens to open pages when you deploy a new version;
- what resizing images costs the server.
Here is the tea shop, built for that. Its next.config.ts sets output: "standalone" on line 6. This example is a real Next.js 16.4.3 project: it was built with next build, two folders were copied, and the shop was started with node .next/standalone/server.js, not next start. Then real Chrome opened it.
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, output: "standalone", async headers() { return [ { source: "/:path*{/}?", headers: [{ key: "X-Accel-Buffering", value: "no" }], }, ]; },}; export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env.production- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (5/5) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /├ ○ /_not-found├ ◐ /cart└ ○ /menu 1h 1d ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content $ cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/ # What .next/standalone holds: 1276 files, 46.4 MB (deeper folders added up).next/standalone/.env.production 50 bytes.next/standalone/.next/BUILD_ID 21 bytes.next/standalone/.next/app-path-routes-manifest.json 148 bytes.next/standalone/.next/build-manifest.json 2.0 kB.next/standalone/.next/package.json 20 bytes.next/standalone/.next/prerender-manifest.json 6.6 kB.next/standalone/.next/required-server-files.json 10.8 kB.next/standalone/.next/routes-manifest.json 2.1 kB.next/standalone/.next/server/ 1.1 MB, 124 files.next/standalone/.next/static/ 592.7 kB, 12 files.next/standalone/node_modules/@emnapi/runtime/ 66.5 kB, 4 files.next/standalone/node_modules/@img/colour/ 46.7 kB, 3 files.next/standalone/node_modules/@img/sharp-darwin-arm64/ 281.3 kB, 3 files.next/standalone/node_modules/@img/sharp-libvips-darwin-arm64/ 18.2 MB, 6 files.next/standalone/node_modules/@img/sharp-wasm32/ 9.3 MB, 5 files.next/standalone/node_modules/@next/env/ 11.0 kB, 2 files.next/standalone/node_modules/@swc/helpers/ 30.7 kB, 5 files.next/standalone/node_modules/client-only/ 467 bytes, 2 files.next/standalone/node_modules/detect-libc/ 11.4 kB, 5 files.next/standalone/node_modules/next/ 14.6 MB, 993 files.next/standalone/node_modules/react-dom/ 1.4 MB, 12 files.next/standalone/node_modules/react/ 22.1 kB, 9 files.next/standalone/node_modules/semver/ 68.7 kB, 47 files.next/standalone/node_modules/sharp/ 631.5 kB, 29 files.next/standalone/node_modules/styled-jsx/ 22.3 kB, 4 files.next/standalone/package.json 391 bytes.next/standalone/public/logo.svg 142 bytes.next/standalone/server.js 8.0 kB $ node .next/standalone/server.js▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Ready✓ Running next.config took 3msOpen the Terminal tab. After $ next build comes the copy command, then a list of what .next/standalone holds, then the server starting: ✓ Ready. Open Steps: Chrome loaded the page with every script, and after a click, the button says Added. The docs say that folder can be deployed on its own, without installing node_modules.
output: "standalone" makes next build write .next/standalone: a server.js, the server's code, and only the packages it traced. The browser's files aren't in it: public and .next/static go to a CDN, or get copied in.A tea shop that sells at markets doesn't bring its storeroom. It packs one crate with the teas it will sell and the scale it weighs them on. The signs and leaflets travel in their own bag, or wait at the market's front desk. With three stalls in three markets, all of them read the same price list, so a new price reaches every stall at once.
- In real life: The shop's whole storeroom
- In Next.js: The project, with every package in
node_modules - In real life: One crate with the teas and the scale the stall uses
- In Next.js:
.next/standalone:server.jsand only the traced packages - In real life: The signs and leaflets, in their own bag
- In Next.js:
publicand.next/static, copied in or on a CDN - In real life: Several stalls reading one shared price list
- In Next.js: Many instances reading one cache store
Self-hosting is running Next.js's server on machines you manage, and deciding what each one needs and what they share. Build a standalone folder and put the browser's files beside it or on a CDN. Put a proxy in front that passes streams through. Give every instance one cache store and the same encryption key, and each deployment its own id.
output: 'standalone': a folder that runs on its own
During every build, Next.js traces which files the server can load: it follows each import, require and file read from your pages and from its own server code. With output: "standalone", it copies exactly those files into .next/standalone, beside a small server.js. These are the commands that build and run it, without Docker:
npm run build
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
node .next/standalone/server.js
Line 1 builds. Line 2 copies public into the folder, and .next/static into its .next. Line 3 starts the server. The list in the Terminal tab above shows what the folder holds:
server.js: the server. It listens on the port in thePORTenvironment variable, 3000 if there's none, and on the address inHOSTNAME,0.0.0.0(every network the computer has) if there's none. The recorder setHOSTNAME=127.0.0.1, which is why the Terminal says so..next/: the build's manifests and.next/server/, the server code for every route and the pages prerendered during the build.node_modules/: only the packages the server uses:next,react,react-dom,sharpwith its@imgpackages for the image optimizer, and a few small ones they need.package.json, and.env.production. The build printed- Environments: .env.production: it loaded the file, and copied it into the folder too.
That last point matters. Anything in an .env file that the build loads ends up in the folder, and so in any image made from it. Keep secrets out of those files, and give them to the server when it starts instead (Server-only code and environment variables).
Size is the other point. The first version in the playground below also lists the project's whole node_modules. On the computer that recorded it, the standalone folder held about 46 MB; the project's node_modules about 352 MB. The folder leaves out TypeScript, @next/swc-…, the native program that compiles and bundles during the build, and most of next itself. (Sizes change from one computer to another: sharp installs a different binary for each operating system.)
What the folder leaves out on purpose is the browser's files: public and .next/static. The docs say those should ideally be served by a CDN; if not, copy them in, and server.js serves them. Pick a version to see what happens without them:
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, output: "standalone", async headers() { return [ { source: "/:path*{/}?", headers: [{ key: "X-Accel-Buffering", value: "no" }], }, ]; },}; export default nextConfig;Each step was taken in real Chrome against node .next/standalone/server.js. Pick one to see the page after it.
Mint tea: $3
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Errors in Chrome's console during this step (9)
Failed to load resource: the server responded with a status of 404 (Not Found)Failed to load resource: the server responded with a status of 404 (Not Found)Failed to load resource: the server responded with a status of 404 (Not Found)Failed to load resource: the server responded with a status of 404 (Not Found)Failed to load resource: the server responded with a status of 404 (Not Found)Failed to load resource: the server responded with a status of 404 (Not Found)Failed to load resource: the server responded with a status of 404 (Not Found)Failed to load resource: the server responded with a status of 404 (Not Found)Failed to load resource: the server responded with a status of 404 (Not Found)
Requests the page made during this step (10), files and other sites included
GET/200a whole pageGET/logo.svg404text/plainGET/_next/static/chunks/1x6obpmlu1_c2.js404text/plainGET/_next/static/chunks/06bs-nf19b2js.js404text/plainGET/_next/static/chunks/2ki52vdnbo2ft.js404text/plainGET/_next/static/chunks/3l_qmp-61z4yc.js404text/plainGET/_next/static/chunks/turbopack-3-ry682tkyiqk.js404text/plainGET/_next/static/chunks/3oh00mkbkq8u6.js404text/plainGET/_next/static/chunks/05qpawisz0721.js404text/plainGET/logo.svg404text/plain
node .next/standalone/server.js serves only what the build put in the folder. The page's HTML arrives, but /logo.svg and the page's first script answer 404. In Steps, every script and the logo fail, and Add to cart does nothing when clicked.
With nothing copied, the server still started, and / still came back with its HTML: Our teas, Mint tea: $3, a button. But /logo.svg and the page's first script answered 404. In Steps, the first step lists every script the page asked for, each 404, and Chrome's console logged each failure. The second step clicked Add to cart, and nothing changed: React never loaded, so the button had no click handler. With the two folders copied, every file answered 200, and the click turned the button into Added.
One more thing the recordings show. Started with next start instead, the same build still serves the shop, but prints a warning:
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, output: "standalone", async headers() { return [ { source: "/:path*{/}?", headers: [{ key: "X-Accel-Buffering", value: "no" }], }, ]; },}; export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env.production- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (5/5) Finalizing page optimization ... Route (app) Revalidate Expire┌ ○ /├ ○ /_not-found├ ◐ /cart└ ○ /menu 1h 1d ○ (Static) prerendered as static content◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content $ next start▲ Next.js 16.4.3- Local: http://127.0.0.1:3000- Network: http://127.0.0.1:3000✓ Ready⚠ "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.The Terminal's last line says "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead. With standalone output, start the server the folder holds.
A production Dockerfile
The docs' official Docker example (examples/with-docker, at the 16.4.3 release) builds the standalone folder inside Docker, in three stages: separate images, each starting fresh, where a later one copies only what it needs from an earlier one. This is the tea shop's version of it, shortened to npm. Docker isn't run on this page; the recordings above ran the same commands its last stage runs.
ARG NODE_VERSION=24.13.0-slim
# Stage 1: install the packages from the lockfile.
FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
# Stage 2: build, with output: "standalone" in next.config.ts.
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN --mount=type=secret,id=actions_key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY npm run build
# Stage 3: only what the server needs, run by a user that isn't root.
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Line by line:
- Line 1 names the Node.js image once. The official example pins the LTS release current when it was written, and says to keep it updated.
- Lines 4 to 7, stage 1, copy only the package files and run
npm ci. Docker keeps each step's result, so as long as these two files don't change, it reuses the installed packages. - Lines 10 to 16, stage 2, take those packages, copy the project, and build it. Line 15 turns off Next.js's anonymous telemetry; the official example leaves that line commented out, so its builds send it unless you remove the
#. Line 16 builds with the Server Actions key, handed in as a BuildKit secret: it exists only while that one command runs, and isn't saved in the image's settings or history. (The official example has no key; why the shop needs one, and where it ends up, is in the version skew part below.) - Lines 19 to 31, stage 3, start from a clean image. Lines 22 and 23 set the port and make the server listen on every network inside the container,
0.0.0.0, sodocker run -pcan reach it. Line 25 copiespublic; lines 27 and 28 copy the standalone folder and.next/static, the same two folders the playground needed.--chown=node:nodemakes them belong tonode, a user the Node.js image already has, and line 26 lets that user write to.next, where the server keeps the pages it makes later. - Line 29 switches to that user. If someone breaks into the server, they get a user that can't change the system. Line 31 starts
server.js.
The last image holds Node.js, the standalone folder and the two copied folders: no source code, no compiler, no dev tools. Because sharp is installed during stage 1 inside Linux, the folder gets sharp's Linux binary, not the one for the computer you typed docker build on. A .dockerignore keeps the rest of your computer's files out of the build:
node_modules
.next
.git
.env
.env*.local
Dockerfile
.dockerignore
Lines 1 and 2 keep your own node_modules and .next out, so the image installs and builds its own. Lines 4 and 5 keep .env and local env files out. The official example's .dockerignore warns about the same thing the standalone folder showed: any env file left in is loaded by next build and copied into .next/standalone. It keeps .env.production for settings that aren't secret, and passes secrets with docker run -e.
A CDN and a reverse proxy in front
The docs recommend a reverse proxy, like nginx, in front of the Next.js server rather than exposing it to the internet. A reverse proxy is a server that receives every request first and passes it on. It can turn away malformed requests, visitors who send very slowly to tie up connections, bodies that are too big, and too many requests from one place, so the Next.js server spends its time rendering. Further out, a CDN keeps copies of answers on servers close to visitors.
Cache-Control allows, then a reverse proxy, which guards the servers, says who the visitor is in X-Forwarded-… headers, and must pass streamed answers through as they come. Behind them, any instance can answer.What each answer lets a CDN keep
Next.js tells caches what they may keep with Cache-Control, and the recording of the standalone shop has one answer of each kind. Open its Network tab above and pick each request. Each row is one of them:
| Answer | Cache-Control | What a CDN may do |
|---|---|---|
/, prerendered (○) | s-maxage=31536000 | Keep it for up to a year. |
/menu, cached for an hour | s-maxage=3600, stale-while-revalidate=82800 | Keep it an hour, then send the old copy for up to 23 more hours while it asks for a new one. |
/cart, made for each request (◐) | private, no-cache, no-store, max-age=0, must-revalidate | Never keep it. Each visitor gets their own. |
A script under /_next/static/ | public, max-age=31536000, immutable | Keep it for a year without asking again. A changed file gets a new address. |
/logo.svg, from public | public, max-age=0 | Keep it, but ask the server each time whether it changed. |
A CDN that follows these headers keeps the shop's static pages and files, and never keeps the cart. Two rules from the docs' CDN guide:
- Keep
rscand_rsc. The same address can answer with HTML or with the RSC payload the router asks for. Every page answer names the request headers it depends on inVary(the recording showsrsc, next-router-state-tree, next-router-prefetch…). Many CDNs ignoreVary, so the router also adds an_rscvalue to the address. A CDN must pass therscheader on and keep_rscin its cache key, or it can send HTML to the router. - Purge the CDN yourself.
revalidateTagandrevalidatePathclear Next.js's own cache, not the CDN's copy. It keeps that until itss-maxageruns out, unless you also call your CDN's purge API (Revalidating data).
A proxy that passes streams through
nginx, by default, buffers an upstream server's answer: pieces can wait in its buffers instead of going straight on. For the cart, the visitor may then wait for the whole page instead of seeing the shell first (Streaming). The docs' fix is the header X-Accel-Buffering: no, which tells nginx not to hold that answer back. Lines 7 to 14 of the shop's next.config.ts add it to every answer, and the recording shows it on each one, files included. The docs add that every load balancer and proxy between the visitor and the server must stream too.
Headers the proxy writes, and headers it replaces
Behind a proxy, Next.js no longer sees the visitor directly. The proxy tells it in X-Forwarded-… headers. This nginx setup spreads requests over two instances:
upstream tea_shop {
server 10.0.0.11:3000;
server 10.0.0.12:3000;
}
# Any name but the shop's: close the connection, with no answer.
server {
listen 443 ssl default_server;
ssl_certificate /etc/nginx/certs/teashop.example.pem;
ssl_certificate_key /etc/nginx/certs/teashop.example.key;
return 444;
}
server {
listen 443 ssl;
server_name teashop.example;
ssl_certificate /etc/nginx/certs/teashop.example.pem;
ssl_certificate_key /etc/nginx/certs/teashop.example.key;
location / {
proxy_pass http://tea_shop;
# Only teashop.example reaches this block, so say exactly that.
proxy_set_header Host teashop.example;
proxy_set_header X-Forwarded-Host teashop.example;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Lines 1 to 4 list the two instances. Lines 7 to 12 are the default server: nginx sends it every request whose Host header names no server it knows, and line 11 closes the connection without an answer (444 is nginx's code for exactly that). Lines 14 to 28 serve the one name the shop owns: line 16 matches teashop.example, and lines 17 and 18 hold its certificate. Line 21 passes each request to one instance. Lines 23 to 26 set the headers that describe the visitor: the shop's name they asked for, their IP address, and whether they used HTTPS.
Trusting those headers takes two things together:
- Replace, don't pass on. Each line sets its header, so whatever a visitor sent under that name is gone. A visitor can send
X-Forwarded-Hosttoo, and Multi-tenant apps showed a made-up one arriving at the app untouched. - Check the host first. Replacing with the visitor's own
Hostwould only copy their claim. Here, only requests forteashop.examplereach lines 23 and 24, which write that fixed name. Without the default server, nginx would treat the first server block as the default, and any made-up name would reach the shop. An app with many domains checks each against its own list, as Multi-tenant apps does.
It matters because Next.js compares a Server Action's Origin with X-Forwarded-Host when there is one (Securing Server Actions). If you'd rather serve /_next/static from a CDN on its own domain, the assetPrefix option makes the pages point there.
deploymentId and version skew
Deploying a new version rarely happens at one moment. A rolling deployment replaces instances one by one, and a visitor's open page keeps running the JavaScript it loaded. For a while, old code talks to new servers. That's version skew, and the docs list what it breaks: files the old page asks for that the new build doesn't have, Server Actions with ids the new build doesn't know, and navigations whose data the old code can't read.
A deploymentId in next.config.ts (or the NEXT_DEPLOYMENT_ID environment variable) gives each deployment a name. Here is the tea shop with one:
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, // One id for each deployment, like the git commit it was built from. deploymentId: "tea-shop-7",}; export default nextConfig;GET /200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 16 scripts left out
<!DOCTYPE html>
<html data-dpl-id="tea-shop-7" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" href="/logo.svg" as="image">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js?dpl=tea-shop-7">
<script src="/_next/static/chunks/06bs-nf19b2js.js?dpl=tea-shop-7" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js?dpl=tea-shop-7" async=""></script>
<script src="/_next/static/chunks/1rwi1y71imcts.js?dpl=tea-shop-7" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js?dpl=tea-shop-7" async=""></script>
<script src="/_next/static/chunks/3oh00mkbkq8u6.js?dpl=tea-shop-7" async=""></script>
<script src="/_next/static/chunks/05qpawisz0721.js?dpl=tea-shop-7" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js?dpl=tea-shop-7" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<header><img src="/logo.svg" alt="Tea shop logo" width="40" height="40"><a href="/">Teas</a> <a href="/menu">Menu</a> <a href="/cart">Cart</a></header>
<!--&-->
<main>
<h1>Our teas</h1>
<p>Mint tea: $3 <button>Add to cart</button></p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js?dpl=tea-shop-7" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Line 7 of next.config.ts names the deployment tea-shop-7; a real pipeline uses something like the git commit it built. In the Network tab, the id shows up three times:
- In the HTML.
<html data-dpl-id="tea-shop-7">, and every script address ends in?dpl=tea-shop-7. - On every file. The page's first script was asked for at its own address,
/_next/static/chunks/…js?dpl=tea-shop-7. The query makes each deployment's files distinct for browsers and CDNs; the docs say Next.js doesn't read it. - On the router's answers.
/menu?_rsc, asked for with the headerrsc: 1the way the router asks for a page's RSC payload (Linking and navigating), came back withx-nextjs-deployment-id: tea-shop-7. The router sends its own id with every navigation, inx-deployment-id.
deploymentId, every navigation answer names the deployment that made it. When it differs from the page's own, the router stops and loads the whole page, so old code never reads a new build's answer.The router compares the two. In 16.4.3, when the answer's id differs from the page's, it stops and loads the whole page from the server instead (fetch-server-response.ts, lines 259 to 265). The visitor gets the new version, with its own files. What they lose is state kept only in React, like a half-typed form; the address, the URL's query and local storage stay. To avoid even that, route each visitor to the instances of their deployment until they reload; the docs leave that routing to your CDN or host.
Two related settings:
- The build id. With a
deploymentIdand nogenerateBuildId, Next.js uses a fixed build id and relies on the deployment id instead (build/index.ts, lines 1105 to 1110). The id also goes into every"use cache"key, so a new deployment never reads an old one's cached results. - The Server Actions key. Next.js encrypts the values an inline Server Action closes over, with a key each build makes for itself (Securing Server Actions). Instances started from one image share it. Instances built separately don't, and the docs say their actions then fail with Failed to find Server Action. Unlike the deployment id, this key doesn't change with each deployment. Set the same one for every build, and keep it from one deployment to the next:
# Once: make a key, and keep it in your secret store.
openssl rand -base64 32
# Every build, on every machine, in every deployment: the same key.
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="<the key>" npm run build
# With Docker: hand it to the build as a secret, never as an ARG or ENV.
docker build --secret id=actions_key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY -t tea-shop .
Line 2 makes 32 random bytes as base64, the length Next.js makes its own keys; 16 and 24 bytes work too. Keep the value in your secret store, never in the repository or an .env file the build copies. Line 4 builds with it. Line 6 does the same with Docker: --secret reads the key from the computer's own environment and gives it to line 16 of the Dockerfile. An ARG or ENV would be saved in the image, where anyone who can pull it could read it.
When does Next.js need the key? Both times:
- During the build, the key makes each action's id, and the docs say it's embedded in the build output. So the built folder, and any image made from it, holds the key: keep those images in a private registry.
- At run time, the server reads
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYif it's set, and the key the build stored if not (encryption-utils.ts, lines 74 to 80). Setting it at run time too is fine, but only with the same value.
Why keep it across deployments? During a rolling deployment, a page from the old build can still post an action's encrypted values to a new instance, which reads them only if the key is the same. Change the key on purpose, at a quiet moment, never as a side effect of each deploy.
What image optimization costs
next/image works on a self-hosted server with nothing to set up (Optimizing images). The resizing happens on your server, though, and it has costs:
- Size. The optimizer uses sharp, and the standalone folder above held sharp and its
@imgpackages: about 29 MB of the folder's 46 on the recording computer. - Work. The first request for each width, quality and format is resized on the spot,
X-Nextjs-Cache: MISS, which takes processor time and memory. On Linux with glibc, the docs point at sharp's advice on the memory allocator to keep that in check. - Disk, on each instance. Resized images are kept in
.next/cache/imagesfor later requests, on that instance's own disk. Since 16.2,images.customCacheHandler: truelets thecacheHandlerfrom the last section keep them in the shared store instead. - Whose images. Each image
images.remotePatternsallows is one your server will fetch and resize on request, for anyone who asks. Give every pattern an exactprotocolandhostname, and narrow itspathname(andsearch) as far as you can, like{ protocol: "https", hostname: "images.teashop.example", pathname: "/teas/**" }. A wildcard host turns the optimizer into a proxy for anyone's images, resized on your servers' time and bandwidth (Optimizing images covers the patterns).
Two settings move the work elsewhere. Pick each version, and look at the page's <img> in the Network tab:
app/page.tsximport Image from "next/image"; export default function Home() { return ( <main> <h1>Green tea</h1> <Image src="/green-tea.jpg" alt="A cup of green tea" width={480} height={320} /> </main> );}GET /_next/image?url=%2Fgreen-tea.jpg&w=640&q=75200
- Sent with the request
acceptimage/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
- Response headers
VaryAcceptCache-Controlpublic, max-age=14400, must-revalidateContent-Typeimage/webpContent-Dispositionattachment; filename="green-tea.webp"Content-Security-Policyscript-src 'none'; frame-src 'none'; sandbox;X-Nextjs-CacheMISS
The body that arrived is an image, image/webp: 640 × 427 pixels, 3.9 kB
The default loader writes /_next/image addresses into the srcset. Asked for the 640-pixel copy, this server resized the photo itself: a WebP image, X-Nextjs-Cache: MISS, made with sharp.
Each row is one choice, who resizes, and what it costs the shop's servers:
| Choice | Who resizes | What it costs this server |
|---|---|---|
| The default loader | This server, with sharp. | Work for each new size (MISS), files in .next/cache/images on each instance, and sharp in every image you ship. |
images.unoptimized | Nobody. | No work here. Every visitor downloads the whole photo. |
| A custom loader | An image service. | No work here. The service does it, and bills for it. |
A proxy or CDN in front must pass the browser's Accept header on to the optimizer, the docs add: it picks WebP or AVIF from it.
The tea shop's self-hosting checklist
Each row is one thing the tea shop sets up before it runs on its own servers, and why:
| Set up | Why |
|---|---|
output: "standalone", with public and .next/static copied or on a CDN | The server folder has no browser files of its own. |
A multi-stage Dockerfile that runs as node, not root | The image holds only what runs, and a break-in gets a user with few rights. |
A reverse proxy that refuses unknown hosts, sets X-Forwarded-Host, -For and -Proto itself, and passes streams through | Next.js reads who's asking from headers only the proxy wrote, and streamed pages arrive in pieces. |
A CDN that follows Cache-Control, and keeps rsc and _rsc in its cache key | Static files and pages are sent from close by; private pages never are. |
cacheHandler and cacheHandlers on one store with a tag index, cacheMaxMemorySize: 0 | A tag expired on one instance is expired for all. |
The same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY for every build and every deployment, passed as a secret | Every instance, old or new, reads every action's encrypted values. |
A new deploymentId for each deployment | An open page from the old deployment reloads instead of breaking. |
A stop that sends SIGTERM and waits | Requests in flight and after() callbacks finish. |
The question behind most of it: is this one thing for every instance, or does each instance have its own? Sort them:
- The
.nextfolder the build wrote NEXT_SERVER_ACTIONS_ENCRYPTION_KEY- The
deploymentId - The store
cacheHandlerandcacheHandlerswrite to - The in-memory copy of the cache, when
cacheMaxMemorySizeisn't 0 .next/cache/images, withoutimages.customCacheHandler- The
after()callbacks still running
Sort each thing: must every instance share one, or does each instance have its own?
Common mix-ups
- "The standalone folder is the whole app." Not quite. It's the server. The browser's files,
publicand.next/static, are copied in or served by a CDN; without them, the recorded page couldn't load a single script. - "Instances on one disk share a cache." No. In the recording, both instances used one folder, and instance 2 still answered the old price from its memory.
- "A shared store is enough." Not on its own.
revalidateTaghas to reach the store too: a tag index, andcacheMaxMemorySize: 0so no instance answers from its own copy first. - "
revalidateTagclears the CDN." No. It clears Next.js's cache. A CDN keeps its copy untils-maxageruns out, or until you purge it. - "
?dpl=sends visitors to the right version." No. Next.js doesn't read it. It makes files distinct per deployment; routing by deployment is your host's job. - "Env files stay on the build machine." No. The recorded build copied
.env.productioninto.next/standalone, so it goes wherever that folder goes.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
node .next/standalone/server.js serves the shop's HTML, but not two folders. Which two?
public and .next/static. With nothing copied, /logo.svg and every script under /_next/static/ answered 404. The docs say they should ideally be served by a CDN, or copied into .next/standalone/public and .next/standalone/.next/static.
Which Cache-Control does the standalone server send with a script under /_next/static/?
public, max-age=31536000, immutable: anyone may keep it for a year, and never needs to ask whether it changed. The recording's first script came with exactly this. A new build gives a changed file a new address.
Write the command that goes on line 2, so the server sends the logo and the scripts.
npm run build
# copy the two folders here
node .next/standalone/server.js
npm run build
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
node .next/standalone/server.js
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/. That's what the docs give, and the recorder ran it: the folder then listed public/logo.svg and .next/static/, and every file answered 200.
Two instances use the default caches and both show $3. This route runs on instance 1 with {"mint":5}. What does instance 2 show next?
import { writeFile } from "node:fs/promises";
import { revalidateTag } from "next/cache";
// A stand-in for the shop's staff tools: a real one checks who is asking first.
export async function POST(request: Request) {
const { mint } = await request.json();
await writeFile("data/prices.json", JSON.stringify({ mint }));
revalidateTag("prices", { expire: 0 });
return Response.json({ mint });
}
$3. Line 8 ran on instance 1 and expired the tag there. Instance 2's memory still held the page with $3, and the recording shows it answering Mint tea: $3 with X-Nextjs-Cache: HIT.
Which method of this handler removes the old page for both instances when the price changes?
// cacheHandler: where every instance keeps pages and Route Handler answers.
const store = require("./shared-store.js");
module.exports = class PagesHandler {
async get(key) {
const entry = store.read(`page:${key}`);
console.log(`pages: get, ${entry ? "found" : "missing"}`);
return entry ?? null;
}
async set(key, data, ctx) {
if (!data) return;
// A page carries its tags in a header; a fetch result in ctx.tags.
const header = data.headers?.["x-next-cache-tags"] ?? "";
const tags = [...new Set([...(ctx?.tags ?? []), ...header.split(",").filter(Boolean)])];
store.write(`page:${key}`, { value: data, lastModified: Date.now(), tags });
// The tag index: for each tag, the keys of the entries that carry it.
for (const tag of tags) store.write(`tag:${tag}`, [...new Set([...(store.read(`tag:${tag}`) ?? []), key])]);
console.log(`pages: set, tags ${tags.filter((tag) => !tag.startsWith("_N_T_")).join(", ")}`);
}
async revalidateTag(tags) {
for (const tag of [tags].flat()) {
for (const key of store.read(`tag:${tag}`) ?? []) store.remove(`page:${key}`);
store.remove(`tag:${tag}`);
console.log(`pages: revalidateTag ${tag}`);
}
}
resetRequestCache() {}
};
revalidateTag, lines 22 to 28. It finds every key under the tag in the index and removes those entries from the shared store. Instance 1 printed pages: revalidateTag prices, and its next render stored the new page, which instance 2 found.
Asha opened the shop while it ran tea-shop-7. Now it runs tea-shop-8, and she clicks Menu. The answer comes with x-nextjs-deployment-id: tea-shop-8. What does the router do?
A full page load. In 16.4.3, the router compares the answer's x-nextjs-deployment-id with its own id and calls doMpaNavigation when they differ, so the browser loads /menu from the new deployment, with the new files.
Check your understanding
For each question, ask what each instance has of its own, and what they share.
Question 1 of 8What does
output: "standalone"add tonext build?Choose an answer to see the explanation.
Question 2 of 8The standalone server starts with nothing copied in. What happens when a browser opens
/?Choose an answer to see the explanation.
Question 3 of 8Which
Cache-Controldid the recorded/cart, made for each request, come with?Choose an answer to see the explanation.
Question 4 of 8nginx holds back the cart's streamed answer until it's complete. What does the docs' fix add?
Choose an answer to see the explanation.
Question 5 of 8Two instances use the default caches. A new price is sent to instance 1, which calls
revalidateTag. What does instance 2 show next?Choose an answer to see the explanation.
Question 6 of 8Why does the shared setup set
cacheMaxMemorySize: 0?Choose an answer to see the explanation.
Question 7 of 8A visitor's page was loaded from deployment
tea-shop-7. A click gets an answer withx-nextjs-deployment-id: tea-shop-8. What does the router do?Choose an answer to see the explanation.
Question 8 of 8Each instance of the shop is built on its own server. What do they need to share for Server Actions to work across them?
Choose an answer to see the explanation.
Key takeaways
output: "standalone"writes.next/standalone:server.js, the server code and only the traced packages. Start it withnode server.js, and copy inpublicand.next/static, or serve them from a CDN.- A production Dockerfile builds in stages, ships only the standalone folder and the two copied folders, and runs as a user that isn't root. Env files the build loads go into the image.
- Next.js says what caches may keep: a year for
/_next/staticand static pages,s-maxageandstale-while-revalidatefor cached ones, nothing for pages made per request. A proxy must pass streams through (X-Accel-Buffering: no) and set the forwarded headers itself. - Each instance keeps its own caches. To share them, point
cacheHandlerandcacheHandlersat one store with a tag index, and setcacheMaxMemorySize: 0. - A
deploymentIdmarks every page, file and navigation answer; when an open page meets a new deployment, the router loads the whole page. Every build, in every deployment, needs the sameNEXT_SERVER_ACTIONS_ENCRYPTION_KEY, passed as a secret. - The image optimizer costs size, processor time and disk on each server; a loader or
unoptimizedmoves it away.
Remember the one-liner.
Ship only the server, put the browser's files beside it or on a CDN, and make every instance share what must agree: the cache, the key and the deployment id.
These links point at release v16.4.3, the version every example on this page was built with. The warning next start printed comes from server/next.ts, lines 390 to 395. The router's deployment check is in fetch-server-response.ts, and the fixed build id in build/index.ts. The official Docker example and Redis handlers are in examples/with-docker and examples/cache-handler-redis.
Coming next: Static exports. It turns the shop into plain files with output: "export": what still works without a server, what doesn't, and how images and single-page apps fit.