Manual deployments — SSH into your server, pull the latest code, restart services — work when you're starting out, but they're error-prone, unrepeatable, and don't scale. CI/CD (Continuous Integration / Continuous Deployment) automates this: every push to your main branch triggers a pipeline that tests your code, builds it, and deploys it to your VPS without human intervention.

GitHub Actions is the most accessible CI/CD platform for projects hosted on GitHub. This guide walks through the complete setup: creating SSH deploy keys, writing workflows for rsync-based and Docker-based deployments, implementing zero-downtime rolling restarts, managing secrets and environment variables, adding build and test stages, setting up deployment notifications, and implementing rollback strategies. By the end, pushing to main will automatically and safely deploy your application to your Ubuntu VPS.

Prerequisites

Before starting, you need:

MassiveGRID Ubuntu VPS — Ubuntu 24.04 LTS pre-installed, Proxmox HA cluster with automatic failover, Ceph 3x replicated NVMe storage, independent CPU/RAM/storage scaling, 12 Tbps DDoS protection, 4 global datacenter locations, 100% uptime SLA, and 24/7 human support rated 9.5/10. Deploy a self-managed VPS from $1.99/mo.

Setting Up SSH Deploy Keys

GitHub Actions needs SSH access to your VPS to deploy code. We'll use a dedicated SSH key pair — separate from your personal keys — so you can revoke it independently.

Generate a Deploy Key

On your local machine (not the VPS), generate a new Ed25519 key pair:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy -N ""

This creates two files:

Add the Public Key to Your VPS

Copy the public key to the deploy user's authorized keys on the VPS:

ssh-copy-id -i ~/.ssh/github_actions_deploy.pub deploy@your-server-ip

Or manually:

# On the VPS, as the deploy user
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "ssh-ed25519 AAAA...your-public-key... github-actions-deploy" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Test the connection:

ssh -i ~/.ssh/github_actions_deploy deploy@your-server-ip "echo 'SSH connection successful'"

Add the Private Key to GitHub Secrets

GitHub Actions reads sensitive values from encrypted repository secrets.

  1. Go to your GitHub repository
  2. Navigate to Settings > Secrets and variables > Actions
  3. Click New repository secret
  4. Add these secrets:
Secret Name Value
SSH_PRIVATE_KEY Contents of ~/.ssh/github_actions_deploy (the entire private key file)
SSH_HOST Your VPS IP address (e.g., 203.0.113.50)
SSH_USER The deploy username (e.g., deploy)
SSH_PORT SSH port (e.g., 22 or your custom port)

To copy the private key contents:

cat ~/.ssh/github_actions_deploy

Copy the entire output including the -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY----- lines.

Restrict the Deploy Key (Optional but Recommended)

For additional security, restrict what the deploy key can do on the VPS. Edit the authorized_keys entry to limit commands:

# On the VPS
nano ~/.ssh/authorized_keys

You can add restrictions before the key:

from="140.82.112.0/20,185.199.108.0/22,192.30.252.0/22",no-pty,no-X11-forwarding ssh-ed25519 AAAA...your-key... github-actions-deploy

The from= directive restricts connections to GitHub Actions IP ranges. Check GitHub's documentation for the current IP ranges, as they may change.

Basic Deploy Workflow with rsync

The simplest deployment strategy: sync your project files to the VPS using rsync over SSH. This works well for static sites, PHP applications, and any deployment that doesn't need a build step on the server.

Create the workflow file in your repository:

mkdir -p .github/workflows

Create .github/workflows/deploy.yml:

name: Deploy to VPS

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy via rsync
        run: |
          rsync -avz --delete \
            --exclude='.git' \
            --exclude='.github' \
            --exclude='node_modules' \
            --exclude='.env' \
            --exclude='storage/logs/*' \
            -e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
            ./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/

      - name: Run post-deploy commands
        run: |
          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
          cd /var/www/myapp
          npm install --production
          pm2 reload ecosystem.config.js
          echo "Deploy completed at $(date)"
          DEPLOY_SCRIPT

This workflow:

  1. Triggers on every push to the main branch
  2. Checks out the repository code
  3. Sets up the SSH key from GitHub Secrets
  4. Syncs files to the VPS using rsync (excluding sensitive and unnecessary files)
  5. Runs post-deploy commands: installs dependencies and reloads the application

The --delete flag in rsync removes files on the server that no longer exist in the repository. Remove it if you don't want this behavior (e.g., if user-uploaded files live in the deployment directory).

