Adios
BlogEngineering

Engineering

Running Next.js as a Node Server Without Lambda

Deploy Next.js as a Node server with standalone builds, systemd, NGINX, streaming, shared-cache decisions, and safe release rollouts.

Adios teamUpdated September 26, 202620 min read

Next.js can run as a persistent Node process: build the app, start its production server, and send HTTP requests to it. Server rendering, Route Handlers, Server Actions, image optimization, and streaming can run there without a Lambda adapter. Here is how to package that server, keep it running, and deploy a second instance without creating cache or release problems.

What runs inside the Node process

An incoming request reaches your reverse proxy, then a Next.js server. That server can return a prebuilt page, render a page for the current request, execute a Route Handler, or serve a cached response. A cache miss may call your database or API. You do not need to write an Express wrapper to make this work.

A persistent process can reuse database pools and HTTP connections between requests. You still need enough CPU and memory for peak traffic, a restart policy, and a deployment procedure. A restart, a new replica, or an empty cache can all make requests slower; running Node does not remove those costs.

Choose the production output for the application
OutputHow you run itWhat to deploy
Standard Node servernext build, then next start.The build, public assets, package metadata, and required runtime dependencies.
Standalone Node serverSet output: standalone, build, then run the generated server.js.Traced runtime files plus public and .next/static. This is the main path below.
Static exportServe the exported files from an HTTP server or object store.Static files only. Request-time server features need another backend.

The deployment we will build

Browser
   |
   v
NGINX :443  -- TLS, request limits, streaming passthrough
   |
   v
Next.js / Node :3001  -- rendering, routes, Server Actions
   |               |
   v               v
Database / API   Next.js caches

systemd supervises the Node process.
Each release gets its own directory and configuration.

Run the production build first

Make sure the app works with the production server before configuring a VM or container. Keep these scripts in package.json and install from the committed lockfile. Install build dependencies before building; omitting devDependencies too early can remove tools the compiler needs.

package.json scripts

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  }
}

Check the same mode you will deploy

With standard output, the commands below start the full Node server on loopback. Check a dynamic page, an authenticated request, and a Route Handler as well as the homepage. A successful next dev session does not exercise production prerendering, dependency tracing, or production cache behavior.

Standard output: local production check

npm ci
npm run build
npm start -- --hostname 127.0.0.1 --port 3000

# In a second terminal:
curl --fail --show-error -I http://127.0.0.1:3000/

Package a standalone release

Standalone output packages the files Next.js traces for the server and generates its server.js entry point. Add the options below to your existing next.config.mjs, preserving the application’s other settings. Give each build a release identifier and reuse its exact output for every replica of that release.

next.config.mjs

const nextConfig = {
  output: "standalone",
  deploymentId: process.env.RELEASE_ID,
};

export default nextConfig;

Include the browser assets

Standalone output does not copy public or .next/static automatically. Include them when the Node server will serve those files. A page can return HTML successfully while every browser script returns 404 if this step is missed.

Build, package, and start

npm ci
RELEASE_ID=release-001 npm run build
mkdir -p .next/standalone/.next
cp -a .next/static .next/standalone/.next/static
if [ -d public ]; then
  cp -a public .next/standalone/public
fi

HOSTNAME=127.0.0.1 PORT=3001 RELEASE_ID=release-001 \
  node .next/standalone/server.js

Test the artifact away from the source tree

Copy the standalone directory to a clean location and start server.js there. This catches dependencies that only worked because the source checkout happened to be present. In a monorepo, inspect the generated directory structure: outputFileTracingRoot and outputFileTracingIncludes may be needed for shared packages or files opened through dynamic paths.

A separate terminal, using a different port

release_dir=$(mktemp -d)
cp -a .next/standalone/. "$release_dir/"
cd "$release_dir"
HOSTNAME=127.0.0.1 PORT=3002 RELEASE_ID=release-001 node server.js

Separate build-time values from runtime secrets

A server-only environment variable can be read at request time. A NEXT_PUBLIC_ variable is compiled into browser JavaScript when you build. Changing it when the process starts will not update an existing browser bundle. Server-rendered output generated during the build can also capture the values available at that time.

