# Deploying a Next.js App on a Linux VPS (The Complete Guide)

### Linux for Everyday Use | Part 7 of the Linux → Deploy Series

* * *

This is what everything in this series has been building toward.

By the end of this blog, your Next.js app will be live on the internet with a real domain, HTTPS, and automatic restarts if the server reboots or your app crashes. No Vercel. No Netlify. Your server, your rules.

Here's the full stack you're deploying:

```plaintext
Browser → nginx (port 443, HTTPS) → Next.js via PM2 (port 3000)
                     ↑
              Certbot (SSL cert)
```

* * *

## Prerequisites

Before you start, make sure you have:

*   A VPS running Ubuntu 22.04 (DigitalOcean, Hetzner, Vultr, AWS EC2 — any works)
    
*   A domain name pointing to your server's IP (A record set, propagated)
    
*   SSH access to the server (ideally with key auth from Blog 6)
    
*   Your Next.js app on GitHub
    

If you don't have a domain yet, you can complete steps 1–5 using your server's IP address and add the domain + SSL later.

* * *

## Step 1 — Connect to Your Server

```bash
ssh aakib@your-server-ip
```

Once connected, update the package list and upgrade existing packages:

```bash
sudo apt update && sudo apt upgrade -y
```

The `-y` flag auto-confirms prompts. This can take a few minutes on a fresh server.

* * *

## Step 2 — Install Node.js via nvm

Don't install Node from `apt` directly — the version in Ubuntu's default repos is almost always outdated. Use **nvm** (Node Version Manager) instead. It lets you install any Node version and switch between them.

```bash
# Download and run the nvm install script
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
```

After it finishes, either restart your terminal session or run:

```bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
```

Verify nvm is available:

```bash
nvm --version
```

Install the latest LTS version of Node:

```bash
nvm install --lts
```

Verify:

```bash
node -v
# v22.x.x (or whatever the current LTS is)

npm -v
```

* * *

## Step 3 — Clone Your Repository

Navigate to where you want to keep your app. The `/var/www/` directory is a common convention for web apps:

```bash
sudo mkdir -p /var/www
sudo chown $USER:$USER /var/www
cd /var/www
```

Clone your repo:

```bash
git clone https://github.com/yourusername/your-nextjs-app.git
cd your-nextjs-app
```

If your repo is private, you'll need to authenticate. The cleanest way is to generate a deploy key:

```bash
# On the server, generate a key specifically for this repo
ssh-keygen -t ed25519 -C "deploy-key" -f ~/.ssh/deploy_key -N ""

# Print the public key
cat ~/.ssh/deploy_key.pub
```

Copy that public key and add it as a **Deploy Key** in your GitHub repo: Settings → Deploy keys → Add deploy key. Then clone with SSH:

```bash
git clone git@github.com:yourusername/your-nextjs-app.git
```

* * *

## Step 4 — Set Environment Variables

Your Next.js app almost certainly needs environment variables — API keys, database URLs, secrets. These should never be committed to Git.

Create the `.env.production` file directly on the server:

```bash
nano /var/www/your-nextjs-app/.env.production
```

Add your variables:

```plaintext
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
NEXTAUTH_SECRET=your-secret-here
NEXTAUTH_URL=https://your-domain.com
NEXT_PUBLIC_API_URL=https://your-domain.com/api
```

Save with `Ctrl+O`, exit with `Ctrl+X`.

Lock down the permissions:

```bash
chmod 600 .env.production
```

Only your user can read or write it. Good.

**Important note on** `NEXT_PUBLIC_` **variables:** These are embedded into the client-side bundle at build time. If you change them, you need to rebuild the app. Regular (non-public) variables are read at runtime by the server.

* * *

## Step 5 — Install Dependencies and Build

```bash
cd /var/www/your-nextjs-app

npm install

npm run build
```

`npm run build` compiles your Next.js app into the `.next/` directory — static assets, server-side code, everything optimized for production.

This can take 1–3 minutes. A successful build ends with something like:

```plaintext
Route (app)                              Size     First Load JS
┌ ○ /                                    5.2 kB         87.4 kB
├ ○ /about                               1.1 kB         83.3 kB
└ ○ /api/health                          0 B                0 B

○  (Static)   prerendered as static content
```

Test that the app starts:

```bash
npm start
```

Visit `http://your-server-ip:3000` in your browser. If you see your app, the build is good. Press `Ctrl+C` to stop it — you'll use PM2 to run it properly next.

* * *

## Step 6 — Install PM2 and Keep the App Alive

**PM2** is a production process manager for Node.js. It:

*   Keeps your app running after you close the terminal
    
*   Automatically restarts it if it crashes
    
*   Starts it automatically on server reboot
    
*   Manages logs
    

Install PM2 globally:

```bash
npm install -g pm2
```

Start your Next.js app with PM2:

```bash
cd /var/www/your-nextjs-app
pm2 start npm --name "nextjs-app" -- start
```

This tells PM2 to run `npm start` and name the process `nextjs-app`.

Check it's running:

```bash
pm2 status
```

```plaintext
┌────┬──────────────┬─────────┬──────┬───────────┬──────────┐
│ id │ name         │ status  │ cpu  │ mem       │ watching │
├────┼──────────────┼─────────┼──────┼───────────┼──────────┤
│ 0  │ nextjs-app   │ online  │ 0%   │ 87.3 MB   │ disabled │
└────┴──────────────┴─────────┴──────┴───────────┴──────────┘
```

### Make PM2 start on reboot

```bash
pm2 startup
```

PM2 prints a command — copy it and run it. It looks like:

```plaintext
sudo env PATH=$PATH:/home/aakib/.nvm/versions/node/v22.0.0/bin \
  /home/aakib/.nvm/versions/node/v22.0.0/lib/node_modules/pm2/bin/pm2 \
  startup systemd -u aakib --hp /home/aakib
```

