Back to Blog
Next.jsDevOpsnginxsystemdDeploymentVPS

Deploying Next.js Standalone to a VPS With systemd and nginx

Umut Korkmaz2026-06-057 min read

I host most of my smaller projects on a single Ubuntu droplet rather than a managed platform. That includes this site. Every time I describe the setup to someone, the first reaction is a polite suggestion that I just use a managed host and stop torturing myself. It is a fair point. But I keep coming back to the VPS, and not out of stubbornness. Owning the deployment teaches me things, costs almost nothing, and gives me a level of control that I actually use. Here is how the whole thing fits together, and where the tradeoffs honestly bite.

Standalone Output Is the Whole Trick

The piece that makes this pleasant is Next.js standalone output. When you set the output to standalone in the config, next build produces a self-contained folder with a minimal node server and only the dependencies the app actually needs at runtime. No giant node_modules to ship, no installing the world on the server. The build emits a server file you run with plain node, a static folder, and the public folder for assets.

That one setting changes the shape of the deploy completely. Instead of treating the server as a place where I rebuild the application, I treat it as a place where I drop a finished artifact and start it. The build happens locally or in CI where I have the full toolchain. The server only ever runs node against an already-built bundle. It is the difference between shipping a compiled binary and shipping source plus a compiler.

A systemd Unit Per Site

Each app on the droplet is its own systemd service. The unit is unremarkable on purpose: it runs node server.js with a working directory set to the app folder, a fixed port like 3000, a few environment variables, and a restart policy so the process comes back if it dies or the machine reboots. Different sites get different ports.

The reason I like systemd here is that it turns a long-running node process into something the operating system actually manages. I get start, stop, restart, status, and logs through journalctl for free. If the box reboots after a kernel update, every site comes back without me touching anything. There is no process manager layer to babysit, no separate supervisor to install and keep alive. The init system that is already running the machine is more than enough for a node process on a port.

nginx in Front, Doing the Boring Parts

Nothing from the public internet talks to node directly. nginx sits in front as a reverse proxy and handles the parts that node should not be wasting time on. It terminates TLS, so certificates and renewal live in one place rather than inside every app. It gzips responses. And it sets long cache headers on the immutable assets under the static path, because those filenames are content-hashed and will never change. The browser can cache them effectively forever.

Each site is a separate nginx vhost pointing at its own upstream port. So one droplet quietly serves several unrelated domains, each as an independent service with its own config block. The proxy layer also gives me a natural seam for anything cross-cutting: a security header, a redirect, a maintenance page. I would rather adjust an nginx directive than redeploy an app to change a header.

Artifact Deploys, Not Re-Provisioning

This is the part I care most about. A deploy is not a full reinstall of the environment. A deploy is: rsync the freshly built standalone folder, the static folder, and the public folder up to the server, then restart that one systemd service. That is the entire operation. It takes seconds, and the surface area of what can go wrong is tiny.

What a deploy deliberately does not do is touch the nginx config or the systemd unit. Those are part of the machine's known-good state, and they change rarely and consciously. Clobbering working infrastructure config on every single push is how you turn a routine deploy into a game of chance. So I keep two layers cleanly separated. The application artifact churns constantly and gets rsynced over. The infrastructure config is provisioned once, edited by hand when it genuinely needs to change, and otherwise left alone. When something breaks, I almost always know which layer to look at, because only one of them moved.

What You Give Up

I want to be honest about the other side. A managed platform buys you real things. Atomic deploys with instant rollback. Preview deployments per pull request. A global edge network that puts your assets close to users. Automatic scaling when traffic spikes. Zero-downtime cutover where the old version serves until the new one is ready. On my droplet, a restart means a brief blip while node boots. There is one machine, in one region, and if it falls over, everything on it falls over together.

For a personal site and a handful of side projects, none of that is a dealbreaker. The traffic is modest, a few seconds of blip on deploy is invisible, and one region is fine. But I would not pretend this is the right answer for a product with real users and an on-call rotation. The reason I run it anyway is that the visibility is genuinely useful. I can read the actual nginx access logs, watch journalctl during a deploy, and reason about every layer between the request and the response. That understanding follows me into projects where the stakes are higher and the platform is doing all of this invisibly. Knowing what is being abstracted away is worth the occasional inconvenience of doing it by hand.