When configuration takes effect
ValueSet it whenDeployment consequence
NEXT_PUBLIC_API_URLBuilding browser assets.Rebuild to change an inlined URL, or expose intentional public runtime configuration through your own endpoint.
DATABASE_URL / API credentialsStarting the server, when the code reads them dynamically.Keep them out of public variables, source control, and image layers.
deploymentIdBuilding the release.Use one identifier for all replicas of that artifact. A startup-only change does not rewrite the build.
PORT / HOSTNAMEStarting server.js.Use loopback behind a proxy on the same host; use 0.0.0.0 inside a container or managed workload.

Add an uncached health route

This route waits for a request before reading RELEASE_ID and reports process-level health. It does not test a database. If serving traffic requires a database, add a separate readiness check using your database client’s query and connection timeouts; return 503 when that required path fails. Do not make an optional analytics service a readiness dependency.

src/app/api/health/route.js

import { connection } from "next/server";

export async function GET() {
  await connection();
  return Response.json(
    { ok: true, release: process.env.RELEASE_ID || "unknown" },
    { headers: { "Cache-Control": "no-store" } },
  );
}

Keep the Node server running with systemd

On a VM, run the packaged server under a dedicated user and let systemd restart it after a crash. Use a separate directory for each release so a deployment cannot overwrite files a running process still needs. The commands below assume a fresh Debian-style host with Node installed; check command -v node and adjust ExecStart if its path differs.

Install the already-built artifact on the target host

sudo useradd --system --home-dir /srv/next --shell /usr/sbin/nologin nextjs
sudo install -d -o nextjs -g nextjs /srv/next/releases/release-001
sudo cp -a .next/standalone/. /srv/next/releases/release-001/
sudo chown -R nextjs:nextjs /srv/next/releases/release-001
sudo install -d -m 700 /etc/next
sudo touch /etc/next/release-001.env
sudo chmod 600 /etc/next/release-001.env
sudoedit /etc/next/release-001.env

Set the release environment

Use these values in the environment file and add the app’s required server-side secrets through your deployment mechanism. The heap limit is an example starting point, not a capacity estimate. Node’s total memory also includes native allocations and buffers, so leave headroom below the service’s total memory limit.

/etc/next/release-001.env

PORT=3001
RELEASE_ID=release-001
NODE_OPTIONS=--max-old-space-size=768

Start a named release

Save this template as /etc/systemd/system/next@.service. The %i value selects the release directory and environment file. A second release can use its own port and run beside the first while you check it. This unit supervises the process; it does not remove an unhealthy process from a load balancer.

/etc/systemd/system/next@.service

[Unit]
Description=Next.js release %i
After=network.target

[Service]
Type=simple
User=nextjs
Group=nextjs
WorkingDirectory=/srv/next/releases/%i
Environment=NODE_ENV=production
Environment=HOSTNAME=127.0.0.1
EnvironmentFile=/etc/next/%i.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=30
MemoryMax=1G
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Inspect startup before adding traffic

The health response should identify release-001. Check the journal for missing modules, invalid configuration, or cache-directory permissions. Keep the release’s writable Next.js cache paths available; turning the entire filesystem read-only without a cache plan can break regeneration or image optimization.

Start and inspect

sudo systemctl daemon-reload
sudo systemctl enable --now next@release-001
sudo journalctl -u next@release-001 -n 50 --no-pager
curl --fail --show-error http://127.0.0.1:3001/api/health

Put NGINX in front and preserve streaming

Keep the Node port private. On the same VM, NGINX can terminate HTTPS and forward to 127.0.0.1:3001. The example assumes DNS already points to the host and a valid certificate exists at the listed paths. Replace app.example.com, then validate with nginx -t before reloading.

Leave proxy caching off initially and disable response buffering so streamed responses can reach the browser as they arrive. Preserve the public host and scheme for redirects and origin checks. This header configuration assumes NGINX is the public edge; if a trusted load balancer precedes it, configure that trust boundary explicitly.

/etc/nginx/conf.d/next.conf, inside the http context

upstream next_app {
  server 127.0.0.1:3001;
  keepalive 32;
}

server {
  listen 80;
  server_name app.example.com;
  return 301 https://app.example.com$request_uri;
}

server {
  listen 443 ssl;
  server_name app.example.com;
  ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
  ssl_protocols TLSv1.2 TLSv1.3;
  client_max_body_size 10m;
  if ($host != app.example.com) { return 421; }

  location / {
    proxy_pass http://next_app;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_buffering off;
    proxy_cache off;
    proxy_connect_timeout 5s;
    proxy_read_timeout 60s;
  }
}

Test two chunks through the public hostname