Build and Test Stages

A proper CI/CD pipeline tests and builds your code before deploying. This catches errors before they reach production.

Create .github/workflows/deploy.yml with build and test stages:

name: Test, Build, and Deploy

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: myapp_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7
        ports:
          - 6379:6379

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        env:
          DATABASE_URL: postgresql://test:testpass@localhost:5432/myapp_test
          REDIS_URL: redis://localhost:6379
          NODE_ENV: test
        run: npm test

  build:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Build production assets
        run: npm run build
        env:
          NODE_ENV: production

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 1

  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/

      - name: Setup SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy via rsync
        run: |
          rsync -avz --delete \
            --exclude='.git' \
            --exclude='.github' \
            --exclude='node_modules' \
            --exclude='.env' \
            -e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
            ./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/

      - name: Run post-deploy commands
        run: |
          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
          cd /var/www/myapp
          npm install --production
          pm2 reload ecosystem.config.js
          echo "Deploy completed at $(date)"
          DEPLOY_SCRIPT

This pipeline has three stages:

  1. Test — runs on every push and pull request. Spins up PostgreSQL and Redis service containers, installs dependencies, runs the linter and test suite.
  2. Build — only runs on pushes to main after tests pass. Compiles production assets and uploads them as artifacts.
  3. Deploy — only runs after a successful build. Syncs code and built assets to the VPS.

Pull requests trigger only the test stage, giving you confidence before merging. The deploy stage only runs on actual pushes to main.

Docker-Based Deployment Workflow

For Docker-based applications, the workflow builds a Docker image, pushes it to a container registry, then pulls and runs it on the VPS. This ensures identical environments between CI and production.

If you haven't installed Docker on your VPS yet, follow our Docker installation guide.

name: Docker Deploy

on:
  push:
    branches:
      - main

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest

    steps:
      - name: Setup SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy to VPS
        env:
          IMAGE_TAG: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          COMMIT_SHA: ${{ github.sha }}
        run: |
          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << DEPLOY_SCRIPT
          set -e

          echo "==> Logging into GitHub Container Registry..."
          echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin

          echo "==> Pulling latest image..."
          docker pull ${IMAGE_TAG}

          echo "==> Stopping old container..."
          docker stop myapp || true
          docker rm myapp || true

          echo "==> Starting new container..."
          docker run -d \
            --name myapp \
            --restart unless-stopped \
            --env-file /var/www/myapp/.env \
            -p 3000:3000 \
            ${IMAGE_TAG}

          echo "==> Cleaning up old images..."
          docker image prune -f

          echo "==> Deploy completed (${COMMIT_SHA:0:7}) at \$(date)"
          DEPLOY_SCRIPT

For this workflow, add one additional secret to your repository:

Secret Name Value
GHCR_PAT A GitHub Personal Access Token with read:packages scope (for the VPS to pull images)

Docker Compose Deployment

For multi-container applications, use Docker Compose on the VPS. Update the deploy step:

      - name: Deploy with Docker Compose
        run: |
          # First, sync the docker-compose file
          scp -i ~/.ssh/deploy_key -P ${{ secrets.SSH_PORT }} \
            docker-compose.prod.yml \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/docker-compose.yml

          # Then deploy
          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
          cd /var/www/myapp

          echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin

          docker compose pull
          docker compose up -d --remove-orphans
          docker image prune -f

          echo "Deploy completed at $(date)"
          DEPLOY_SCRIPT

Zero-Downtime Deployments

The basic deployment strategy has a brief downtime window when the old process stops and the new one starts. Here are strategies to eliminate that gap.

PM2 Reload (Node.js)

PM2's reload command starts new workers, waits for them to be ready, then gracefully shuts down old workers. No requests are dropped:

      - name: Zero-downtime deploy
        run: |
          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
          cd /var/www/myapp

          # Pull latest code
          git fetch origin main
          git reset --hard origin/main

          # Install dependencies
          npm install --production

          # Zero-downtime reload (requires cluster mode)
          pm2 reload ecosystem.config.js

          pm2 save
          echo "Zero-downtime deploy completed at $(date)"
          DEPLOY_SCRIPT

This requires your application to run in PM2 cluster mode (exec_mode: 'cluster' in the ecosystem file). See our guide on running Node.js with PM2 for the complete cluster mode setup.

Docker Rolling Update

