RE:NODE
ჰოსტინგი

აპლიკაციები12 წუთის საკითხავი

Deploy Next.js on a Node server: build, standalone, cache

Self-hosting Next.js: why the build needs more memory than the app, standalone output and its missing folders, baked environment variables and the ISR cache.

ეს სტატია ჯერ ინგლისურადაა. ვთარგმნით.

0 მკითხველი

Next.js self-hosts with two commands: next build produces a .next directory, and next start serves it with a Node HTTP server on a port you choose. Everything else that goes wrong is a consequence of four facts. The build needs far more memory and CPU than the running app. The build needs devDependencies the running app does not. Some environment variables are baked into the output and some are read at runtime, and nothing tells you which is which. And the cache that makes incremental static regeneration fast is a directory on disk, so it has opinions about restarts and about more than one instance.

This is what each of those means on a single server behind a reverse proxy, which is the ordinary way to run Next.js when you are not on the platform it was written for.

Build and start: two commands with very different appetites#

bash
$ npm ci                      # devDependencies included, on purpose$ npm run build               # next build$ npm run start -- -p 3000 -H 0.0.0.0

The first surprise is that npm ci --omit=dev && npm run build does not work. TypeScript, Tailwind, PostCSS, ESLint and your type packages are devDependencies, and next build needs them. Either install everything and build on the server, or build somewhere else and ship the output. npm ci vs npm install covers why npm ci is still the right installer in both cases.

The second is resource use. Compiling and type-checking a medium application can hold well over a gigabyte of heap and will use every core it can find, while the same application serving traffic afterwards sits in a couple of hundred megabytes. A build on a 1 GB container is the classic failure here: it ends with JavaScript heap out of memory, or the container is stopped at its limit and the log just stops, which shows as exit code 137. Neither message says "buy more memory for ninety seconds", but that is what it means.

Three ways out, in order of how much they cost you:

  1. Build in CI and deploy the output. The server only installs production dependencies and runs. The fastest and least surprising option, and the one to choose if you already have CI.
  2. Build on a larger tier temporarily. Moving up a tier changes the limit on the server you already have, so a plan that is generous for the build and adequate for the app is a legitimate answer when builds are rare.
  3. Cap the heap and hope. NODE_OPTIONS=--max-old-space-size=1536 makes V8 collect garbage more aggressively rather than growing towards a ceiling it does not have. It turns some failures into slow builds. It does not create memory. Why your Node app dies at 2 GB on a 4 GB plan explains the two ceilings involved.

Build time on a fractional CPU is the other half of the trade. A build that takes 40 seconds on a laptop can take several minutes on half a core, and that is several minutes of downtime if you build on the same server that serves the site.

on deploynew build idloaded at startallocated portISR writes backRepositoryapp/ and public/Proxy slotHTTPS on 443next buildneeds devDependenciesNode servernext start.next outputserver, static, cache
From a push to a rendered page

Standalone output, and the two folders it does not copy#

Setting output: "standalone" in next.config.js makes the build emit a self-contained directory: a small server.js plus only the node_modules files that tracing found to be necessary. For a deployment that is typically hundreds of megabytes smaller than shipping the whole tree.

