Key takeaways
- Run next start under PM2 on 127.0.0.1:3000 and put Nginx in front of it for HTTPS; port 3000 should never be reachable from the internet.
- Variables prefixed NEXT_PUBLIC_ are inlined into the JavaScript at build time, so changing one needs a new next build, not just a restart.
- On a 1 GB server, add a 2 GB swap file before building, or build on another Linux machine with standalone output and copy the result across.
- Run pm2 save and the command pm2 startup prints, then reboot once to prove the site comes back on its own.
- A 502 from Nginx means the app is not answering on the proxy_pass address: check pm2 status, the port, and the Nginx error log, in that order.
- Building inside the live directory breaks the running site while the build runs; a release directory per deploy, switched after a health check, avoids it.
To deploy Next.js to a VPS, you install Node.js on the server, build the app with next build, run next start under a process manager such as PM2 so it restarts after crashes and reboots, and put Nginx in front of it to terminate HTTPS and forward traffic to port 3000. On a fresh Ubuntu 24.04 server it takes about an hour the first time. The problems people hit almost all come from four places: memory during the build, build-time environment variables, image optimization, and the connection between Nginx and the app.
This guide walks through every step with the exact commands, then covers the failures you are most likely to see and how to fix each one. If your app is a plain Express, Fastify or NestJS server rather than Next.js, follow How to Deploy a Node.js App to a Linux VPS instead: the server side is the same, but the build and runtime details are not.
Why is Next.js harder to deploy than a plain Node.js app?
A plain Node.js API is usually one compiled entry file and a port. Next.js adds four things that matter on a server:
- A heavy build.
next buildcompiles every route, type-checks the project and pre-renders static pages. It needs far more memory than the running app, and on a 1 GB server it is the step most likely to fail. - Build-time configuration. Any variable prefixed
NEXT_PUBLIC_is copied into the browser JavaScript during the build. Changing it on the server does nothing until you build again. - Image optimization.
next/imageresizes images on the server at request time using thesharplibrary, which ships native binaries built for a specific operating system and CPU. - Mixed rendering. Some pages are static HTML produced at build time, others render on every request. One
next startprocess serves both, and it writes to.next/cacheas it goes.
What do you need before you start?
- A VPS running Ubuntu 24.04 LTS. Debian 12 works with the same commands. 2 GB of RAM is comfortable; 1 GB works if you add swap, which is covered below.
- SSH access with sudo, ideally with an SSH key rather than a password.
- A domain name and access to its DNS settings.
- A Next.js app in a Git repository with a committed
package-lock.json. The examples use npm; pnpm and Yarn work the same way with their own commands. - The app building cleanly on your own machine with
npm run build. If it fails there, it will fail on the server too.
The examples use shop as the app name, example.com as the domain and deploy as the Linux user.
How to deploy Next.js to a VPS manually
Step 1: Prepare the server (10 minutes)
Update the packages and create a non-root user to own and run the app, so a bug in the app or its dependencies never runs as root.
sudo apt update && sudo apt upgrade -y
sudo adduser deploy
sudo usermod -aG sudo deploy
sudo rsync --archive --chown=deploy:deploy ~/.ssh /home/deployThe rsync line copies your authorized SSH key so you can log in as deploy directly. Open a second terminal, confirm ssh deploy@your-server-ip works, and do the rest of this guide as that user. Then install the tools the build and the proxy need:
sudo apt install -y git nginx build-essentialbuild-essential provides the compiler that native npm modules fall back to when no prebuilt binary matches the server.
Step 2: Install Node.js 22 (5 minutes)
Next.js 16 needs Node.js 20.9 or later, and Node 20 has reached end of life, so use an LTS line: 22 or 24. Ubuntu's own nodejs package is older than that, so install from the NodeSource repository:
curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs
node -vThe alternative is nvm, which installs Node per user and needs no sudo:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.bashrc
nvm install 22Either works. With NodeSource, global npm installs need sudo; with nvm they must not, and the Node path includes the version number, which matters in Step 7. Do not mix the two. Then install PM2 globally:
sudo npm install -g pm2 # without sudo if you used nvmStep 3: Clone your repository (5 minutes)
A public repository clones over HTTPS. For a private one, give the server its own read-only deploy key instead of copying your personal SSH key onto it:
ssh-keygen -t ed25519 -C "deploy@shop" -f ~/.ssh/shop_deploy -N ""
cat ~/.ssh/shop_deploy.pubAdd the printed public key under the repository's Settings, then Deploy keys, on GitHub, with write access left off. Tell SSH to use that key for GitHub, and clone:
cat >> ~/.ssh/config <<'EOF'
Host github.com
IdentityFile ~/.ssh/shop_deploy
IdentitiesOnly yes
EOF
mkdir -p ~/apps
git clone git@github.com:your-org/shop.git ~/apps/shop
cd ~/apps/shopStep 4: Set environment variables (5 minutes)
Create .env.production.local in the project directory. Next.js loads it automatically for both next build and next start, and the default Next.js .gitignore keeps it out of Git.
nano .env.production.local
chmod 600 .env.production.localDATABASE_URL=postgres://shop:secret@127.0.0.1:5432/shop
AUTH_SECRET=change-me
NEXT_PUBLIC_SITE_URL=https://example.comThis is the rule that catches everyone: variables prefixed NEXT_PUBLIC_ are inlined at build time. During next build, every process.env.NEXT_PUBLIC_SITE_URL in code that runs in the browser is replaced with the literal string. The running server never reads that variable again. If you change a NEXT_PUBLIC_ value, you have to run next build again; restarting the app is not enough.
Variables without the prefix are server-only and are read at runtime by code that renders per request, so a restart picks up a change. The exception is a page pre-rendered at build time: its server code ran during the build, and any value it read is frozen into that HTML until the next build. And because the prefix means "send this to every visitor", never put a secret behind it.
Step 5: Install dependencies and build (5-10 minutes)
npm ci
npm run buildnpm ci installs exactly what package-lock.json says and fails if the lockfile is out of sync with package.json. Do not export NODE_ENV=production in the shell before running it: npm then skips devDependencies, where most Next.js projects keep TypeScript and their CSS tooling, and the build fails with a missing module. next build sets NODE_ENV=production for itself.
If the build is killed with no explanation, or dies with JavaScript heap out of memory, jump to the 1 GB fix in the troubleshooting section below, then come back.
Step 6: Start the app with PM2 (3 minutes)
PM2 keeps the process running, restarts it if it crashes and captures its logs. If PM2 is new to you, What is PM2? explains how it works.
cd ~/apps/shop
pm2 start node_modules/next/dist/bin/next --name shop -- start -p 3000 -H 127.0.0.1
pm2 statusThat runs next start -p 3000 under PM2. -H 127.0.0.1 makes Next.js listen on the loopback interface only, so the app is reachable through Nginx but not directly on port 3000. Pointing PM2 at the next binary rather than npm start means it supervises the real Node process, not an npm wrapper around it.
Check it answers on the server:
curl -I http://127.0.0.1:3000You should see HTTP/1.1 200 OK, or a redirect if your home page redirects.
Step 7: Make PM2 start on boot (2 minutes)
pm2 save
pm2 startuppm2 save writes the process list to ~/.pm2/dump.pm2. pm2 startup prints one command beginning with sudo env PATH=; run that exact line. It installs a systemd unit, pm2-deploy, that restores the saved list at boot. With nvm, run pm2 startup again after changing Node versions. Run pm2 save whenever you add or remove an app.
Step 8: Configure Nginx as a reverse proxy (10 minutes)
Nginx receives traffic on ports 80 and 443 and forwards it to the app. First define the WebSocket upgrade mapping, once for the whole server:
# /etc/nginx/conf.d/websocket-upgrade.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Then create /etc/nginx/sites-available/shop:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}The Host and X-Forwarded-* headers let the app see the real domain, client address and protocol. The Upgrade and Connection headers let WebSockets through. proxy_pass names 127.0.0.1 rather than localhost, so Nginx never tries IPv6 first and misses an app bound to IPv4. If you stream responses with Suspense, the Next.js docs describe sending an X-Accel-Buffering: no header so Nginx does not buffer them.
Enable the site, remove the default one, test and reload:
sudo ln -s /etc/nginx/sites-available/shop /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxAlways run nginx -t first: it names the exact line that is wrong. The Nginx config generator produces this block for your own domain and port.
Step 9: Open the firewall (2 minutes)
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw statusAllow SSH before enabling UFW, or you lock yourself out. Nginx Full opens ports 80 and 443. Port 3000 stays closed, because nothing outside the server should reach it. If your provider has a firewall of its own, such as an AWS security group or a Hetzner or DigitalOcean cloud firewall, open 80 and 443 there too.
Step 10: Point your domain at the server (5 minutes, plus DNS propagation)
At your DNS provider, create an A record for example.com pointing to the server's public IPv4 address, and another for www, or a CNAME from www to example.com. Add AAAA records only if the server has IPv6 configured. From your own machine, check both names resolve before you ask for a certificate:
dig +short example.com
dig +short www.example.comBoth should print the server's IP. A new record usually appears within minutes; a changed one can take as long as the old record's TTL.
Step 11: Add HTTPS with Certbot (5 minutes)
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx --redirect -d example.com -d www.example.comCertbot proves you control the domain, obtains a Let's Encrypt certificate, adds the HTTPS listener to your server block and, with --redirect, sends plain HTTP to HTTPS. The Ubuntu package installs a systemd timer that renews certificates before they expire. Confirm renewal will work:
sudo certbot renew --dry-runThe EFF's Certbot instructions also describe the snap-based install, which follows newer Certbot releases than Ubuntu's package does.
Step 12: Verify the deployment (3 minutes)
curl -I https://example.com
pm2 status
pm2 logs shop --lines 50
sudo tail -n 20 /var/log/nginx/error.logYou want a 200 over HTTPS, shop online in PM2 with a restart count that is not climbing, no errors in the app log and nothing new in the Nginx error log. Then reboot once with sudo reboot and check the site comes back without you touching it. A deployment that does not survive a reboot is not finished.
Should you use output: 'standalone'?
Setting output: 'standalone' in next.config.ts makes next build produce a self-contained server in .next/standalone, containing only the files and node_modules the app actually uses. It is how most Docker images for Next.js are built, and on a VPS it is useful when you build on one machine and run on another, because you copy a much smaller directory.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;The standalone folder does not include public or .next/static, so copy them in after each build, then run server.js with Node. Next.js warns that next start does not work with this setting.
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
PORT=3000 HOSTNAME=127.0.0.1 pm2 start .next/standalone/server.js --name shopThe standalone server reads PORT and HOSTNAME from its environment, and PM2 captures the environment it was started with. Set HOSTNAME explicitly, because the documented examples use 0.0.0.0, which listens on every interface. Pass runtime variables the same way, or through a PM2 ecosystem file, rather than relying on .env files being found next to server.js.
If you build and run in the same directory, standalone buys little. It is the cleaner artifact when you build elsewhere, such as a CI runner, and ship the result; build on Linux with the server's CPU architecture, because native modules such as sharp are compiled per platform. The Next.js self-hosting guide covers the rest, including caching across several instances.
Where do Next.js deployments break?
The build is killed on a 1 GB server
The symptom is npm run build stopping with a bare Killed, or with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory. The first is the kernel's out-of-memory killer ending the process; the second is Node reaching its own heap limit. Both mean the build needed more memory than the machine had.
Add a swap file. Swap is far slower than RAM, but a build that runs slowly beats one that dies:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -hIf Node still stops at its heap limit, raise it for the build only with NODE_OPTIONS=--max-old-space-size=1536 npm run build. Stopping the running app during the build also frees memory, at the cost of downtime. The lasting fix is to stop building on the production machine: build elsewhere with standalone output and copy the result across, or move to a 2 GB server.
NEXT_PUBLIC_ variables do not change
You edited .env.production.local, ran pm2 restart shop, and the browser still shows the old value. That is expected: the value was inlined into the JavaScript files during the last build. Rebuild, then restart:
npm run build && pm2 restart shopIf you set variables in the shell instead, PM2 keeps the environment it captured at first start; pm2 restart shop --update-env replaces it. Still the old value? Hard-refresh, and purge any CDN in front of the site. When one build has to serve several environments, read the value on the server at request time and pass it to the client as a prop, instead of using the prefix.
502 Bad Gateway
Nginx answered, but it could not reach the app. Its error log says why:
sudo tail -n 20 /var/log/nginx/error.logconnect() failed (111: Connection refused) while connecting to upstream means nothing is listening on the address in proxy_pass. Work through it in order:
- Run
pm2 status. Isshoponline, or errored or stopped? If it is errored,pm2 logs shop --lines 100shows the crash. - Run
sudo ss -ltnp | grep 3000. Is anything listening on 3000, and on which address? An app on127.0.0.1:3000does not answer a proxy to[::1]:3000. - Compare the port in
proxy_passwith the port the app was started on. - If the 502s last only a few seconds after each deploy, that is the restart window while
next startboots. The update section below covers how to avoid it.
Images fail to load, or sharp errors
Recent Next.js versions use sharp automatically for next/image with next start and standalone output, installed as an optional dependency. It breaks in predictable ways:
Could not load the "sharp" module using the linux-x64 runtimemeans the installed binary was built for a different platform, which happens whennode_moduleswas installed on macOS or Windows and copied to the server. Runnpm cion the server itself, or on a Linux machine with the same CPU architecture.- Installing with
--omit=optionalskipssharpentirely. Remove the flag. - Images return 500 and the log shows
EACCESunder.next/cache/images: the cache belongs to another user, usually because a build was once run withsudo. Fix it withsudo chown -R deploy:deploy ~/apps/shop. - On a small server, a burst of first-time image requests pushes memory hard. Resize very large source images before upload, or set
images.unoptimizedtotrueif they are already optimized.
Port 3000 is already in use
Error: listen EADDRINUSE: address already in use 127.0.0.1:3000 means another process holds the port. It is usually a next start you ran by hand earlier and left running, or a second PM2 entry created by running pm2 start twice. Find it:
sudo ss -ltnp 'sport = :3000'
pm2 listRemove duplicate PM2 entries with pm2 delete <id>, stop the stray process, then run pm2 save so the clean list is the one that comes back after a reboot.
Should you use Vercel or your own VPS?
Vercel, the company behind Next.js, runs it with the least effort: connect a repository, get a preview URL per branch, never think about a server. A VPS gives you a fixed machine you control. The choice comes down to the shape of the bill, control, and how much operations work you are willing to own.
| Vercel | Your own VPS | |
|---|---|---|
| Cost model | A free Hobby tier for personal, non-commercial use; paid plans charge per team seat, plus metered usage such as bandwidth, function execution and image optimization beyond what is included | A flat monthly price for fixed CPU, RAM, disk and transfer; the bill stays the same as traffic grows until you outgrow the machine |
| Setup | Connect a repository | The steps in this guide |
| Scaling | Automatic | You resize the server or add another |
| Preview deployments | Built in, per branch or pull request | Not built in; you build them yourself |
| Long-running work | Functions have execution time limits | Background jobs, cron and WebSocket servers run on the same box with no platform time limit |
| Maintenance | Handled for you | OS updates, security patches, disk space; Certbot automates certificate renewal |
| Where it runs | Vercel's infrastructure and regions | Your server, in the region you chose |
Current figures are on vercel.com/pricing. Prices and included amounts change, so check that page against your own traffic rather than trusting a number from a blog post.
A rough rule: if the site is small, traffic is spiky, and nobody on the team wants to look after a server, Vercel is the better default. If traffic is steady, bandwidth-heavy or image-heavy pages would make metered billing hard to predict, you need long-lived connections or background workers, or the data has to stay on infrastructure you control, a VPS usually fits better.
How Shipown deploys a Next.js app
Shipown is a desktop app for Windows and Linux that does the steps above against your own server over SSH. Nothing of it is installed on the server: it connects the way you do and uses the same Node, PM2, Nginx and Certbot. Here is how the manual steps map:
| Manual step | In Shipown |
|---|---|
| Install Node, PM2, Nginx and Certbot | Server setup inspects the server, reports what is missing, and offers PM2 preparation and a separate Domains & HTTPS installer for Nginx, Certbot and its Nginx plugin |
| Clone the repository | Pick a GitHub repository and one of its real branches, or a folder on your computer |
| npm ci and next build | Framework detection proposes the install command, build command and output directory, and every value is editable before anything runs |
| Building on a 1 GB server | Build on the server or on your PC; a PC build uses Linux Docker on the PC to target the server's architecture, is checksum-verified, and is never rebuilt on the server |
| PM2 start and restart policy | The app is started under PM2 with the port, health check and restart policy set |
| Nginx server block and Certbot | The virtual host is written and tested before Nginx reloads, a configuration Nginx rejects is put back, and Certbot issues the certificate when you ask for HTTPS |
| Swap file and UFW | Server management creates a swap file after checking memory and disk, and manages UFW with the live SSH port allowed first |
Each deploy lands in its own release directory, and the live pointer moves only after the build and the health check pass. A failed deploy restores the previous release by itself, and rolling back later is picking an earlier release. The install and build output you see streams from the machine doing the work. When something breaks on the server, the AI terminal uses your own Anthropic, OpenAI or DeepSeek key to propose one shell command at a time, with its reason, and waits for your approval before running it.
What stays manual: you rent the server, create the DNS records, decide the environment values and press deploy. Shipown shows how many commits a branch is ahead of what the server is running, but it does not deploy on push. The NEXT_PUBLIC_ rule still applies: a changed public value needs a new deploy, because the build is where it is inlined. Live monitoring runs while the desktop app is running, minimized to the tray included, and your PC is awake. The deployments documentation describes the release model, and the installers are on the download page.
How do you update a Next.js app after the first deploy?
The simplest update is a short script run on the server:
cd ~/apps/shop
git pull --ff-only
npm ci
npm run build
pm2 restart shopIt works, with two weaknesses worth knowing. First, next build rewrites .next while the old process is still serving from it, so requests during the build can fail with missing chunks, and a failed build leaves a broken .next with nothing to go back to. Second, pm2 restart stops the old process before the new one is ready, which gives you a few seconds of 502s.
The fix for both is to build somewhere other than the live directory: a release directory per deploy, and a current symlink that moves only after the new build passes a health check. Rolling back is pointing the symlink at the previous release. How to roll back a deployment on a VPS walks through that layout. For restarting the process itself without downtime, PM2 cluster mode and pm2 reload are explained in the Node.js deployment guide. They apply to next start too, with the caveat that each instance keeps its own in-memory cache.
After that, three habits keep the server healthy:
- Rotate PM2's logs with
pm2 install pm2-logrotate, because PM2 never deletes them on its own. - Watch disk space. Old builds,
.next/cacheand logs grow quietly;df -handdu -sh ~/apps/*take seconds to run. - Keep the system patched. Ubuntu Server applies security updates through
unattended-upgradesby default; leave it on, runsudo apt upgradefor everything else, and reboot when/var/run/reboot-requiredexists.
Frequently asked questions
Can I deploy Next.js to a VPS without Vercel?
Yes. next start is an ordinary Node.js server, and the Next.js documentation supports self-hosting it, including the App Router, Server Actions, image optimization and incremental static regeneration. You need Node.js, a process manager such as PM2, a reverse proxy such as Nginx, and a TLS certificate from Certbot.
How much RAM does a Next.js app need on a VPS?
The running app is usually modest, but next build needs much more memory than the app does at runtime. On a 1 GB server the build is often killed unless you add a swap file, so 2 GB is the comfortable minimum if you build on the server. The alternative is to build on another Linux machine and copy the output across.
Why do my NEXT_PUBLIC_ environment variables not update after a restart?
Variables with the NEXT_PUBLIC_ prefix are replaced with their literal values in the browser JavaScript during next build. Restarting the server does not change files that were already built. Run next build again, then restart the app.
Should I use PM2 or systemd to run Next.js?
Both work. PM2 adds crash restarts, log capture, cluster mode and zero-downtime reloads with very little configuration, which is why most VPS guides use it. A plain systemd unit avoids an extra dependency and is a good choice if you only need the process restarted when it exits.
Do I need output standalone to self-host Next.js?
No. next start works from a normal project directory with its node_modules. Standalone output is useful when you build on one machine and run on another, or inside Docker, because it produces a small self-contained server. Remember to copy the public and .next/static folders into it after each build.
How do I fix a 502 Bad Gateway with Next.js and Nginx?
A 502 means Nginx could not connect to the app. Check that the PM2 process is online, that something is listening on the port in proxy_pass, and that the address matches, using 127.0.0.1 rather than localhost. The Nginx error log at /var/log/nginx/error.log names the exact upstream that failed.
Can Shipown deploy a Next.js app to my own server?
Yes. Shipown is a desktop app for Windows and Linux that detects a Next.js project, builds it on the server or on your PC, runs it under PM2 behind Nginx with a Certbot certificate, and keeps each deploy as a release you can roll back to. A person still presses deploy; it does not deploy automatically on a git push.