Run the exact command it gives you (yours will have your paths). Then save the current PM2 process list:

```bash
pm2 save
```

Now if your server reboots, PM2 restarts automatically and brings your Next.js app back up.

### Useful PM2 commands

```bash
pm2 logs nextjs-app          # View live logs
pm2 logs nextjs-app --lines 50  # Last 50 lines
pm2 restart nextjs-app       # Restart the app
pm2 stop nextjs-app          # Stop the app
pm2 delete nextjs-app        # Remove from PM2
pm2 monit                    # Interactive monitoring dashboard
```

* * *

## Step 7 — Configure nginx

Install nginx if you haven't already (from Blog 6):

```bash
sudo apt install nginx
```

Create your site config:

```bash
sudo nano /etc/nginx/sites-available/your-domain.com
```

```nginx
server {
    listen 80;
    server_name your-domain.com www.your-domain.com;

    location / {
        proxy_pass http://localhost: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 it:

```bash
sudo ln -s /etc/nginx/sites-available/your-domain.com /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
```

Visit `http://your-domain.com` — your app should load over HTTP.

* * *

## Step 8 — Add HTTPS with Certbot (Free SSL)

Certbot obtains a free SSL certificate from Let's Encrypt and automatically configures nginx to use it.

```bash
sudo apt install certbot python3-certbot-nginx
```

Run Certbot:

```bash
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
```

Certbot will:

1.  Verify you own the domain (by serving a challenge file via nginx)
    
2.  Issue an SSL certificate
    
3.  Automatically update your nginx config to redirect HTTP → HTTPS and serve on port 443
    

When it asks about HTTP → HTTPS redirect, choose option 2 (Redirect). This forces all traffic to HTTPS.

Test by visiting `https://your-domain.com`. Your browser should show the padlock.

### Auto-renewal

Let's Encrypt certs expire every 90 days. Certbot installs a cron job or systemd timer to renew automatically. Test it with:

```bash
sudo certbot renew --dry-run
```

If it completes without errors, your cert will renew automatically forever.

* * *

## Step 9 — Deploying Updates

When you push changes to GitHub, here's how to deploy them to the server:

```bash
cd /var/www/your-nextjs-app

# Pull latest code
git pull origin main

# Install any new dependencies
npm install

# Rebuild
npm run build

# Restart the app (zero-downtime via PM2)
pm2 restart nextjs-app
```

That's 4 commands. You can put them in a shell script for convenience:

```bash
nano ~/deploy.sh
```

```bash
#!/bin/bash
set -e

cd /var/www/your-nextjs-app
git pull origin main
npm install
npm run build
pm2 restart nextjs-app
echo "Deployed successfully."
```

```bash
chmod +x ~/deploy.sh
```

Now deploying is just:

```bash
~/deploy.sh
```

* * *

## Troubleshooting Common Issues

### App starts but shows a 502 Bad Gateway

nginx can't reach your app on port 3000. Check:

```bash
pm2 status          # Is the app running?
pm2 logs nextjs-app # Any crash logs?
ss -tlnp            # Is port 3000 actually listening?
```

### Environment variables not loading

Make sure your `.env.production` file is in the project root (same level as `package.json`). If you added new variables after building, you need to rebuild:

```bash
npm run build && pm2 restart nextjs-app
```

### Build fails with out-of-memory errors

On a 1 GB RAM server, Next.js builds can run out of memory. Add a swap file:

```bash
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

# Make it permanent
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
```

This gives your server 1 GB of swap space — slower than RAM, but it prevents OOM kills during builds.

### Domain not working after connecting to server

DNS propagation takes up to 48 hours but usually resolves in 15–60 minutes. Check whether your domain is pointing to the right IP:

```bash
# On your local machine
nslookup your-domain.com
# or
dig your-domain.com
```

If the IP matches your server, nginx should be reachable.

* * *

## The Full Picture

Here's what you've built:

```plaintext
User types https://your-domain.com
    ↓
DNS resolves to your server IP
    ↓
nginx receives request on port 443 (HTTPS)
SSL cert verified by browser (Certbot / Let's Encrypt)
    ↓
nginx proxies request to localhost:3000
    ↓
PM2-managed Next.js process handles the request
    ↓
Response goes back through nginx to the user
```

Your server survives:

*   **Terminal disconnect** — PM2 keeps the process alive
    
*   **App crash** — PM2 auto-restarts
    
*   **Server reboot** — PM2 startup script brings everything back
    

* * *

## Quick Reference

| Command | What it does |
| --- | --- |
| `nvm install --lts` | Install latest LTS Node |
| `npm run build` | Build Next.js for production |
| `pm2 start npm --name "app" -- start` | Start app with PM2 |
| `pm2 startup && pm2 save` | Persist PM2 across reboots |
| `pm2 logs app` | View live app logs |
| `pm2 restart app` | Restart app |
| `sudo nginx -t` | Test nginx config |
| `sudo systemctl reload nginx` | Apply nginx config |
| `sudo certbot --nginx -d domain.com` | Issue SSL cert + configure nginx |
| `sudo certbot renew --dry-run` | Test cert auto-renewal |

* * *

## What's Next

You have a production-grade Next.js deployment. But there's more to learn:

*   **CI/CD** — automate the `~/deploy.sh` to run on every push to `main` using GitHub Actions
    
*   **Database on the server** — PostgreSQL setup, connection pooling, migrations
    
*   **Docker** — containerize your app so it runs identically everywhere
    
*   **Monitoring** — Uptime checks, alerting when your app goes down
    

Each of these is a series in itself. But you now have the Linux foundation to approach all of them.
