devops

CI/CD Pipeline with GitHub Actions for Next.js on VPS

Build a complete CI/CD pipeline with GitHub Actions to automatically deploy your Next.js application to a VPS. Step-by-step tutorial with real code.

June 24, 2026·9 min read·
#github-actions#ci-cd#nextjs#vps#deployment#nginx

Introduction

Deploying a Next.js app to a VPS used to mean SSH-ing in, running git pull, npm run build, and restarting PM2. Every. Single. Time. That workflow is fragile, error-prone, and doesn't scale beyond one developer.

GitHub Actions changes everything. With a single YAML file, you can automate the entire build → test → deploy pipeline. Push to main, and your app is live 2 minutes later. No manual SSH. No forgotten build steps. No "it worked on my machine."

In this tutorial, you'll build a production-grade CI/CD pipeline that:

  1. Runs linting and tests on every push
  2. Builds the Next.js application inside GitHub's infrastructure
  3. Deploys to your VPS via SSH with zero-downtime
  4. Sends Slack notifications on deploy status

We'll use a $5/month VPS (Hetzner, DigitalOcean, or any Ubuntu box) and plain GitHub Actions — no Docker registry, no Kubernetes, no complexity you don't need.


Prerequisites

Before we start, make sure you have:

  • A Next.js project in a GitHub repository (App Router or Pages Router — both work)
  • A VPS running Ubuntu 22.04+ with SSH access
  • Node.js 20+ and PM2 installed on the VPS
  • Nginx installed on the VPS (we'll configure it together)

If you're missing any of these, don't worry — we'll cover the VPS setup in the next section.


Step 1: Prepare Your VPS

First, let's make sure your VPS is ready to serve a Next.js application.

Install Node.js and PM2

SSH into your VPS and run:

# Install Node.js 20.x via NodeSource
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs

# Install PM2 globally
sudo npm install -g pm2

# Verify installations
node --version   # Should show v20.x.x
pm2 --version    # Should show 5.x.x

Create the app directory

# Create directory for the application
sudo mkdir -p /var/www/nextjs-app
sudo chown -R $USER:$USER /var/www/nextjs-app

Configure Nginx as a Reverse Proxy

Create an Nginx config file:

sudo nano /etc/nginx/sites-available/nextjs-app

Paste this configuration:

server {
    listen 80;
    server_name your-domain.com;  # Replace with your domain or IP

    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_cache_bypass $http_upgrade;
        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;
    }
}

Enable the site and restart Nginx:

sudo ln -s /etc/nginx/sites-available/nextjs-app /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx

Step 2: Create the GitHub Actions Workflow

Now the fun part. Create .github/workflows/deploy.yml in your repository:

name: Deploy Next.js to VPS

on:
  push:
    branches: [main]
  workflow_dispatch:  # Allow manual triggers

env:
  NODE_VERSION: '20'
  APP_DIR: '/var/www/nextjs-app'