Add this diagnostic Route Handler and rebuild the release. Request it directly and through NGINX with curl --no-buffer. The first line should arrive before the second. If both arrive together only through the public route, inspect every proxy and CDN between the browser and Node for buffering.

src/app/api/stream/route.js

import { connection } from "next/server";

export async function GET() {
  await connection();
  const encoder = new TextEncoder();
  const body = new ReadableStream({
    async start(controller) {
      controller.enqueue(encoder.encode("first chunk\n"));
      await new Promise((resolve) => setTimeout(resolve, 1000));
      controller.enqueue(encoder.encode("second chunk\n"));
      controller.close();
    },
  });
  return new Response(body, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "no-store",
      "X-Accel-Buffering": "no",
    },
  });
}

Check limits at both layers

proxy_read_timeout is an idle timeout between upstream reads. Long-lived streams need suitable timeouts and, where appropriate, heartbeat data. Upload limits also exist in the app: increasing NGINX’s body limit does not change Next.js Server Action limits. Adjust the specific limit you hit instead of opening every limit globally.

Check proxy configuration and streaming

sudo nginx -t
sudo systemctl reload nginx
curl --fail --no-buffer http://127.0.0.1:3001/api/stream
curl --fail --no-buffer https://app.example.com/api/stream

Know which cache you are changing

There is no single Next.js cache switch that covers every layer. A stale product page could come from an application cache, a generated route, a CDN response, or browser navigation state. Identify the layer before changing TTLs or adding Redis.

Cache responsibilities in a self-hosted deployment
LayerWhat it holdsWhat to check
Build assetsJavaScript, CSS, and other files under .next/static.Deploy assets from the same build and retain files needed by browsers using the previous release.
Data cache / ISRCached fetch data and regenerated route output in the relevant caching model.Local storage must be writable. Multiple replicas need deliberate storage and invalidation coordination.
Cache ComponentsValues created through use cache and related directives.The default is process-local memory. A remote directive needs a configured external handler to share data.
Image optimizationGenerated next/image variants.Watch CPU, disk, and first-request latency. A data-cache handler does not automatically share optimized image files.
Reverse proxy / CDNHTTP responses allowed by the edge’s cache policy.Respect Cache-Control and response variation. Authenticated pages and shared public responses need different policies.

The two cache-handler APIs are different

For the incremental server cache used by ISR and cached data, Next.js exposes cacheHandler, singular. When configuring shared storage for that model, cacheMaxMemorySize: 0 can disable the per-process memory layer. Your handler must implement the required storage and tag behavior.

Cache Components use cacheHandlers, plural. A configured remote handler can back use cache: remote; without one, that directive alone does not provision Redis or create a shared cache. Coordinate tag state as well as values, including refreshTags where the handler API requires it. Select the API for your installed Next.js version and caching model.

In Next.js 16.2 and later, optimized images can opt into cacheHandler through images.customCacheHandler: true. That handler must support IMAGE entries, including their binary data and expiry. Configure and test this explicitly if you want image variants shared across replicas.

Do not cache all HTML at the edge

Next.js navigation can request React Server Component payloads as well as HTML. A CDN must preserve the framework’s required request variation and cache keys. A generic cache-everything rule can mix response types or expose personalized content. Begin with the framework’s cache headers and the official CDN guidance, then test logged-in and logged-out requests separately.

Add replicas without duplicating problems

Run the same artifact on every replica in a release, but give each process its own runtime identity and writable local paths. Store durable uploads outside the release directory. Sessions must be usable by whichever replica handles the next request, through verified cookies or a shared session store rather than a process-local object.

Budget database connections across the fleet. Four processes with a maximum pool of ten connections can use forty connections; keeping four old processes alive during a rollout can raise that to eighty before workers and administrative connections are counted. Set pool limits against the database’s capacity and the largest temporary replica count.

Measure CPU, event-loop delay, total process memory, request latency, and errors under representative load. A persistent Node process can handle concurrent I/O, but CPU-heavy JavaScript can hold up unrelated requests. Move expensive background work to a worker and scale based on measured bottlenecks. Increasing the V8 heap limit does not fix a CPU bottleneck.

Deploy a new release while the old one is still in use

Start release-002 in its own directory and on port 3002 while release-001 still serves traffic on 3001. Run health and application checks against 3002. Then change the NGINX upstream to 3002, validate the configuration, and reload it. Keep the old process available while existing requests drain and while you verify the new release.

