All Articles
CategoryDevOps
Reading Time
15 min read
Published
2025-12-16
Word Count
3,518words

Grab a coffee — this one is a deep dive!

Self-Host Next.js: systemd + nginx

Summary

Self-hosting Next.js: take the output:'standalone' build to production with a non-root systemd service, an nginx reverse proxy, and a build-lock zero-downtime deploy.

  • output: 'standalone' copies only necessary files and select node_modules packages — but not public/ and .next/static/, the most common cause of 404s.
  • Run the Next.js server as a separate, nologin systemd service user, not root, with hardening directives like NoNewPrivileges/ProtectSystem.
  • Put nginx in front as a reverse proxy, add an immutable Cache-Control header for /_next/static/; brotli and gzip_static both require a separate nginx build, not core.
  • For zero-downtime deploys, combine build-lock + release-directory/symlink + post-restart health-retry + rollback to the previous release on failure.
Self-Host Next.js: systemd + nginx

Self-hosting Next.js on your own server starts with the standalone output: when you turn on output: 'standalone', Next.js collects all the files needed for production plus a selected subset of packages from node_modules into a single folder, so you can run it on any VPS, not just Vercel. In this guide you'll see, step by step, how to bring this folder up under systemd with a non-root user, how to put it behind nginx, and how to deploy without taking the site down during a build.

💡 Pro Tip: The standalone output does not automatically copy the public/ and .next/static/ folders — if you leave them out of your deploy script, the site stays up but every CSS/JS asset returns a 404.

Table of Contents

What `output: 'standalone'` actually produces

When you add output: 'standalone' to next.config.js, Next.js can produce a standalone folder that, according to the official docs, automatically copies "only the files necessary for a production deployment, including select files in node_modules" into .next/standalone. A minimal server.js is also generated inside this folder; it replaces the next start command and runs on its own, with no need for the next CLI or the full project node_modules.

In practice this drastically shrinks the size of the deploy package: you can copy the .next/standalone folder to a VPS and run it directly, without having to npm install every package.json dependency on the server.

Output mode
What it produces
What the server needs
Default (next build)
.next/ + the full node_modules
Full node_modules, next start
output: 'standalone'
.next/standalone/server.js + select dependencies
Only server.js + the copied public/static folders
output: 'export'
Static HTML/CSS/JS
Any static file server (no Next.js server)

To run standalone:

bash
1PORT=3000 HOSTNAME=127.0.0.1 node server.js

The docs describe these two variables like this: "If your project needs to listen to a specific port or hostname, you can define PORT or HOSTNAME environment variables before running server.js." Behind a reverse proxy, setting HOSTNAME to 127.0.0.1 keeps the Node process on the local interface, never exposed directly to the internet.

Standalone mode's real payoff is removing the npm install step on the server entirely. In a classic deploy flow, the build machine and the runtime machine must share the same node_modules tree; in standalone mode that tree is pruned during the build, and only packages actually required get carried over. That shrinks the deploy package and removes the risk of a dependency-install step failing on the server (network issues, registry problems, version mismatches) — the server just needs a working Node.js runtime, not a package manager.

File placement and public/.next/static sync (the classic 404 trap)

Here's the most common trap with the standalone output: the minimal server.js does not automatically copy the public/ folder or the .next/static/ folder. The Next.js docs state this explicitly — if you want to serve these two folders via a CDN you have to copy them by hand, and if you don't, you still need to include them in your deploy script.

