DeployWise
HomeBlogGitHub Actions Deploy to VPS
TutorialGitHub ActionsCI/CDVPS

GitHub Actions Deploy to VPS — Complete CI/CD Guide

Set up automated deployments from GitHub to your VPS in 5 steps. This guide covers SSH key setup, workflow YAML configuration, zero-downtime deploys with PM2, and environment variable management. Plus, a faster alternative that skips all the config.

Mar 12, 2026
12 min read

Why Use GitHub Actions for VPS Deployment?

Pushing code to your server via SSH every time you make a change doesn't scale. GitHub Actions gives you a free CI/CD pipeline that automatically builds, tests, and deploys your app every time you push to your main branch — no Jenkins server, no third-party CI service, no manual work.

By the end of this guide, your deployment workflow will look like this:

  1. You push code to main
  2. GitHub Actions runs your tests
  3. If tests pass, it SSHs into your VPS
  4. It pulls the latest code, builds, and restarts with zero downtime

Prerequisites

  • A VPS with Ubuntu 22.04+ (Hetzner, DigitalOcean, Linode, etc.)
  • Node.js 18+ installed on the server
  • A GitHub repository with your app
  • PM2 installed globally (npm i -g pm2)
  • Nginx configured as a reverse proxy
  • Basic terminal and SSH knowledge

Need help with initial setup? See our Ubuntu VPS setup guide and PM2 + Nginx production setup.

Step 1: Setting Up SSH Keys for GitHub Actions

GitHub Actions needs a way to authenticate with your VPS. The most secure method is SSH key-based authentication. Generate a dedicated key pair for CI/CD — don't reuse your personal SSH key.

Generate SSH key pair on your local machine
# Generate a new Ed25519 key pair (no passphrase)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy -N ""

# Copy the public key to your VPS
ssh-copy-id -i ~/.ssh/github_actions_deploy.pub user@your-server-ip

# Test the connection
ssh -i ~/.ssh/github_actions_deploy user@your-server-ip "echo 'Connection successful'"

# Display the private key (you'll add this to GitHub)
cat ~/.ssh/github_actions_deploy

Now add the private key to your GitHub repository secrets:

  1. Go to your repo on GitHub → SettingsSecrets and variablesActions
  2. Click New repository secret
  3. Name: SSH_PRIVATE_KEY, Value: paste the entire private key
  4. Add another secret: SSH_HOST with your server IP
  5. Add another: SSH_USER with your server username

For a deeper dive into SSH key setup, see our SSH key setup guide.

Step 2: Creating the Workflow File

Create .github/workflows/deploy.yml in your repository. This workflow runs on every push to main, installs dependencies, runs tests, and deploys via SSH.

.github/workflows/deploy.yml
name: Deploy to VPS

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            npm ci --production
            npm run build
            pm2 reload myapp --update-env

How this works: The workflow has two jobs. The test job runs lint and tests. The deploy job only runs if tests pass (via needs: test). It SSHs into your server, pulls the latest code, builds, and reloads PM2.

Step 3: Environment Variables and Secrets

Never commit .env files to Git. Instead, use GitHub secrets for sensitive values and create the .env file on your server during deployment.

Recommended secrets to add

Secret NamePurposeExample
SSH_PRIVATE_KEYServer authentication(Ed25519 private key)
SSH_HOSTServer IP address203.0.113.10
SSH_USERServer usernamedeploy
DATABASE_URLDatabase connectionpostgresql://...
NODE_ENVRuntime environmentproduction

To write environment variables to the server during deployment, extend your SSH script:

Writing .env during deploy
- name: Deploy via SSH
  uses: appleboy/ssh-action@v1
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
    NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
  with:
    host: ${{ secrets.SSH_HOST }}
    username: ${{ secrets.SSH_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    envs: DATABASE_URL,NEXTAUTH_SECRET
    script: |
      cd /var/www/myapp
      echo "DATABASE_URL=$DATABASE_URL" > .env
      echo "NEXTAUTH_SECRET=$NEXTAUTH_SECRET" >> .env
      git pull origin main
      npm ci --production
      npm run build
      pm2 reload myapp --update-env

For more on environment variables in Node.js, see our Next.js environment variables guide.

Step 4: Zero-Downtime Deployment with PM2

The key to zero-downtime is using pm2 reload instead of pm2 restart. Reload gracefully replaces old processes, keeping your app available throughout the deployment.

ecosystem.config.js
module.exports = {
  apps: [{
    name: 'myapp',
    script: 'node_modules/.bin/next',
    args: 'start',
    instances: 2,          // Run 2 instances for zero-downtime
    exec_mode: 'cluster',  // Cluster mode enables reload
    env: {
      NODE_ENV: 'production',
      PORT: 3000,
    },
    // Graceful shutdown
    kill_timeout: 5000,
    listen_timeout: 10000,
    wait_ready: true,
  }],
};

How PM2 reload works

  1. PM2 starts a new instance with the updated code
  2. The new instance signals it's ready to receive traffic
  3. PM2 routes new requests to the new instance
  4. PM2 gracefully shuts down the old instance after existing requests complete
  5. Repeat for each cluster instance — zero downtime achieved

Full walkthrough: Zero-downtime deployment for Node.js.

Step 5: Nginx and SSL Considerations

Your VPS needs Nginx as a reverse proxy to handle HTTPS termination, gzip compression, and static file caching. Here's a minimal Nginx config for a Next.js app:

/etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

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

    # Cache static assets
    location /_next/static/ {
        proxy_pass http://127.0.0.1:3000;
        expires 365d;
        add_header Cache-Control "public, immutable";
    }
}

Set up SSL with Certbot:

SSL setup with Certbot
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com
# Auto-renewal is configured automatically

Detailed guides: Nginx reverse proxy for Node.js and SSL certificate setup on VPS.

The Easier Alternative: Skip All of This with DeployWise

The GitHub Actions workflow above works, but it's a lot of configuration: SSH keys, YAML files, secrets management, PM2 config, Nginx setup, SSL certificates. And when something breaks in the pipeline, you're debugging YAML syntax at 2am.

DeployWise does all of this automatically. Instead of GitHub Actions, it uses webhook-based deployments that trigger on push:

GitHub Actions (manual setup)

  • 1. Generate SSH keys manually
  • 2. Copy keys to GitHub secrets
  • 3. Write deploy.yml workflow
  • 4. Configure PM2 ecosystem file
  • 5. Set up Nginx reverse proxy
  • 6. Install and configure Certbot SSL
  • 7. Debug when the pipeline fails

DeployWise (automated)

  • Connect your GitHub repo
  • Add your VPS server IP
  • Push to deploy — that's it
  • Auto SSL, PM2, Nginx configured
  • Zero-downtime built in
  • No YAML files to maintain
  • Free and open source

Learn more about how DeployWise handles deployments: GitHub webhooks auto-deploy guide.

Advanced: Multi-Environment Deployments

For teams that need staging and production environments, extend your workflow to deploy different branches to different servers:

Multi-environment workflow
name: Deploy

on:
  push:
    branches: [main, staging]

jobs:
  deploy-staging:
    if: github.ref == 'refs/heads/staging'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to staging
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp-staging
            git pull origin staging
            npm ci && npm run build
            pm2 reload myapp-staging

  deploy-production:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            npm ci --production && npm run build
            pm2 reload myapp

Common Errors and Troubleshooting

Permission denied (publickey)

The SSH key isn't authorized on the server. Make sure the public key is in ~/.ssh/authorized_keys on the VPS and the private key is correctly added to GitHub secrets with no extra whitespace.

npm ci fails: node version mismatch

Your CI uses Node 20 but the server has Node 18. Use nvm on the server to match versions, or add nvm use 20 to the SSH script before npm ci.

Build succeeds but app crashes on restart

Check PM2 logs with pm2 logs myapp --lines 50. Common causes: missing environment variables, wrong NODE_ENV, or port conflicts. Make sure your .env file exists on the server.

Workflow hangs on SSH step

The server might be prompting for host key verification. Add StrictHostKeyChecking=no to the SSH config or pre-approve the host key. Also check if the build process is hanging (e.g., waiting for user input).

Out of disk space during build

Next.js builds can consume significant disk space. Clean old builds with rm -rf .next before building, and periodically run npm cache clean --force. Consider a VPS with at least 20GB storage.

GitHub Actions timeout (exceeded 6 hour limit)

Add a timeout-minutes: 10 to your job to fail fast instead of waiting the full 6 hours. Most deploy workflows should complete in under 5 minutes.

Frequently Asked Questions

How do I deploy to a VPS using GitHub Actions?

Create an SSH key pair, add the private key as a GitHub repository secret, then create a .github/workflows/deploy.yml file that uses appleboy/ssh-action to connect to your server, pull code, build, and restart your app with PM2.

Is GitHub Actions free for VPS deployments?

Yes. Public repos get unlimited free minutes. Private repos get 2,000 free minutes/month on the Free plan and 3,000 on Pro ($4/month). A typical VPS deployment takes 1-3 minutes, so even private repos can deploy hundreds of times per month for free.

What SSH action should I use for GitHub Actions?

The most popular and well-maintained option is appleboy/ssh-action. It supports Ed25519 and RSA keys, password authentication, proxy jumps, multi-host deployment, and inline script execution.

How do I handle environment variables in GitHub Actions for VPS deployment?

Store all sensitive values as GitHub repository secrets under Settings > Secrets > Actions. Reference them in your workflow with ${{ secrets.SECRET_NAME }}. Use the 'envs' parameter of appleboy/ssh-action to pass them to your SSH script.

Can I do zero-downtime deployment with GitHub Actions?

Yes. Use PM2 in cluster mode with 'pm2 reload' instead of 'pm2 restart'. Reload gracefully swaps old processes for new ones, maintaining availability. You need at least 2 instances in cluster mode for true zero-downtime.

Is there an easier alternative to GitHub Actions for VPS deployments?

Yes. DeployWise provides webhook-based push-to-deploy without any YAML files, SSH key management, or workflow configuration. It automatically handles builds, PM2, Nginx, and SSL on your VPS. It's free and open source.

Skip the YAML. Deploy with a Push.

DeployWise gives you automated VPS deployments without GitHub Actions workflows, SSH key management, or YAML debugging. Free and open source.

Try DeployWise Free

Related reading