A browser may still hold JavaScript and prefetched data from release-001. deploymentId lets Next.js detect a mismatch and trigger a full navigation, which can discard unsaved component state. The dpl query parameter does not make Next.js route requests to an older release. Retain old assets and use version-aware routing if clients must continue talking to that release.

Release state that must stay consistent
ConcernWhat to preserveFailure to test
Browser assetsThe files referenced by both new and still-open old pages.Open a page before promotion, then load a lazily imported component afterward.
Server ActionsOne artifact and compatible action encryption configuration across its replicas.Submit a form loaded before promotion after traffic has switched.
Database schemaCompatibility with both releases during overlap and rollback.Run the old release against the migrated schema before declaring rollback available.
Requests in progressA measured drain period before terminating the old process.Switch traffic during a slow response or stream and check completion.

Keep Server Action keys consistent

Server Action closure encryption uses a build-time key. Reusing one artifact preserves its generated key across replicas. If you manage NEXT_SERVER_ACTIONS_ENCRYPTION_KEY explicitly, inject the same valid base64-encoded AES key into the builds that need it and protect it as a secret. A shared key does not make different builds’ action IDs interchangeable.

Drain, stop, and keep a rollback path

Remove the old instance from new traffic before sending SIGTERM. Allow in-flight work to finish within your shutdown budget; increase the example’s 30-second limit if measured request behavior requires it. after() callbacks are not a durable job queue, so work that must survive a crash belongs in a persistent queue with retry handling.

If the new release fails, point traffic back to the known-good process and retain logs from the failed one. Database migrations and external side effects may make that rollback unsafe, which is why schema compatibility needs testing before promotion.

Deploy the same server model on Adios

On Adios, declare the build command, standalone start command, listening port, and health path in adios.yaml. The public gateway forwards to the Node workload, so the workload must listen on 0.0.0.0. The VM’s systemd and NGINX configuration is not needed inside that managed workload.

Set a unique RELEASE_ID in both build.env and env for each deployment so the build and running process identify the same release. Include devDependencies during the build even if NODE_ENV is production. This example starts with one replica. Raising the count does not configure a shared cache, shared sessions, or database capacity for you.

adios.yaml

name: web
region: de
replicas: 1

build_cmd: |
  npm ci --include=dev
  npm run build
  mkdir -p .next/standalone/.next
  cp -a .next/static .next/standalone/.next/static
  if [ -d public ]; then cp -a public .next/standalone/public; fi
start_cmd: node .next/standalone/server.js

build:
  env:
    RELEASE_ID: release-001

env:
  NODE_ENV: production
  HOSTNAME: 0.0.0.0
  PORT: "3000"
  RELEASE_ID: release-001

runtime:
  name: node@24
  port: 3000
  health_path: /api/health
  memory_mb: 1024

Check these failures before adding traffic

Run the checks against the packaged production release and its final hostname. Keep the release ID in application logs so you can tell whether an error belongs to one replica, one build, or the whole service.

  • —A clean artifact starts without access to the source checkout.
  • —Health, authenticated requests, assets, image handling, and streaming work through HTTPS.
  • —A process restart recovers, and a failed dependency produces the intended readiness response.
  • —An old browser tab behaves correctly across promotion, including form submission.
  • —Multiple replicas agree on data after invalidation, and the database has connection headroom.
  • —The previous release and a compatible schema remain available for rollback.
Production verification and troubleshooting
SymptomLikely place to investigateVerification
HTML works; scripts or styles return 404Missing .next/static files or mixed releases.Check a script URL from the actual HTML and confirm its release artifact contains the file.
The public URL returns 502Listener address, port mismatch, or a crashed process.Request the health route directly on the Node port, then inspect the proxy and process logs.
Streaming arrives all at onceBuffering in the reverse proxy or CDN.Compare the two-chunk endpoint directly and through the public hostname.
The browser still uses an old API URLA NEXT_PUBLIC_ value frozen into the bundle.Inspect the built browser code and rebuild with the intended public configuration.
Only some requests show stale dataIndependent replica caches or an edge cache.Check each replica directly and verify both stored values and tag invalidation.
Server Actions fail after a rolloutBuild skew, encryption-key mismatch, or origin headers.Compare release IDs, the artifact used by each replica, and Host/Origin forwarding.
The process is killed under loadTotal memory exceeds the host or container limit.Check RSS, image optimization load, cache growth, and the supervisor’s exit reason.
  All articles