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 thepublic/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
- File placement and public/.next/static sync (the classic 404 trap)
- Non-root systemd unit + hardening
- nginx reverse proxy, brotli, and immutable asset headers
- Build-lock and zero-downtime deploy
- How ISR / 'use cache' behaves in self-hosting
- Purge strategy in front of a CDN (Cloudflare)
- Monitoring and automatic rollback
- FAQ
- How do I run a Next.js standalone build on my own server?
- How do I write a systemd service file for Next.js?
- Why do static files 404 behind nginx with Next.js?
- How do ISR and caching work in self-hosted Next.js?
- What's the difference between a standalone build and a normal `next build`?
- Why put a reverse proxy in front of the Next.js server?
- How many release directories should I keep on the server?
- Update (September 2026)
- Conclusion
- Sources
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:
1PORT=3000 HOSTNAME=127.0.0.1 node server.jsThe 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:
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.
1[Unit]2Description=Next.js standalone app3After=network.target4 5[Service]6Type=simple7User=webapp8Group=webapp9WorkingDirectory=/opt/myapp/current10Environment=PORT=300011Environment=HOSTNAME=127.0.0.112ExecStart=/usr/bin/node server.js13Restart=on-failure14RestartSec=515NoNewPrivileges=true16ProtectSystem=full17ProtectHome=true18PrivateTmp=true19 20[Install]21WantedBy=multi-user.targetDirective | 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:
1useradd --system --no-create-home --shell /usr/sbin/nologin webapp2chown -R webapp:webapp /opt/myappThe --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.
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: nofrom withinnext.config.js. Above I disabled the same behavior on the nginx side withproxy_buffering off;;X-Forwarded-ForandX-Forwarded-Protolet 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_modulefor `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.
1#!/usr/bin/env bash2set -e3 4LOCK=/tmp/nextjs-build.lock5exec 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 build12cp -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.tmp18mv -T /opt/myapp/current.tmp /opt/myapp/current19 20systemctl restart myapp21 22for i in 1 2 3; do23 sleep $((i * 3))24 curl -sf http://127.0.0.1:3000/ && { echo STANDALONE_OK; exit 0; }25done26 27if [ -n "$PREV" ]; then28 echo "Health check failed, rolling back to previous release"29 ln -sfn "$PREV" /opt/myapp/current.tmp30 mv -T /opt/myapp/current.tmp /opt/myapp/current31 systemctl restart myapp32else33 echo "No previous release, cannot roll back"34fi35exit 1This 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
- Next.js `output` config reference — the official docs describing what the
standaloneoutput copies and howserver.jsruns. - Next.js Self-Hosting guide — the reverse-proxy recommendation, cache behavior, and the current version of the immutable-header advice.
- Next.js 16 announcement post —
"use cache"/Cache Components and the removal ofexperimental.ppr. - Cloudflare cache purge docs — the official guide stating that single-URL purge is the recommended method.
- ngx_brotli (GitHub) — the third-party brotli compression module for nginx.
- nginx `gzip_static` module docs — the non-core module for serving precompressed files.
- Next.js 16.3 announcement post — native Node.js streams, immutable asset sharing, and Turbopack build cache updates.
Tags
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.