next.config.js
const nextConfig = {  output: "standalone",  compress: false,      // the proxy in front already does this  poweredByHeader: false,};export default nextConfig;

The part that catches everyone is that the standalone build deliberately does not copy two directories, because they are expected to be served by a CDN: public and .next/static. If you move the standalone folder somewhere and run it as-is, the site loads with no CSS, no JavaScript and no images, and the console fills with 404s for /_next/static/.... Copy them in:

bash
$ cp -r public .next/standalone/public$ cp -r .next/static .next/standalone/.next/static$ node .next/standalone/server.js

The generated server reads PORT and HOSTNAME from the environment. Set both explicitly - HOSTNAME=0.0.0.0 in particular, because a server bound to loopback inside a container is unreachable from the proxy, and the failure looks like a broken application rather than a networking setting. Note also that next start is not how you run a standalone build; the build tells you to run server.js directly.

Standalone is worth it when disk or install time is tight. It is one more moving part when they are not, and in a monorepo it needs the output file tracing root pointed at the workspace root or the traced bundle misses packages that live outside the app directory.

Environment variables: baked at build, read at run#

This is the single most confusing thing about self-hosting Next.js, and it has a simple rule underneath.

VariableWhen it is readChanging it needs
NEXT_PUBLIC_*Inlined into the client bundle at buildA rebuild
Server-side vars in dynamic codeEvery requestA restart
Server-side vars in statically prerendered pagesAt build, into the HTMLA rebuild

NEXT_PUBLIC_API_URL is not a variable in the deployed app. It is a string that was substituted into your JavaScript during the build. Setting it to something else on the Startup tab and restarting changes nothing at all, because there is nothing left to change. If a value has to differ between staging and production and it is used in the browser, either build twice or fetch it at runtime from an API route.

The reverse trap is a server-side variable used in a page that was statically prerendered. The value present at build time is in the HTML. Mark the route dynamic, or read the value in a request-scoped path, if it must be current.

Secrets without the NEXT_PUBLIC_ prefix are not exposed to the browser, which is the whole point of the prefix, and they belong in environment variables on the Startup tab rather than in the repository. Where to put secrets on an application server is the general argument, and it applies with extra force here because a leaked NEXT_PUBLIC_ value is already in every visitor's browser.

The cache: ISR, revalidation and what a restart loses#

Incremental static regeneration renders a page once, serves the stored copy, and re-renders it in the background when it goes stale. Self-hosted, that store is a directory on disk under .next/cache. Three consequences follow.

A new build is a new cache. Each build gets its own identifier, so the pages generated by the previous deploy are not reused. The first visitor to each route after a deploy pays for the render. On a site with a lot of ISR routes that is a visible cold period, which is an argument for warming the important pages with a few requests after a deploy rather than letting a real user find them first.

Restarts are fine, ephemeral disks are not. The cache survives a restart because the directory survives. It does not survive a rebuild, and it does not exist at all if the filesystem is thrown away between runs. On a normal server with persistent storage this is a non-issue, which is one of the quiet advantages of running Next.js on an ordinary box.

More than one instance means more than one cache. Two processes each keep their own, so the same URL can be stale on one and fresh on the other. That is what the cacheHandler option in next.config.js is for: point it at a shared store and all instances agree. On a single server you do not need it, and the configuration name has moved between releases, so check the docs for your major version if you adopt it.

Revalidation itself comes in two forms: a time, set with export const revalidate = 3600 on a route or per fetch, and on demand, with revalidatePath or revalidateTag called from a route handler or server action after you change something. On-demand is almost always better for content that changes when a human presses save.

next/image, and the CPU nobody budgeted for#

next/image optimises images on demand in the running server: it resizes, re-encodes to a modern format, and caches the result under .next/cache/images. That is real CPU and real disk, on the same container that renders your pages.

  • Install sharp. Without it the optimiser is dramatically slower, and Next warns about it at start-up.
  • External sources need images.remotePatterns configured, or requests fail with a clear message about the hostname not being configured.
  • The cache is keyed by source, width and quality, so a component that requests eight sizes of a hero image generates eight encodes. deviceSizes and imageSizes control how many variants can exist.
  • minimumCacheTTL decides how long an optimised file is kept before it is produced again.
  • unoptimized: true turns the whole thing off, which is the right answer when your images are already the right size and served from elsewhere.

On a half-core plan, a page with a dozen unoptimised source images will make the first request slow and fill the cache directory with re-encodes. It is the most common reason a self-hosted Next site feels fine locally and sluggish on a small server. What NVMe actually changes is relevant here in an unexpected way: the encoding is CPU-bound, so faster disk does not rescue it.

What the proxy in front needs to do#

Next's own server handles HTTP; the proxy in front handles TLS and the hostname. On RE:NODE that is the proxy slot included with every app plan: point an A record at the address shown and the certificate is issued and renewed automatically within a 21-day window, with the client address forwarded in X-Forwarded-For. What a reverse proxy actually does and pointing a domain at your server cover the two halves.

Four things to get right on that boundary:

  • Compression once. Next compresses responses by default. If the proxy also compresses, set compress: false in next.config.js and let the proxy do it, or you are paying twice for the same bytes.
  • Forwarded protocol. Code that builds absolute URLs needs to know the original request was HTTPS. Read the forwarded headers rather than assuming, or your canonical links and redirects point at http://.
  • Streaming. App Router responses stream, and Suspense boundaries arrive progressively. A proxy that fully buffers responses turns that into a single late response - the page still works, but the perceived speed improvement disappears.
  • Static assets. Files under /_next/static are content-hashed and immutable, so they can be cached hard and for a long time. HTTP caching headers explained covers which headers make that happen, and TTFB, Core Web Vitals and hosting is honest about which parts of a Lighthouse score hosting can actually move.

Deploying and restarting#

The start command on a host that builds on the server looks like this:

bash
npm ci && npm run build && npm run start -- -p $PORT -H 0.0.0.0

That is honest but slow, because the build runs on every start. The better arrangement is to build when the code changes rather than when the process starts: build in CI, commit or upload the output, and let the start command be npm ci --omit=dev && npm run start.

Connect the repository instead of uploading files. On RE:NODE the Git integration is GitHub only, through a GitHub App with short-lived tokens so private repositories work, with two switches: pull the branch on every start, and deploy on push, which restarts a server that was already running. One record per deploy tells you which push is actually live. The walkthrough is in deploy a Node.js app from GitHub.

A deploy costs a gap: the process stops, the new one builds or starts, and until it is listening the proxy has nothing to talk to. With one process you cannot make that zero, only short. Build ahead of time, deploy when traffic is low, and read zero-downtime deploys on a server that only has one of everything for what actually helps. If your Next app also exposes API routes that other things depend on, the operational advice in deploying an Express API to production - timeouts, clean shutdown, health endpoints - applies unchanged.

Troubleshooting#

`Could not find a production build in the '.next' directory`. You started without building, or the build output was not part of what got deployed. .next is usually in .gitignore, so pulling the repository does not bring it.

The build is killed with no error, exit code 137. The container hit its memory limit during the build. Build elsewhere, or build on a larger tier.

The site loads with no styling. A standalone build without public and .next/static copied in.

`EADDRINUSE` on start. The previous process is still holding the port. Stop it properly; on a panel, use Stop rather than starting a second copy.

A page will not update. Work out which cache. A statically generated page needs a revalidation or a rebuild; a fetch result may be cached according to your Next version's defaults; the browser may be holding it. Check in that order.

An environment variable has no effect. If it starts with NEXT_PUBLIC_, it was baked into the bundle at build time and needs a rebuild.

Images 404 or throw a hostname error. images.remotePatterns does not list the source.

FAQ#

Do I need a specific platform to run Next.js?

No. next build and next start are the supported way to self-host, and a Node process behind a reverse proxy runs the framework as designed. What you take on is the operational side: builds, restarts, the cache directory and a certificate, which is what the rest of this post is about.

Why does my build run out of memory when the app only uses 200 MB?

Because compilation, type checking and bundling all happen at once and hold the whole module graph. The running app holds almost none of that. Size for the build, or move the build off the server.

Should I use standalone output?

Use it if install time or disk is tight, or if you are building an image to ship. Remember to copy public and .next/static next to the generated server.js, and set HOSTNAME and PORT explicitly.

Can I host a Next.js site on a static or PHP plan?

Only if you export it. output: "export" produces plain HTML, CSS and JavaScript with no server, which any static host serves - and which gives up server rendering, ISR, route handlers and image optimisation. If your site is content that changes at build time, that trade is often a good one; static site hosting and web hosting cover it.

How much memory does a Next.js server need at runtime?

A small site is comfortable in 512 MB to 1 GB once it is built, growing with concurrency, the size of the route table and the image cache. The build is the peak, not the serving. Watch the memory graph in the console for a day before deciding, and read sizing a web app for launch day if you are expecting a spike.

Why is my page stale after I changed the content?

Because something cached it deliberately. Work out whether it is a statically generated page waiting for its revalidation window, a cached fetch, or the browser. On-demand revalidation with revalidatePath after a content change removes the guesswork.


კომენტარები

სრულიად ანონიმურად: ანგარიშის, ელფოსტის და cookie-ის გარეშე. ინახება მხოლოდ სახელი, ტექსტი და დრო - სხვა არაფერი. ბმულების რაოდენობა ლიმიტირებულია.

0/2000