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:
- Runs linting and tests on every push
- Builds the Next.js application inside GitHub's infrastructure
- Deploys to your VPS via SSH with zero-downtime
- 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?
- Parallel potential: You could add a
lintandtestmatrix in the future - Clean failure attribution: If tests fail, you know immediately
- Security: The
deployjob 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.
| Secret | Description |
|---|---|
VPS_HOST | Your VPS IP address (e.g., 123.45.67.89) |
VPS_USER | SSH username (usually root or ubuntu) |
VPS_SSH_KEY | Full private key — paste the entire contents of ~/.ssh/id_rsa |
NEXT_PUBLIC_API_URL | Any public env vars your app needs at build time |
SLACK_WEBHOOK_URL | Slack 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
- test — runs ESLint/Prettier check, then your test suite (if you have one)
- deploy — builds Next.js (you'll see the output in the log), copies files to VPS, restarts PM2
- 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:
- Preview deployments for PRs — Deploy each pull request to a subdomain for testing
- Database migrations — Run
npx prisma migrate deployas part of the deploy step - Health checks — Curl your site after deploy to verify it's actually serving
- Rollback capability — Keep the previous build and add a rollback script
- Docker-based deploys — Build a Docker image in CI and pull it on the VPS
- 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.