Tools: How to Deploy a Next.js App to a VPS (The Manual Way)

Tools: How to Deploy a Next.js App to a VPS (The Manual Way)

What You Need

Step 1: Set Up the Server

Step 2: Install Node.js

Step 3: Clone and Build Your App

Step 4: Run the App with PM2

Step 5: Install and Configure Nginx

Step 6: SSL with Let's Encrypt

Step 7: Set Up Deployments

Automating with GitHub Actions

What This Doesn't Handle

Step 8: Monitoring (The Part Most Tutorials Skip)

The Full Stack of What You Just Built

Or: Skip All of That 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. This guide assumes Ubuntu 22.04 or 24.04. Debian works too with minor differences. SSH into your new server: Update packages and install the basics: Set up the firewall. Open SSH, HTTP, and HTTPS. Close everything else: Create a non-root user (running everything as root is asking for trouble): Copy your SSH key to the new user: 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: 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. 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: 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. By default, Next.js starts on port 3000. Verify it's listening: Tell PM2 to start your app on server boot: 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. Create a site config: 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. Certbot modifies your Nginx config to add SSL, sets up automatic certificate renewal, and redirects HTTP to HTTPS. Verify the renewal timer is active: 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: Add SERVER_IP and SSH_PRIVATE_KEY to your repo's GitHub Secrets. Now every push to main triggers the deploy script over SSH. The deploy script above has real gaps: 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. 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: 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. 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. Analytics: Google Analytics, Plausible, Umami, or similar. Each of these is a separate tool, a separate account, a separate dashboard. Running on your server: Still need but didn't set up: 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ ssh root@your-server-ip ssh root@your-server-ip ssh root@your-server-ip -weight: 500;">apt -weight: 500;">update && -weight: 500;">apt -weight: 500;">upgrade -y -weight: 500;">apt -weight: 500;">install -y -weight: 500;">curl -weight: 500;">git ufw -weight: 500;">apt -weight: 500;">update && -weight: 500;">apt -weight: 500;">upgrade -y -weight: 500;">apt -weight: 500;">install -y -weight: 500;">curl -weight: 500;">git ufw -weight: 500;">apt -weight: 500;">update && -weight: 500;">apt -weight: 500;">upgrade -y -weight: 500;">apt -weight: 500;">install -y -weight: 500;">curl -weight: 500;">git ufw ufw allow OpenSSH ufw allow 80 ufw allow 443 ufw -weight: 500;">enable ufw allow OpenSSH ufw allow 80 ufw allow 443 ufw -weight: 500;">enable ufw allow OpenSSH ufw allow 80 ufw allow 443 ufw -weight: 500;">enable adduser deploy usermod -aG -weight: 600;">sudo deploy adduser deploy usermod -aG -weight: 600;">sudo deploy adduser deploy usermod -aG -weight: 600;">sudo deploy rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy -weight: 500;">curl -fsSL https://deb.nodesource.com/setup_20.x | -weight: 600;">sudo -E bash - -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nodejs -weight: 500;">curl -fsSL https://deb.nodesource.com/setup_20.x | -weight: 600;">sudo -E bash - -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nodejs -weight: 500;">curl -fsSL https://deb.nodesource.com/setup_20.x | -weight: 600;">sudo -E bash - -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nodejs node --version # Should be 20.x -weight: 500;">npm --version node --version # Should be 20.x -weight: 500;">npm --version node --version # Should be 20.x -weight: 500;">npm --version cd /home/deploy -weight: 500;">git clone https://github.com/your-username/your-nextjs-app.-weight: 500;">git cd your-nextjs-app -weight: 500;">npm ci cd /home/deploy -weight: 500;">git clone https://github.com/your-username/your-nextjs-app.-weight: 500;">git cd your-nextjs-app -weight: 500;">npm ci cd /home/deploy -weight: 500;">git clone https://github.com/your-username/your-nextjs-app.-weight: 500;">git cd your-nextjs-app -weight: 500;">npm ci cp .env.example .env.production nano .env.production # Fill in your production values: DATABASE_URL, API keys, etc. cp .env.example .env.production nano .env.production # Fill in your production values: DATABASE_URL, API keys, etc. cp .env.example .env.production nano .env.production # Fill in your production values: DATABASE_URL, API keys, etc. -weight: 500;">npm run build -weight: 500;">npm run build -weight: 500;">npm run build -weight: 600;">sudo -weight: 500;">npm -weight: 500;">install -g pm2 -weight: 600;">sudo -weight: 500;">npm -weight: 500;">install -g pm2 -weight: 600;">sudo -weight: 500;">npm -weight: 500;">install -g pm2 pm2 -weight: 500;">start -weight: 500;">npm --name "nextjs-app" -- -weight: 500;">start pm2 -weight: 500;">start -weight: 500;">npm --name "nextjs-app" -- -weight: 500;">start pm2 -weight: 500;">start -weight: 500;">npm --name "nextjs-app" -- -weight: 500;">start pm2 -weight: 500;">status pm2 logs nextjs-app pm2 -weight: 500;">status pm2 logs nextjs-app pm2 -weight: 500;">status pm2 logs nextjs-app -weight: 500;">curl http://localhost:3000 -weight: 500;">curl http://localhost:3000 -weight: 500;">curl http://localhost:3000 pm2 startup pm2 save pm2 startup pm2 save pm2 startup pm2 save -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nginx -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nginx -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nginx -weight: 600;">sudo nano /etc/nginx/sites-available/your-app -weight: 600;">sudo nano /etc/nginx/sites-available/your-app -weight: 600;">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 '-weight: 500;">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; } } 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 '-weight: 500;">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; } } 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 '-weight: 500;">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; } } -weight: 600;">sudo ln -s /etc/nginx/sites-available/your-app /etc/nginx/sites-enabled/ -weight: 600;">sudo nginx -t -weight: 600;">sudo -weight: 500;">systemctl reload nginx -weight: 600;">sudo ln -s /etc/nginx/sites-available/your-app /etc/nginx/sites-enabled/ -weight: 600;">sudo nginx -t -weight: 600;">sudo -weight: 500;">systemctl reload nginx -weight: 600;">sudo ln -s /etc/nginx/sites-available/your-app /etc/nginx/sites-enabled/ -weight: 600;">sudo nginx -t -weight: 600;">sudo -weight: 500;">systemctl reload nginx -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y certbot python3-certbot-nginx -weight: 600;">sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y certbot python3-certbot-nginx -weight: 600;">sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y certbot python3-certbot-nginx -weight: 600;">sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status certbot.timer -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status certbot.timer -weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">status certbot.timer #!/bin/bash set -e cd /home/deploy/your-nextjs-app echo "Pulling latest code..." -weight: 500;">git pull origin main echo "Installing dependencies..." -weight: 500;">npm ci echo "Building..." -weight: 500;">npm run build echo "Restarting..." pm2 -weight: 500;">restart nextjs-app echo "Done." #!/bin/bash set -e cd /home/deploy/your-nextjs-app echo "Pulling latest code..." -weight: 500;">git pull origin main echo "Installing dependencies..." -weight: 500;">npm ci echo "Building..." -weight: 500;">npm run build echo "Restarting..." pm2 -weight: 500;">restart nextjs-app echo "Done." #!/bin/bash set -e cd /home/deploy/your-nextjs-app echo "Pulling latest code..." -weight: 500;">git pull origin main echo "Installing dependencies..." -weight: 500;">npm ci echo "Building..." -weight: 500;">npm run build echo "Restarting..." pm2 -weight: 500;">restart nextjs-app echo "Done." chmod +x /home/deploy/deploy.sh chmod +x /home/deploy/deploy.sh 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 # .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 # .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 pm2 -weight: 500;">install pm2-logrotate pm2 -weight: 500;">install pm2-logrotate pm2 -weight: 500;">install pm2-logrotate - A VPS from any provider (Hetzner, DigitalOcean, Linode, Vultr, whatever). 2GB RAM minimum for a Next.js app with a build step. 4GB if you're running a database on the same box. - A domain name pointed at your server's IP address. - SSH access to the server. - A Next.js app that builds successfully with -weight: 500;">npm run build on your local machine. - No health check. If the new build is broken, PM2 restarts the old process, but there's a window where requests fail. - No rollback. If the deploy breaks the app, you have to manually revert. - No preview environments. Every push to main goes straight to production. - Downtime during -weight: 500;">restart. PM2's -weight: 500;">restart kills the old process and starts the new one. There's a 1-3 second gap with 502 errors. - Node.js (runtime) - PM2 (process manager) - Nginx (reverse proxy, SSL termination) - Certbot (certificate renewal) - Git (code delivery) - GitHub Actions (build automation) - Uptime monitoring (external -weight: 500;">service) - Error tracking (external -weight: 500;">service) - Analytics (external -weight: 500;">service) - Log management (PM2 + logrotate, or external -weight: 500;">service) - Vercel is the obvious choice for Next.js. They built the framework. Free tier is generous. Costs scale fast with traffic. - Coolify is open-source, self-hosted. Handles Docker, Traefik proxy, SSL, and -weight: 500;">git-push deploys. Good community. - Dokploy is another open-source option, simpler than Coolify, focused on minimal configuration. - Kamal (from the Rails team) deploys Docker containers to any server over SSH. Minimal abstraction. - Temps is what I build. Single Rust binary that handles deployments plus analytics, error tracking, uptime monitoring, and session replay. One tool instead of 10. Smaller community, dashboard isn't as polished as Vercel's. Open source and free to self-host.