A Next.js production deploy on a VPS is four pieces: a Node process running next start, a process manager to keep it alive across crashes and reboots, a reverse proxy terminating TLS on port 443, and a way to get new code onto the box.
None of the four is difficult. What surprises people is that they are yours forever: the kernel updates, the certificate renewals, the log rotation, the memory leak at 4am, the rebuild after a host failure.
This is the whole path, written so you can follow it, followed by an honest account of what you have signed up for. Both halves matter.
Table of contents
- Prepare the server
- Build and run the application
- Keep it running with a process manager
- Reverse proxy and TLS
- Deploying new code without downtime
- The list you now own
- How this fits the rest of the stack
- FAQ
Prepare the server
Start from a clean Ubuntu LTS instance. First, do not run the application as root.
adduser --disabled-password --gecos "" deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy/
Then disable password authentication over SSH, because an exposed password login will be attacked continuously from the first hour.
# In /etc/ssh/sshd_config:
# PasswordAuthentication no
# PermitRootLogin no
sudo systemctl reload ssh
Open only what you need. The application port stays closed to the internet; only the proxy is public.
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw enable
Install Node from a versioned source rather than the distribution package, which is usually several major versions behind. Match the version to whatever your package.json engines field or your CI declares, because a version mismatch between your build and your runtime produces bugs that do not reproduce locally.
Build and run the application
Clone the repository, install production dependencies and build.
cd /home/deploy
git clone [email protected]:you/your-app.git app
cd app
npm ci
npm run build
Use npm ci rather than npm install. It installs exactly what the lockfile says and fails if the lockfile and manifest disagree, which is the behaviour you want on a server. npm install will happily resolve something different from what you tested.
Environment variables need to exist before the build, not just before the run. Anything prefixed for client exposure is inlined into the bundle at build time, so setting it afterwards does nothing and the value will be missing in the browser with no error to explain why.
One decision to make deliberately: the standalone output mode. Setting output: 'standalone' in your Next config produces a self-contained server directory with only the dependencies actually used, which cuts the deployed size dramatically and is worth doing if you are copying build artefacts rather than building on the server.
Confirm it runs before wiring anything else up:
PORT=3000 npm run start
# In another session:
curl -I http://127.0.0.1:3000
Keep it running with a process manager
npm run start in a terminal dies when you close the terminal. You need something that restarts the process on crash and starts it on boot.
A systemd unit is the option with no extra dependency, and it is genuinely simpler than it looks:
# /etc/systemd/system/nextapp.service
[Unit]
Description=Next.js application
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/app
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/home/deploy/app/.env.production
ExecStart=/usr/bin/node node_modules/.bin/next start
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now nextapp
sudo systemctl status nextapp
journalctl -u nextapp -f
The alternative most tutorials reach for is PM2, which adds clustering across cores and a nicer status view at the cost of another dependency to keep updated. Either is fine. What matters is that Restart=always and enable are both set, because the classic incident is a server that reboots for a kernel update and comes back with no application running and nothing to tell you.
Reverse proxy and TLS
Node should not face the internet directly. A reverse proxy terminates TLS, serves static files efficiently, handles compression and gives you a place to set headers and limits.
# /etc/nginx/sites-available/nextapp
server {
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 Upgrade $http_upgrade;
proxy_set_header Connection upgrade;
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_cache_bypass $http_upgrade;
}
client_max_body_size 20M;
}
The forwarded headers are not decorative. Without X-Forwarded-Proto, your application believes every request arrived over plain HTTP, which breaks secure cookie handling and any redirect logic that checks the scheme. Without X-Real-IP, every request appears to come from localhost, which makes rate limiting and audit logs useless.
client_max_body_size defaults to 1MB, which is where the 413 errors on file uploads come from. Set it to match what your application actually accepts.
Then get a certificate. This is the one step that is genuinely a single command:
sudo ln -s /etc/nginx/sites-available/nextapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d example.com -d www.example.com
Verify the renewal timer is actually enabled afterwards, with systemctl list-timers. An issued certificate with no working renewal is a 90-day fuse.
Deploying new code without downtime
The naive approach is to pull, build and restart on the server. It works, and it has two problems: the site is down during the restart, and a failed build leaves you with a broken working tree and no easy way back.
A build-elsewhere, symlink-switch pattern fixes both:
#!/usr/bin/env bash
set -euo pipefail
RELEASE="/home/deploy/releases/$(date +%Y%m%d%H%M%S)"
git clone --depth 1 [email protected]:you/your-app.git "$RELEASE"
cd "$RELEASE"
cp /home/deploy/shared/.env.production .
npm ci
npm run build
# Atomic switch: the symlink move is a single filesystem operation.
ln -sfn "$RELEASE" /home/deploy/app-next
mv -Tf /home/deploy/app-next /home/deploy/app
sudo systemctl restart nextapp
# Keep the last five releases so a rollback is one symlink move.
ls -1dt /home/deploy/releases/* | tail -n +6 | xargs -r rm -rf
The build now happens before anything switches, so a compile error never reaches production. Rolling back is pointing the symlink at the previous release and restarting, which takes seconds.
There is still a gap during the restart. Closing it properly means running two instances on different ports and switching the proxy between them, which is a blue-green deploy and is where this starts to be a real amount of infrastructure to maintain.
The list you now own
Everything above is a one-time setup measured in hours. What follows is recurring and has no end date.
- Security updates and reboots. Unattended upgrades handles most of it; kernel updates still need a reboot, and something has to notice.
- Certificate renewal monitoring. The automation is reliable until a redirect rule breaks the challenge path. Monitor expiry externally.
- Log rotation. Journald has limits by default; application logs written to files do not unless you configure logrotate. A disk full of logs takes the site down.
- Memory. A single Node process leaking slowly will eventually be killed by the kernel. You need to know when that happens rather than hearing it from a user.
- Backups you have restored. An untested backup is a hypothesis.
- A rebuild path. If the host dies, how long to a working server? If the answer involves remembering, write the script now.
None of this is hard. All of it is attention, and it recurs every month whether or not you have time. The realistic figure for one production box is a few hours monthly when nothing goes wrong.
The alternative is not doing without the capabilities, it is having them handled. On RunxBuild the same application is a repository connection: push, get a build log, a live route, environment variables, runtime logs, metrics, and a rollback to the previous deploy, with the certificate handled as part of the custom domain. The Next.js service docs cover the specifics.
How this fits the rest of the stack
The right way to decide is arithmetic rather than principle: add the server, the backups, the bandwidth and an honest estimate of the monthly hours, then compare that total against a managed plan. The RunxBuild hosting calculator prices the managed side, and a Dev plan starts at $4 with the ladder running up as the application grows. Plenty of teams do the sum and still choose the VPS, which is fine. The mistake is choosing without doing the sum.
Useful related references:
- VPS SSD Storage: What You’re Actually Getting, When It Matters, When It Doesn’t
- Can Cloudways Host Next.js Server-Side Rendering: A Managed VPS Can Host Anything If You Operate It, but the Real Question Is Whether You Should
- The Next.js Framework: What It Adds and What It Costs
- Services on RunxBuild
FAQ
Do I need nginx in front of Next.js?
For anything public, yes. Next.js can serve HTTPS directly but a reverse proxy gives you TLS termination, compression, static file handling, request size limits and a place to set headers, all outside your application process. It also lets you restart the application without dropping connections at the edge.
Should I use PM2 or systemd?
Either works. systemd is already installed, integrates with journald for logs, and has no extra dependency to keep updated. PM2 adds clustering across CPU cores and a friendlier status view. What matters more is that automatic restart and start-on-boot are both configured, whichever you pick.
Why are my environment variables missing in the browser?
Client-exposed variables are inlined into the bundle at build time, not read at runtime. If you set them after building, the build already baked in whatever was present, usually nothing. Set them before running the build, and rebuild whenever one changes.
How do I deploy without downtime?
Build each release into a fresh directory, then move a symlink to point at it, which is atomic. That removes the risk of a failed build breaking production. The restart itself still causes a brief gap; closing it fully requires running two instances and switching the proxy between them.
How much does self-hosting Next.js actually cost?
The server is the small part. Add backups, bandwidth, and a realistic estimate of the recurring hours for patching, certificate monitoring, log rotation and incident response. For one production box that is commonly a few hours a month, and it is the part left out of most comparisons.