March 20, 2026 (3w ago)
Written by David Viejo
Last updated March 20, 2026 (3w ago)
Most Next.js tutorials end at npm run dev. The deployment section says "push to Vercel" and moves on.
That's fine until you need to own your infrastructure, keep costs under control, or just understand what's actually happening when your app goes live. This post walks through deploying a Next.js app to a bare VPS, step by step. No platform, no abstraction layer. Just you, a server, and the commands.
By the end, you'll understand every piece of the deployment pipeline. And you'll be able to decide whether you want to keep doing it manually or hand it off to a tool.
TL;DR: Deploying Next.js to a VPS manually means managing Node.js, PM2, Nginx, Certbot, deploy scripts, and GitHub Actions — plus 4 external services for monitoring and analytics. This guide walks through every step so you can decide whether to automate it or keep full control.
npm run build on your local machine.This guide assumes Ubuntu 22.04 or 24.04. Debian works too with minor differences.
SSH into your new server:
ssh root@your-server-ip
Update packages and install the basics:
apt update && apt upgrade -y
apt install -y curl git ufw
Set up the firewall. Open SSH, HTTP, and HTTPS. Close everything else:
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw enable
Create a non-root user (running everything as root is asking for trouble):
adduser deploy
usermod -aG sudo deploy
Copy your SSH key to the new user:
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
Log out and log back in as the deploy user from now on.
Don't install Node from apt. The version in Ubuntu's default repos is usually ancient. Use the NodeSource repository:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
Verify:
node --version # Should be 20.x
npm --version
If your project uses a specific Node version (check .nvmrc or engines in package.json), match it here. Mismatched Node versions between local and server are one of the most common causes of "works on my machine" deployment failures.
cd /home/deploy
git clone https://github.com/your-username/your-nextjs-app.git
cd your-nextjs-app
npm ci
npm ci instead of npm install. It installs from the lockfile exactly, which is what you want in production. npm install can resolve to different versions.
Set your environment variables:
cp .env.example .env.production
nano .env.production
# Fill in your production values: DATABASE_URL, API keys, etc.
Build:
npm run build
If this fails, fix it before continuing. Common issues: missing env vars that the build needs at compile time (Next.js bakes NEXT_PUBLIC_* variables into the client bundle during build), or native dependencies that need build tools (sudo apt install -y build-essential).
You need a process manager. If you just run npm start in your terminal and disconnect, the process dies. PM2 keeps it running, restarts it if it crashes, and manages logs.
sudo npm install -g pm2
Start your app:
pm2 start npm --name "nextjs-app" -- start
Check it's running:
pm2 status
pm2 logs nextjs-app
By default, Next.js starts on port 3000. Verify it's listening:
curl http://localhost:3000
Tell PM2 to start your app on server boot:
pm2 startup
pm2 save
The pm2 startup command prints a line you need to copy and run with sudo. Don't skip it, or your app won't survive a server reboot.
Nginx sits in front of your Next.js app. It handles SSL termination, serves static files, and proxies dynamic requests to your Node process.
sudo apt install -y nginx
Create a site config:
sudo nano /etc/nginx/sites-available/your-app
server {
listen 80;
server_name yourdomain.com www.yourdomain.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;
}
}
Enable the site:
sudo ln -s /etc/nginx/sites-available/your-app /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
The proxy_set_header Upgrade and Connection 'upgrade' lines are for WebSocket support. If your app uses real-time features, these headers are required.
At this point, your app should be accessible at http://yourdomain.com. No SSL yet.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Certbot modifies your Nginx config to add SSL, sets up automatic certificate renewal, and redirects HTTP to HTTPS.
Verify the renewal timer is active:
sudo systemctl status certbot.timer
Certificates expire every 90 days. Certbot's timer renews them automatically. If you skip this check and the timer isn't running, your site goes down in 3 months with an expired cert. It happens more often than you'd think.
Your app is live. Now you need a way to update it when you push new code.
Create /home/deploy/deploy.sh:
#!/bin/bash
set -e
cd /home/deploy/your-nextjs-app
echo "Pulling latest code..."
git pull origin main
echo "Installing dependencies..."
npm ci
echo "Building..."
npm run build
echo "Restarting..."
pm2 restart nextjs-app
echo "Done."
chmod +x /home/deploy/deploy.sh
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_IP }}
username: deploy
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: /home/deploy/deploy.sh
Add SERVER_IP and SSH_PRIVATE_KEY to your repo's GitHub Secrets.
The deploy script above has real gaps:
main goes straight to production.You can solve each of these individually (blue-green deploys with Nginx upstream toggling, a webhook server, PM2's cluster mode). But each solution adds complexity, and by the time you've built all of them, you've built a deployment platform. For a deeper look at how these platforms automate this pipeline, see how git-push deployments work under the hood.
Your app is deployed. How do you know it's still running tomorrow?
Logs: PM2 handles application logs. Rotate them or they'll fill your disk:
pm2 install pm2-logrotate
Uptime: You need something that pings your site and alerts you when it's down. Free options: UptimeRobot, Betterstack (free tier), or a cron job that curls your health endpoint. See our guide on building an uptime monitoring system for more depth.
Error tracking: When your app throws an unhandled exception in production, how do you find out? Sentry (free tier), LogRocket, or parsing PM2 logs manually. We covered the options in error tracking without Sentry.
Analytics: Google Analytics, Plausible, Umami, or similar. If you want to avoid third-party scripts entirely, see how to add web analytics without third-party scripts.
Each of these is a separate tool, a separate account, a separate dashboard.
Let's count:
Running on your server:
Still need but didn't set up: 7. Uptime monitoring (external service) 8. Error tracking (external service) 9. Analytics (external service) 10. Log management (PM2 + logrotate, or external service)
That's 6 things on your server and 4 external services for a single Next.js app.
The manual approach works. But there's a reason deployment platforms exist.
If you want the git push workflow without managing Nginx, PM2, Certbot, deploy scripts, and GitHub Actions yourself:
The point of this tutorial isn't to talk you out of doing it manually. It's to make sure you know what "deploying to production" actually involves, so you can make an informed choice.