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.
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.
| Output | How you run it | What to deploy |
|---|---|---|
| Standard Node server | next build, then next start. | The build, public assets, package metadata, and required runtime dependencies. |
| Standalone Node server | Set output: standalone, build, then run the generated server.js. | Traced runtime files plus public and .next/static. This is the main path below. |
| Static export | Serve 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.jsTest 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.jsSeparate 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.
| Value | Set it when | Deployment consequence |
|---|---|---|
| NEXT_PUBLIC_API_URL | Building browser assets. | Rebuild to change an inlined URL, or expose intentional public runtime configuration through your own endpoint. |
| DATABASE_URL / API credentials | Starting the server, when the code reads them dynamically. | Keep them out of public variables, source control, and image layers. |
| deploymentId | Building the release. | Use one identifier for all replicas of that artifact. A startup-only change does not rewrite the build. |
| PORT / HOSTNAME | Starting 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.envSet 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=768Start 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.targetInspect 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/healthPut 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/streamKnow 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.
| Layer | What it holds | What to check |
|---|---|---|
| Build assets | JavaScript, 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 / ISR | Cached 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 Components | Values 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 optimization | Generated next/image variants. | Watch CPU, disk, and first-request latency. A data-cache handler does not automatically share optimized image files. |
| Reverse proxy / CDN | HTTP 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.
| Concern | What to preserve | Failure to test |
|---|---|---|
| Browser assets | The files referenced by both new and still-open old pages. | Open a page before promotion, then load a lazily imported component afterward. |
| Server Actions | One artifact and compatible action encryption configuration across its replicas. | Submit a form loaded before promotion after traffic has switched. |
| Database schema | Compatibility with both releases during overlap and rollback. | Run the old release against the migrated schema before declaring rollback available. |
| Requests in progress | A 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: 1024Check 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.
| Symptom | Likely place to investigate | Verification |
|---|---|---|
| HTML works; scripts or styles return 404 | Missing .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 502 | Listener 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 once | Buffering in the reverse proxy or CDN. | Compare the two-chunk endpoint directly and through the public hostname. |
| The browser still uses an old API URL | A NEXT_PUBLIC_ value frozen into the bundle. | Inspect the built browser code and rebuild with the intended public configuration. |
| Only some requests show stale data | Independent replica caches or an edge cache. | Check each replica directly and verify both stored values and tag invalidation. |
| Server Actions fail after a rollout | Build 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 load | Total memory exceeds the host or container limit. | Check RSS, image optimization load, cache growth, and the supervisor’s exit reason. |