What changes when you leave a managed platform
On a managed host, the platform splits your app into functions, an edge cache and an image service. On your own server, all of that runs inside one Node.js process started with next start or the standalone server. That is simpler than it sounds, but a few things need explicit decisions.
- Where the build runs and how the output reaches the server
- Which process manager keeps the server alive and restarts it on deploy
- Where the ISR and data cache live, especially when you run more than one instance
- How images are optimized and cached, and how much CPU that takes
- What sits in front for TLS, compression and long cache headers on static assets
Answer these once, write them into your deploy script, and every later deploy is routine.
Build with standalone output
The standalone output mode traces the files your app actually imports and copies them, with a minimal node_modules, into .next/standalone. The result is a small folder you can run with plain node, without installing all dev dependencies on the server.
// next.config.js
module.exports = {
output: "standalone",
};After next build, copy the static assets and the public folder next to the standalone server, because they are not included by default.
npm ci npm run build cp -r public .next/standalone/public cp -r .next/static .next/standalone/.next/static
Start the server with node .next/standalone/server.js and set PORT and HOSTNAME through environment variables. Bind to 127.0.0.1 so the app is only reachable through the reverse proxy.
Build on the server or in CI
Next.js builds are memory hungry. A 1 GB VPS often runs out of memory during next build, even though the running app needs only 150 to 300 MB. You have two good options.
- Build on the server in a fresh release folder, with a swap file of 2 GB or more as a safety net
- Build in CI, upload the standalone folder as an artifact, and only unpack and switch on the server
Building on the server is simpler and keeps one source of truth. Building in CI saves server resources and lets you test the exact artifact you deploy. Either way, never build inside the folder that is serving traffic, or requests will hit a half written .next directory.
Run Next.js with PM2 or systemd
Both work well. systemd is already on every Ubuntu and Debian server, writes logs to journald and needs no extra package. PM2 adds cluster mode and a convenient reload command, which some teams prefer.
[Service] User=deploy WorkingDirectory=/home/deploy/web/current/.next/standalone EnvironmentFile=/home/deploy/web/shared/.env Environment=NODE_ENV=production PORT=3000 HOSTNAME=127.0.0.1 ExecStart=/usr/bin/node server.js Restart=always
pm2 start .next/standalone/server.js --name web -i 2 pm2 reload web pm2 save && pm2 startup
With PM2 cluster mode, reload replaces instances one by one, so there is always a process accepting requests. With systemd, use a release folder layout and a short health check, or run two units behind nginx and switch between them, to avoid the restart gap.
Get caching right with more than one instance
Next.js keeps the ISR cache and the data cache on the local file system by default. With a single instance that is fine. With two instances, or with release folders where each deploy starts with an empty .next/cache, you will see stale pages on one instance and fresh pages on another, and revalidateTag calls that only reach one process. Two fixes cover most cases.
- Point the cache at a shared location by configuring a custom cacheHandler, commonly backed by Redis, so every instance reads and writes the same entries
- Set a stable build ID across instances with generateBuildId, so assets and cache keys match even if two servers build separately
Also link a shared cache folder into each release if you want ISR pages to survive a deploy rather than regenerate on first request. Treat revalidation as an event your app emits, not as something the platform handles for you.
Image optimization on your own server
The built in image optimizer works when you self host, and it uses sharp for fast resizing. Install it as a dependency so the standalone build includes it, because the fallback is much slower and uses more memory.
npm install sharp
Optimized images are cached under .next/cache/images. Keep that folder in shared storage so it survives deploys, set minimumCacheTTL in next.config.js so images are not re-encoded too often, and restrict remotePatterns to the hosts you actually use, otherwise anyone can make your server resize arbitrary remote images. If image traffic is heavy, a CDN in front of /_next/image takes most of the CPU load off the server.
Put nginx in front with long cache headers
nginx terminates TLS and serves hashed static assets with year long cache headers, which takes load off Node.js entirely for the files that never change.
location /_next/static/ {
alias /home/deploy/web/current/.next/standalone/.next/static/;
expires 365d;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}Add a Let's Encrypt certificate with certbot, enable gzip or brotli, and make sure middleware that reads the request protocol trusts the X-Forwarded-Proto header.
Environment variables and the build
Variables prefixed with NEXT_PUBLIC_ are inlined at build time. Changing them on the server after the build does nothing until you rebuild. Server only variables are read at runtime from the environment of the process. Keep both in one .env file per environment, outside the repository, and rebuild whenever a public variable changes. Mixing these up is the most common reason a self hosted Next.js app talks to the wrong API after a deploy.
A deploy script for next js self hosting
Put the pieces together and a full release looks like this. It is the same shape as any other zero downtime deploy, with the standalone copy steps and a short smoke test of the new build on a spare port added.
cd "$REL" && npm ci && npm run build
cp -r public .next/standalone/public
cp -r .next/static .next/standalone/.next/static
PORT=3001 node .next/standalone/server.js & PID=$!; sleep 5
curl -fsS http://127.0.0.1:3001/api/health || { kill $PID; exit 1; }
kill $PID
ln -sfn "$REL" /home/deploy/web/current
sudo systemctl restart webKeep the last few releases on disk, so a rollback is a symlink switch and a restart, not a fresh build under pressure.
Middleware, headers and the request protocol
Middleware runs inside the same Node.js process when you self host, so it costs CPU on your server rather than at an edge network. Keep it light: redirects, auth checks and header rewrites are fine, heavy data fetching is not. Behind nginx, the app sees requests from 127.0.0.1 over plain HTTP. Forward the original host, protocol and client IP with X-Forwarded headers, as in the nginx block above, so redirects, absolute URLs and rate limits use the real values. If you set security headers such as Content-Security-Policy in next.config.js, check that nginx does not add a second, conflicting copy. Finally, set a sensible keepalive timeout in nginx for the upstream, because opening a new connection to Node.js for every request adds latency you can easily avoid.
How Deployment self hosts Next.js for you
Deployment runs this whole setup on a server in your own cloud account. It detects Next.js from your repository, provisions the server with Node.js, swap and nginx or Caddy, and builds each push in a new release folder with standalone output. It copies the static and public folders, links shared cache directories so ISR and optimized images survive deploys, checks your health route, then switches the symlink for zero downtime deployment with one click rollback. The Node.js process runs as a managed daemon, environment variables are encrypted per environment and diffed before each deploy, and Let's Encrypt certificates renew on their own. See the nextjs hosting page for details, or paste your repository into the demo on the home page to see the detected stack and the first lines of your deploy script.