The result: the build succeeds, node server.js starts up fine, even the homepage renders — but every /_next/static/* request and every favicon, image, or font under public/ returns a 404. This is usually the root cause of the "the build worked but the site looks broken" complaint.

The correct directory layout should look like this:

bash
1# after next build, to complete the standalone folder:
2cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/

Moving the .next/standalone folder as-is without adding this exact step from the docs to your build script is the most common cause of 404s in production.

Non-root systemd unit + hardening

Running the standalone server.js as root is an unnecessary risk — if the process is compromised, the attacker gets root directly. Common production practice: run the app as a separate, login-disabled (nologin) system service user, and add a handful of hardening directives to the systemd unit file.

ini
1[Unit]
2Description=Next.js standalone app
3After=network.target
4 
5[Service]
6Type=simple
7User=webapp
8Group=webapp
9WorkingDirectory=/opt/myapp/current
10Environment=PORT=3000
11Environment=HOSTNAME=127.0.0.1
12ExecStart=/usr/bin/node server.js
13Restart=on-failure
14RestartSec=5
15NoNewPrivileges=true
16ProtectSystem=full
17ProtectHome=true
18PrivateTmp=true
19 
20[Install]
21WantedBy=multi-user.target
Directive
What it does
User=/Group=
Runs the process as a separate non-root system user
NoNewPrivileges=true
Blocks the process and its children from gaining new privileges
ProtectSystem=full
Makes directories like /usr, /boot, /etc read-only
ProtectHome=true
Blocks access to /root and user home directories
PrivateTmp=true
Gives the process an isolated, unshared /tmp
Restart=on-failure
Automatically restarts if the process crashes

These directives are a general systemd capability, not specific to Next.js; they work the same on any VPS distribution (Ubuntu, Debian, etc.). Just adjust WorkingDirectory and the port for your own environment.

Creating the service user is a one-liner:

bash
1useradd --system --no-create-home --shell /usr/sbin/nologin webapp
2chown -R webapp:webapp /opt/myapp

The --no-create-home and --shell /usr/sbin/nologin flags guarantee this user can't log in interactively and can only be used by systemd to start a process. Be careful before tightening ProtectSystem=full/ProtectHome=true to something as aggressive as ProtectSystem=strict: some Node.js versions need writable+executable memory pages for the V8 JIT, and an overly restrictive MemoryDenyWriteExecute=true can cause unexpected runtime crashes — test this directive outside production before adding it.

nginx reverse proxy, brotli, and immutable asset headers

Next.js's own self-hosting guide recommends putting a reverse proxy (like nginx) in front of the Node process instead of exposing it directly to the internet.

nginx
1server {
2 listen 80;
3 server_name _;
4 
5 location /_next/static/ {
6 alias /opt/myapp/current/.next/static/;
7 add_header Cache-Control "public, max-age=31536000, immutable";
8 }
9 
10 location / {
11 proxy_pass http://127.0.0.1:3000;
12 proxy_http_version 1.1;
13 proxy_set_header Upgrade $http_upgrade;
14 proxy_set_header Connection "upgrade";
15 proxy_set_header Host $host;
16 proxy_set_header X-Real-IP $remote_addr;
17 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
18 proxy_set_header X-Forwarded-Proto $scheme;
19 proxy_buffering off;
20 }
21}

Files under _next/static/ carry a content hash, so they're effectively immutable; per the guide, Next.js itself sends Cache-Control: public, max-age=31536000, immutable for truly immutable assets, and this header can't be overridden — sending the same header on the nginx side keeps things consistent at the CDN/proxy layer.

Three more notes:

  • Streaming: the same guide says that behind nginx or a similar proxy, you need to disable buffering for streaming to work; the guide's own path is sending X-Accel-Buffering: no from within next.config.js. Above I disabled the same behavior on the nginx side with proxy_buffering off;; X-Forwarded-For and X-Forwarded-Proto let the app see the client's real IP and scheme.
  • Brotli: nginx's core has no built-in brotli support — `ngx_brotli` is a third-party Google module (filter + static submodules), and you need to recompile nginx with it.
  • Precompressed gzip serving: nginx's own precompressed-file feature is also outside core; compile nginx with --with-http_gzip_static_module for `ngx_http_gzip_static_module`.

Neither module ships by default in nginx's official package; on Ubuntu/Debian you either compile from source or use a third-party repo with these modules (e.g., a distribution's own nginx-extras-style package). With both added, nginx can prefer brotli based on the client's Accept-Encoding header, falling back to gzip when brotli isn't supported — an automatic fallback between old and new browsers.

Build-lock and zero-downtime deploy

The most common cause of downtime in a standalone deploy: a new next build overwrites the same directory while the old build is still serving traffic — files the running process reads change mid-build, and it crashes with something like MODULE_NOT_FOUND. Three steps help: a lock file blocking concurrent builds, building into a separate "release" directory and swapping it in atomically via symlink, and after a restart, retrying a health check a few times and, on failure, pointing the symlink back at the previous release.

bash
1#!/usr/bin/env bash
2set -e
3 
4LOCK=/tmp/nextjs-build.lock
5exec 9>"$LOCK"
6flock -n 9 || { echo "Build already running, exiting"; exit 1; }
7 
8RELEASE_DIR="/opt/myapp/releases/$(date +%s)"
9mkdir -p "$RELEASE_DIR"
10 
11npm run build
12cp -r .next/standalone/. "$RELEASE_DIR"/
13cp -r public "$RELEASE_DIR"/ && cp -r .next/static "$RELEASE_DIR"/.next/
14 
15PREV=$(readlink /opt/myapp/current || true)
16 
17ln -sfn "$RELEASE_DIR" /opt/myapp/current.tmp
18mv -T /opt/myapp/current.tmp /opt/myapp/current
19 
20systemctl restart myapp
21 
22for i in 1 2 3; do
23 sleep $((i * 3))
24 curl -sf http://127.0.0.1:3000/ && { echo STANDALONE_OK; exit 0; }
25done
26 
27if [ -n "$PREV" ]; then
28 echo "Health check failed, rolling back to previous release"
29 ln -sfn "$PREV" /opt/myapp/current.tmp
30 mv -T /opt/myapp/current.tmp /opt/myapp/current
31 systemctl restart myapp
32else
33 echo "No previous release, cannot roll back"
34fi
35exit 1

This is a general deploy principle, independent of tooling: if the build fails or the health check doesn't pass, don't insist on the new release — point the symlink back and let the old version keep serving traffic.

To avoid accumulating release directories, it's common to add a cleanup step at the end of the script — keeping the three most recent releases and deleting older ones preserves disk space while leaving room for a quick rollback. Rolling back then comes down to one command: point the symlink at the previous release and restart the service.

How ISR / 'use cache' behaves in self-hosting

In a self-hosted setup, ISR works automatically, with no extra configuration required, on a single server process with persistent local disk. My own practical note: once you run multiple instances or put a CDN/reverse proxy in front of it, you need to plan the cache behavior separately.

When Next.js 16 went GA (around October 2025), the `"use cache"` directive and Cache Components arrived, and experimental.ppr was removed — a new model for explicitly marking, in code, which components or functions should be cached.

Single-server, this on-disk cache works fine. Scale horizontally with more than one server.js process (different machines, or multiple instances on one) and each instance gets its own local disk cache — then you need a shared storage layer (e.g., a common directory reachable over the network) for consistency; that's outside a single-server VPS's scope.

One more detail with the release-directory pattern: since each deploy opens a new release directory, the previous release's on-disk ISR cache doesn't carry over automatically. The new release fills its cache from scratch on first requests — usually fine, but on a very frequently deploying setup, the warm-up can cause brief delays on high-traffic pages. To cut this cost, point the ISR cache directory at a fixed path shared across releases (outside the symlink, in its own directory).

Purge strategy in front of a CDN (Cloudflare)

Put a CDN (say, Cloudflare) in front of nginx and you need to prevent changed pages from going stale in the edge cache after a deploy. Per Cloudflare's own docs, purging a single URL/file is the preferred method — targeting only what actually changed, instead of wiping the whole zone, keeps the rest of the edge cache intact.

A practical split:

  • Files under /_next/static/* are content-hashed and immutable, so they never need purging — the filename changes, so the old version is unreachable anyway.
  • HTML pages (/, /blog/*) can change on every deploy, so the deploy script's last step should issue a single-URL purge for the changed URLs.

Automate this split by having the deploy script know which pages' content actually changed — purging just one updated post's URL is far cheaper than purging the whole site, and keeps the rest of the edge cache warm. Reserve zone-wide purges for rare, broad cases, like a configuration change.

Monitoring and automatic rollback

The last link in the deploy chain is monitoring: don't call a deploy "done" without verifying the service actually came back up after a restart. If you hit the health-check endpoint (which can just be the homepage) a few times at increasing intervals and get no response, pointing the symlink back at the previous release and restarting with that version beats a restart (crash) loop.

Think of three behaviors together: (1) the build-lock stops two builds running at once, (2) the release-directory + symlink pattern makes the deploy atomic, (3) health-retry + rollback stops a bad build from taking the site down. Together, these get you as close as practically possible to "zero downtime" on a single-machine VPS.

Day to day, systemd's own log collector is usually enough; before reaching for a separate log-shipping tool, journalctl -u <service-name> -n 200 --no-pager shows the last lines, and journalctl -u <service-name> -f follows live. Logging the exact moment the script detects a failed health check as its own line makes it easy to later trace which deploy got rolled back, and when.

GOLDEN TIP

The most valuable insight in this article

This tip holds the article's most important takeaway.

Easter Egg

You found a hidden gem!

There's a hidden detail in this section. Want to uncover it?

Reader Reward

We've collected everything you should check before putting this guide into practice into a single list. Work through each item one by one, and you'll avoid the five most common standalone-deploy mistakes: missing static files (404), a service running as root, a concurrent build collision, an unpurged HTML cache, and a restart with no health check.

FAQ

How do I run a Next.js standalone build on my own server?

Add output: 'standalone' to next.config.js, run next build, copy the resulting .next/standalone folder to your VPS, add the public/ and .next/static/ folders inside it, then start it with PORT=3000 HOSTNAME=127.0.0.1 node server.js (the local interface is enough behind a reverse proxy). This minimal server.js replaces next start and needs no full project node_modules.

How do I write a systemd service file for Next.js?

Create a .service file under /etc/systemd/system/, set ExecStart=/usr/bin/node server.js, set WorkingDirectory to the path of the standalone folder, and set User/Group to a non-root service user. Directives like NoNewPrivileges=true and ProtectSystem=full add extra hardening. You can start it and enable it on boot with systemctl enable --now <service-name>.

Why do static files 404 behind nginx with Next.js?

Most commonly, the standalone server.js doesn't auto-copy the public/ and .next/static/ folders — explicitly documented in Next.js's docs. Skip that copy step in your build script and the page HTML renders but CSS/JS/image files 404. Second most common: the alias path in nginx's location /_next/static/ block points at the wrong directory.

How do ISR and caching work in self-hosted Next.js?

Per Next.js's self-hosting guide, ISR works automatically on a single self-hosted next start instance with persistent local disk; the guide tells you to separately review cache configuration if you run multiple instances or put a CDN/reverse proxy in front. On the single-server setup in this post, that automatic behavior works fine. The "use cache" directive from Next.js 16 lets you explicitly mark, in code, which data or component gets cached. Run more than one process/instance and each has its own local cache, so you may need a shared storage layer.

What's the difference between a standalone build and a normal `next build`?

A normal next build needs the full node_modules on the server to run next start. output: 'standalone' instead collects only necessary files and select dependencies into one portable folder, produces a minimal server.js, and shrinks the deploy package.

Why put a reverse proxy in front of the Next.js server?

Next.js's own self-hosting guide recommends a reverse proxy like nginx in front of the Node process rather than exposing it directly. That lets you handle TLS termination, compression (gzip/brotli), static file cache headers, and rate limiting in a separate layer, outside the Next.js process.

How many release directories should I keep on the server?

No fixed rule; common practice is keeping the two or three most recent and deleting older ones. It depends on disk space and rollback frequency — a team deploying often usually finds the last few releases enough to fall back on.

Update (September 2026)

The body of this guide was written against the version of Next.js current as of 2025-12-16. A few things have changed since then:

  • Next.js 16.3 GA shipped (around summer 2026). According to the official blog post, the App Router now uses native Node.js streams instead of web streams for SSR; this change lets it handle up to 22% more requests under load with no code-side changes required (related PR).
  • Immutable static assets can now be shared across deploys: according to the docs (last updated 2026-07-20), with config.supportsImmutableAssets, content-addressed /_next/static/immutable/* files can be reused across deploys without "skew" risk. This is an interesting development for setups using the release-directory + symlink pattern described above; however, as of this writing it isn't clear how cache sharing behaves with the release-directory approach, so you should test it in your own environment before turning it on.
  • Turbopack's filesystem cache is on by default in next build: per the same blog post, this can speed up repeat builds by up to 5.5x on some projects.
  • Node.js release line: as of 2026-09-24, per the Node.js downloads page, the Node 26.x line is listed as "Current", while the LTS line is Node 24.x. For running the standalone server in production, don't move to Current — stay on the LTS line.

Conclusion

Combine the standalone output with the correct directory layout, a non-root systemd service, an nginx reverse proxy, and a build-lock deploy script, and you can run Next.js on your own VPS with reliability close to Vercel's. I'd recommend reading this playbook alongside iOS CI/CD Pipeline: GitHub Actions and Fastlane on the mobile side — the build-lock and health-check philosophy described there shares the same roots as the server-side deploy pattern covered here. For a broader DevOps view, see Mobile DevOps Best Practices; if you're looking for a lightweight backend alternative, see Hono.js: Serverless Web Framework Production Guide; to harden your API layer, see the Network Layer Optimization guide; and if you're using Swift on the server side, take a look at Server-Side Swift Ecosystem.

Sources

Tags

#Next.js#self-hosting#systemd#nginx#VPS#DevOps#standalone#deploy
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share