For Docker deployments, use a blue-green strategy with Nginx as the load balancer:

      - name: Zero-downtime Docker deploy
        run: |
          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
          set -e
          cd /var/www/myapp

          IMAGE="ghcr.io/youruser/yourapp:latest"
          echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker pull $IMAGE

          # Start new container on a different name
          docker run -d --name myapp-new \
            --env-file .env \
            -p 3001:3000 \
            $IMAGE

          # Wait for health check
          echo "Waiting for new container to be healthy..."
          for i in $(seq 1 30); do
            if curl -sf http://localhost:3001/health > /dev/null 2>&1; then
              echo "New container is healthy!"
              break
            fi
            if [ $i -eq 30 ]; then
              echo "Health check failed — rolling back"
              docker stop myapp-new && docker rm myapp-new
              exit 1
            fi
            sleep 2
          done

          # Switch Nginx upstream to new container
          sudo sed -i 's/127.0.0.1:3000/127.0.0.1:3001/' /etc/nginx/sites-available/myapp
          sudo nginx -t && sudo systemctl reload nginx

          # Stop old container
          docker stop myapp || true
          docker rm myapp || true

          # Rename new container and update port
          docker stop myapp-new
          docker rm myapp-new

          # Start final container on the standard port
          docker run -d --name myapp \
            --restart unless-stopped \
            --env-file .env \
            -p 3000:3000 \
            $IMAGE

          # Restore Nginx config to standard port
          sudo sed -i 's/127.0.0.1:3001/127.0.0.1:3000/' /etc/nginx/sites-available/myapp
          sudo nginx -t && sudo systemctl reload nginx

          docker image prune -f
          echo "Zero-downtime deploy completed at $(date)"
          DEPLOY_SCRIPT

Health Check Endpoint

Both strategies above rely on a health check endpoint. Add one to your application:

// Express.js example
app.get('/health', (req, res) => {
  // Check database connection, cache, etc.
  const checks = {
    uptime: process.uptime(),
    timestamp: Date.now(),
    status: 'ok'
  };

  // Add database check
  try {
    await db.query('SELECT 1');
    checks.database = 'connected';
  } catch (err) {
    checks.database = 'disconnected';
    checks.status = 'degraded';
    return res.status(503).json(checks);
  }

  res.status(200).json(checks);
});

Environment Variables and Secrets Management

Never commit secrets to your repository. GitHub Actions and your VPS each have their own mechanism for managing sensitive values.

GitHub Actions Secrets

Store deployment credentials in GitHub Secrets (Settings > Secrets and variables > Actions). Reference them in workflows with ${{ secrets.SECRET_NAME }}.

For application-specific environment variables that need to be on the VPS, you have two options:

Option 1: .env File on the VPS

Manually create a .env file on the VPS that your application reads at startup. This is the simplest approach and keeps secrets completely off GitHub:

# On the VPS
nano /var/www/myapp/.env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
SESSION_SECRET=your-random-secret
API_KEY=sk-xxxxxxxxxxxx
chmod 600 /var/www/myapp/.env

Exclude .env from rsync in your deploy workflow (which we already did with --exclude='.env').

Option 2: GitHub Secrets to .env File

If you want GitHub Actions to manage environment variables (useful for multi-environment deployments), create the .env file during deployment:

      - name: Create .env file on VPS
        run: |
          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << ENVSCRIPT
          cat > /var/www/myapp/.env << 'ENVFILE'
          NODE_ENV=production
          PORT=3000
          DATABASE_URL=${{ secrets.DATABASE_URL }}
          REDIS_URL=${{ secrets.REDIS_URL }}
          SESSION_SECRET=${{ secrets.SESSION_SECRET }}
          API_KEY=${{ secrets.API_KEY }}
          ENVFILE
          chmod 600 /var/www/myapp/.env
          ENVSCRIPT

GitHub Environments

For multi-environment deployments (staging + production), use GitHub Environments. Each environment has its own set of secrets and can require manual approval.

  1. Go to Settings > Environments
  2. Create environments: staging and production
  3. Add environment-specific secrets (different database URLs, API keys, etc.)
  4. Enable Required reviewers for production

Reference environments in your workflow:

  deploy-staging:
    needs: test
    runs-on: ubuntu-latest
    environment: staging
    steps:
      # ... deploy to staging server using staging secrets

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production
    steps:
      # ... deploy to production server using production secrets
      # Requires manual approval before running

Deployment Notifications