jobs:
  test:
    name: Lint & Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm run test --if-present

  deploy:
    name: Build & Deploy
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build Next.js
        run: npm run build
        env:
          # Pass any build-time environment variables here
          NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

      - name: Deploy to VPS
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          source: ".next,public,package.json,package-lock.json,node_modules,next.config.*"
          target: ${{ env.APP_DIR }}
          strip_components: 0

      - name: Restart Application
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd ${{ env.APP_DIR }}
            npm ci --production
            pm2 restart nextjs-app || pm2 start npm --name "nextjs-app" -- start
            pm2 save

  notify:
    name: Slack Notification
    needs: deploy
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Notify Slack
        uses: slackapi/slack-github-action@v1.24.0
        with:
          payload: |
            {
              "text": "Deploy ${{ job.status == 'success' && 'succeeded ✅' || 'failed ❌' }} — ${{ github.repository }}@${{ github.ref_name }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Understanding the Workflow

Let me break down what each job does:

test job — Runs first. Installs dependencies, runs linting, and executes tests. If anything fails here, the deploy job won't run. This is your safety net.

deploy job — Only runs if tests pass AND the push is to main. It builds your Next.js app (producing the optimized .next directory), then uses scp to copy everything to your VPS. Finally, it SSHs in and restarts PM2.

notify job — Runs regardless of success/failure (if: always()). Posts a Slack message so your team knows what happened.

Why Three Separate Jobs?

  1. Parallel potential: You could add a lint and test matrix in the future
  2. Clean failure attribution: If tests fail, you know immediately
  3. Security: The deploy job only accesses SSH secrets after passing tests

Step 3: Configure GitHub Secrets

The workflow references several secrets. Never hardcode credentials in your YAML file. Add them in your repository: Settings → Secrets and variables → Actions.

SecretDescription
VPS_HOSTYour VPS IP address (e.g., 123.45.67.89)
VPS_USERSSH username (usually root or ubuntu)
VPS_SSH_KEYFull private key — paste the entire contents of ~/.ssh/id_rsa
NEXT_PUBLIC_API_URLAny public env vars your app needs at build time
SLACK_WEBHOOK_URLSlack incoming webhook URL (optional)

Generating an SSH Key for Deployments

Never use your personal SSH key. Generate a dedicated deploy key:

# On your VPS
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions
cat ~/.ssh/github_actions.pub >> ~/.ssh/authorized_keys
cat ~/.ssh/github_actions  # Copy this entire output to VPS_SSH_KEY secret

Step 4: Push and Watch the Magic

Commit and push your workflow file:

git add .github/workflows/deploy.yml
git commit -m "Add GitHub Actions CI/CD pipeline"
git push origin main

Head to your repository's Actions tab. You'll see the workflow start running within seconds. Click on the running workflow to watch each job execute in real time.

What You Should See

  1. test — runs ESLint/Prettier check, then your test suite (if you have one)
  2. deploy — builds Next.js (you'll see the output in the log), copies files to VPS, restarts PM2
  3. notify — sends a Slack message

The entire pipeline should complete in 2-4 minutes for a typical Next.js project.


Step 5: Handle Environment Variables Properly

Next.js handles environment variables differently at build time vs runtime. Here's how to manage both:

Build-time Variables (.env.production)

These are baked into the JavaScript bundle. Add them to GitHub Secrets and reference them in the build step:

- name: Build Next.js
  run: npm run build
  env:
    NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

Runtime Variables (.env.local on VPS)

These stay on the server and are read at runtime. Create them on your VPS:

# On your VPS
cat > /var/www/nextjs-app/.env.local << 'EOF'
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
API_SECRET_KEY=your-secret-here
EOF

Never Commit .env Files

Add these to your .gitignore:

.env
.env.local
.env.production
.env*.local

Step 6: Optimize for Speed

Use npm Caching

The setup-node action with cache: 'npm' already caches node_modules. But you can also cache the Next.js build output:

- name: Cache Next.js build
  uses: actions/cache@v4
  with:
    path: |
      ${{ github.workspace }}/.next/cache
    key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-nextjs-

Place this before the build step.

Skip Unnecessary Files in SCP

Only transfer what the production app needs. The config above already limits files — notice it doesn't copy src/, pages/, or app/ directories (only the compiled .next output).


Step 7: Add Zero-Downtime Deploys with PM2

The basic workflow restarts PM2 immediately, which causes ~2 seconds of downtime. For zero-downtime, use PM2's reload:

script: |
  cd ${{ env.APP_DIR }}
  npm ci --production
  pm2 reload nextjs-app || pm2 start npm --name "nextjs-app" -- start
  pm2 save

pm2 reload starts new instances before killing old ones — your users never notice a deployment.

PM2 Ecosystem File (Advanced)

For more control, create ecosystem.config.js on your VPS:

module.exports = {
  apps: [{
    name: 'nextjs-app',
    script: 'node_modules/.bin/next',
    args: 'start',
    instances: 'max',     // Use all CPU cores
    exec_mode: 'cluster', // Enable cluster mode
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    }
  }]
};

Then start with: pm2 start ecosystem.config.js


Common Issues & Troubleshooting

"Permission denied (publickey)"

The SSH key in your VPS_SSH_KEY secret is incorrect or missing a trailing newline. Make sure you copied the entire key including -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY-----.

"next: command not found" After Deploy

PM2 can't find the Next.js binary. Make sure you're running npm ci --production on the VPS before restarting PM2. If using the ecosystem file, set script: 'node_modules/.bin/next' instead of just next.

Build Succeeds But Site Shows 502

Check if PM2 is actually running: pm2 status. If it shows errored, check logs with pm2 logs nextjs-app. Common causes: missing env vars, wrong Node.js version, or port conflicts.

GitHub Actions Runner Memory Limit

The default runner has 7GB RAM. If your Next.js build runs out of memory, add:

- name: Build Next.js
  run: NODE_OPTIONS='--max-old-space-size=4096' npm run build

Going Further: Production-Ready Enhancements

Once your basic pipeline is working, consider adding:

  1. Preview deployments for PRs — Deploy each pull request to a subdomain for testing
  2. Database migrations — Run npx prisma migrate deploy as part of the deploy step
  3. Health checks — Curl your site after deploy to verify it's actually serving
  4. Rollback capability — Keep the previous build and add a rollback script
  5. Docker-based deploys — Build a Docker image in CI and pull it on the VPS
  6. Multi-environment support — Different VPS targets for staging vs production

Conclusion

You now have a production CI/CD pipeline that:

  • Runs automatically on every push to main
  • Catches errors early with linting and tests before deploying
  • Deploys in minutes without manual SSH or build steps
  • Notifies your team on Slack when deployments happen

The total cost is $0 extra — GitHub Actions gives 2,000 free minutes/month for private repos (unlimited for public). Your VPS is the only cost.

This is the foundation. From here, you can add Docker, Kubernetes, preview deployments, or whatever your team needs. But don't over-engineer early — a simple scp + pm2 reload pipeline serves most projects perfectly well for years.

Your Next.js app is now one git push away from production.

#github-actions#ci-cd#nextjs#vps#deployment#nginx
D
DevToCashAuthor

Senior DevOps/SRE Engineer · 10+ years · Professional Trader (IDX, Crypto, US Equities)

I write about real infrastructure patterns and trading strategies I use in production and in live markets. No courses, no affiliate hype — just documentation of what actually works.

More about me →