Get notified when deployments succeed or fail so you can respond quickly to issues.

Slack Notifications

Add a Slack webhook URL as a GitHub Secret (SLACK_WEBHOOK_URL), then add a notification step at the end of your deploy job:

      - name: Notify Slack on success
        if: success()
        run: |
          curl -X POST -H 'Content-type: application/json' \
            --data '{
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": ":white_check_mark: *Deployment Successful*\n*Repo:* ${{ github.repository }}\n*Branch:* ${{ github.ref_name }}\n*Commit:* `${{ github.sha }}` by ${{ github.actor }}\n*Message:* ${{ github.event.head_commit.message }}"
                  }
                }
              ]
            }' \
            ${{ secrets.SLACK_WEBHOOK_URL }}

      - name: Notify Slack on failure
        if: failure()
        run: |
          curl -X POST -H 'Content-type: application/json' \
            --data '{
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": ":x: *Deployment Failed*\n*Repo:* ${{ github.repository }}\n*Branch:* ${{ github.ref_name }}\n*Commit:* `${{ github.sha }}` by ${{ github.actor }}\n*Action:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"
                  }
                }
              ]
            }' \
            ${{ secrets.SLACK_WEBHOOK_URL }}

Discord Notifications

Discord webhooks accept a similar format. Add DISCORD_WEBHOOK_URL as a secret:

      - name: Notify Discord
        if: always()
        run: |
          if [ "${{ job.status }}" == "success" ]; then
            COLOR=3066993
            STATUS="Successful"
          else
            COLOR=15158332
            STATUS="Failed"
          fi

          curl -X POST -H "Content-Type: application/json" \
            -d "{
              \"embeds\": [{
                \"title\": \"Deployment ${STATUS}\",
                \"color\": ${COLOR},
                \"fields\": [
                  {\"name\": \"Repository\", \"value\": \"${{ github.repository }}\", \"inline\": true},
                  {\"name\": \"Branch\", \"value\": \"${{ github.ref_name }}\", \"inline\": true},
                  {\"name\": \"Author\", \"value\": \"${{ github.actor }}\", \"inline\": true},
                  {\"name\": \"Commit\", \"value\": \"\`${{ github.sha }}\`\"},
                  {\"name\": \"Message\", \"value\": \"${{ github.event.head_commit.message }}\"}
                ],
                \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
              }]
            }" \
            ${{ secrets.DISCORD_WEBHOOK_URL }}

Email Notifications

GitHub Actions sends email notifications for workflow failures by default. You can configure this in your GitHub notification settings. For custom email notifications, use a step with curl to call a service like SendGrid or Mailgun.

Rollback Strategies

When a deployment goes wrong, you need to quickly revert to the previous working version. Plan your rollback strategy before you need it.

Git-Based Rollback

The simplest rollback: deploy a previous commit. Create a manual workflow trigger:

name: Rollback Deployment

on:
  workflow_dispatch:
    inputs:
      commit_sha:
        description: 'Commit SHA to roll back to'
        required: true
        type: string
      reason:
        description: 'Reason for rollback'
        required: true
        type: string

jobs:
  rollback:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout specific commit
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.inputs.commit_sha }}

      - name: Setup SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy rollback
        run: |
          rsync -avz --delete \
            --exclude='.git' \
            --exclude='.github' \
            --exclude='node_modules' \
            --exclude='.env' \
            -e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
            ./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/

          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
          cd /var/www/myapp
          npm install --production
          pm2 reload ecosystem.config.js
          echo "ROLLBACK to ${{ github.event.inputs.commit_sha }} completed at $(date)"
          echo "Reason: ${{ github.event.inputs.reason }}"
          DEPLOY_SCRIPT

Trigger this workflow manually from the Actions tab in GitHub: Actions > Rollback Deployment > Run workflow.

Docker Image Rollback

If you use Docker deployments with tagged images, rolling back is even simpler — just run the previous image tag:

name: Docker Rollback

on:
  workflow_dispatch:
    inputs:
      image_tag:
        description: 'Docker image tag to roll back to (commit SHA)'
        required: true
        type: string

jobs:
  rollback:
    runs-on: ubuntu-latest

    steps:
      - name: Setup SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Rollback container
        run: |
          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << DEPLOY_SCRIPT
          set -e

          IMAGE="ghcr.io/${{ github.repository }}:${{ github.event.inputs.image_tag }}"

          echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker pull \$IMAGE

          docker stop myapp || true
          docker rm myapp || true

          docker run -d --name myapp \
            --restart unless-stopped \
            --env-file /var/www/myapp/.env \
            -p 3000:3000 \
            \$IMAGE

          echo "ROLLBACK to tag ${{ github.event.inputs.image_tag }} completed at \$(date)"
          DEPLOY_SCRIPT

Keeping Rollback Versions on the VPS

Another approach: keep the last N deployments on the VPS and symlink the current one. This makes rollback instant (no re-download):

      - name: Deploy with release directories
        run: |
          RELEASE_DIR="releases/$(date +%Y%m%d_%H%M%S)_${GITHUB_SHA:0:7}"

          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << DEPLOY_SCRIPT
          set -e
          BASE="/var/www/myapp"
          RELEASE="\$BASE/${RELEASE_DIR}"

          mkdir -p \$RELEASE

          DEPLOY_SCRIPT

          # Sync to the release directory
          rsync -avz --delete \
            --exclude='.git' \
            --exclude='.github' \
            --exclude='node_modules' \
            --exclude='.env' \
            -e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
            ./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/${RELEASE_DIR}/

          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << DEPLOY_SCRIPT
          set -e
          BASE="/var/www/myapp"
          RELEASE="\$BASE/${RELEASE_DIR}"

          # Install dependencies
          cd \$RELEASE
          npm install --production

          # Copy persistent files
          cp \$BASE/shared/.env \$RELEASE/.env 2>/dev/null || true

          # Symlink current to new release
          ln -sfn \$RELEASE \$BASE/current

          # Reload application
          cd \$BASE/current
          pm2 reload ecosystem.config.js

          # Keep only the last 5 releases
          cd \$BASE/releases
          ls -dt */ | tail -n +6 | xargs rm -rf

          echo "Deployed ${RELEASE_DIR} at \$(date)"
          DEPLOY_SCRIPT

To rollback, simply re-symlink to a previous release directory:

# Manual rollback on the VPS
cd /var/www/myapp
ls releases/              # See available releases
ln -sfn releases/20260226_143000_abc1234 current
cd current && pm2 reload ecosystem.config.js

Securing the Deployment Pipeline

A few additional security measures for production deployments:

Branch Protection Rules

In GitHub, go to Settings > Branches > Add rule for main:

This ensures only tested, reviewed code reaches main and triggers deployment.

Deployment Concurrency

Prevent multiple deployments from running simultaneously, which could cause race conditions:

jobs:
  deploy:
    runs-on: ubuntu-latest
    concurrency:
      group: production-deploy
      cancel-in-progress: false    # Don't cancel running deploys

Firewall Rules for SSH

If your VPS firewall allows SSH from any IP, consider restricting it to GitHub Actions IP ranges for the deploy key. See our security hardening guide for comprehensive firewall configuration.

Complete Workflow Template

Here's a production-ready workflow combining all the pieces — test, build, deploy, notify, with rollback available as a separate manual workflow:

name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'

      - run: npm ci
      - run: npm run build
        env:
          NODE_ENV: production

      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy
        run: |
          rsync -avz --delete \
            --exclude='.git' --exclude='.github' \
            --exclude='node_modules' --exclude='.env' \
            -e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
            ./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/

          ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
          cd /var/www/myapp
          npm install --production
          pm2 reload ecosystem.config.js
          pm2 save
          EOF

      - name: Notify success
        if: success()
        run: |
          curl -sS -X POST -H 'Content-type: application/json' \
            --data '{"text":"Deployed `${{ github.sha }}` to production by ${{ github.actor }}"}' \
            ${{ secrets.SLACK_WEBHOOK_URL }} || true

      - name: Notify failure
        if: failure()
        run: |
          curl -sS -X POST -H 'Content-type: application/json' \
            --data '{"text":"FAILED deploy `${{ github.sha }}` — <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"}' \
            ${{ secrets.SLACK_WEBHOOK_URL }} || true

Prefer Managed Deployment?

Setting up and maintaining a CI/CD pipeline requires ongoing attention — updating workflow files, rotating deploy keys, troubleshooting failed deploys, and managing server-side dependencies. If you'd rather focus on your application code, MassiveGRID's Managed Dedicated Cloud Servers include infrastructure administration: OS updates, security patching, monitoring, backups, and 24/7 incident response. Every managed server runs on a Proxmox HA cluster with automatic failover and Ceph triple-replicated NVMe storage.

What's Next