Mar 6, 2026

Self-Hosted CI/CD: GitHub Actions + Your VPS (Complete Guide)

Build a complete self-hosted CI/CD pipeline using GitHub Actions and your own VPS. Learn SSH deployment, Docker builds, zero-downtime deployments, rollback strategies, and save hundreds on expensive build minutes.

Server Compass TeamMar 6, 2026
Self-Hosted CI/CD: GitHub Actions + Your VPS (Complete Guide)

Every time you push code, you want it deployed. That's the promise of CI/CD. But if you're self-hosting on a VPS, you've probably discovered the gap: platforms like Vercel and Netlify handle this automatically, while VPS deployments require you to build everything from scratch.

The good news? GitHub Actions gives you 2,000 free minutes per month on private repos (unlimited on public repos). Combined with your VPS, you get a production-grade CI/CD pipeline that costs nothing beyond your server bill. No expensive build minutes. No vendor lock-in. Complete control over your deployment process.

This guide walks you through building a complete self-hosted CI/CD pipeline: from basic SSH deployment to Docker builds, zero-downtime deployments, and automated rollbacks. By the end, you'll have a deployment system that rivals any managed platform.

Why GitHub Actions + VPS Is the Perfect Combination

Before diving into implementation, let's understand why this combination works so well for self-hosted deployments.

The Build Resource Problem

Building applications is resource-intensive. A Next.js build can easily consume 2-4GB of RAM. A Docker image build with multi-stage compilation? Even more. If you're running a $5-10/month VPS with 1-2GB RAM, building on the server itself is often impossible — or it grinds your production app to a halt.

GitHub Actions solves this by offloading builds to GitHub's runners:

  • 2-core CPU with 7GB RAM per runner
  • 14GB SSD storage for build artifacts
  • 2,000 free minutes/month on private repos
  • Unlimited minutes on public repos

Your VPS stays cool and responsive. The heavy lifting happens on GitHub's infrastructure. You only pull the final artifact (a Docker image or compiled bundle) to your server.

Automatic Triggers on Every Push

GitHub Actions workflows trigger automatically on events like push or pull_request. Push to main, and your deployment starts within seconds. No webhooks to configure. No polling scripts. It just works.

Built-in Secrets Management

GitHub Secrets store your SSH keys, environment variables, and API tokens securely. They're encrypted at rest and only exposed to workflow runs. No plaintext credentials in your repository or server.

Prerequisites

Before we start, make sure you have:

  • A VPS — Any provider works: DigitalOcean, Hetzner, Vultr, Linode, AWS EC2, etc. Ubuntu 22.04+ recommended.
  • SSH access to your VPS with a non-root user that has sudo privileges
  • A GitHub repository with your application code
  • Docker installed on your VPS (we'll cover this if you don't have it)

If you don't have Docker installed yet, run this on your VPS:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Log out and back in for group changes to take effect

Step 1: Set Up Your GitHub Actions Workflow

Let's start with a basic workflow that deploys on every push to main. Create a file at .github/workflows/deploy.yml in your repository:

name: Deploy to VPS

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

jobs:
  deploy:
    runs-on: ubuntu-latest

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

      - name: Deploy to VPS
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USERNAME }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /var/www/myapp
            git pull origin main
            npm install
            npm run build
            pm2 restart myapp

This workflow does the basics: SSH into your server, pull the latest code, install dependencies, build, and restart the app with PM2. But we can do much better.

Step 2: Configure SSH Deployment Securely

The workflow above requires three secrets. Let's set them up properly.

Generate a Dedicated SSH Key Pair

Never use your personal SSH key for CI/CD. Generate a dedicated key pair for deployments:

# On your local machine
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy

# This creates:
# ~/.ssh/github_actions_deploy     (private key)
# ~/.ssh/github_actions_deploy.pub (public key)

Add the Public Key to Your VPS

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

# Or manually append to authorized_keys:
cat ~/.ssh/github_actions_deploy.pub | ssh user@your-vps-ip "cat >> ~/.ssh/authorized_keys"

Add Secrets to GitHub

Go to your repository on GitHub, then Settings → Secrets and variables → Actions. Add these secrets:

  • VPS_HOST: Your server's IP address or domain
  • VPS_USERNAME: The SSH username (e.g., deploy or ubuntu)
  • VPS_SSH_KEY: The entire contents of your private key file (~/.ssh/github_actions_deploy)
GitHub Actions workflow configuration with SSH deployment secrets

Security Best Practices

  • Use a dedicated deploy user — Create a deploy user with limited permissions instead of using root
  • Restrict the SSH key — In authorized_keys, you can limit what the key can do:
# In ~/.ssh/authorized_keys on your VPS:
command="/home/deploy/deploy.sh",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA... github-actions-deploy
  • Use GitHub Environments — For production deployments, require approvals before the workflow runs

Step 3: Docker Build and Push to Registry

Building on the VPS is fine for small apps, but Docker images are better for production. They're portable, versioned, and enable zero-downtime deployments.

Here's a workflow that builds a Docker image and pushes it to GitHub Container Registry (GHCR):

name: Build and Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

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

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

    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}

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

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to 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
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:cache,mode=max

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

    steps:
      - name: Deploy to VPS
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USERNAME }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            # Log in to GHCR
            echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin

            # Pull the new image
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

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

            # Start new container
            docker run -d \
              --name myapp \
              --restart unless-stopped \
              -p 3000:3000 \
              --env-file /home/deploy/.env.myapp \
              ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

            # Clean up old images
            docker image prune -f

Key features of this workflow:

  • Docker layer caching — Builds are fast because unchanged layers are cached in the registry
  • Git SHA tags — Each image is tagged with its commit SHA for easy rollbacks
  • Automatic cleanup — Old images are pruned to save disk space

Sample Multi-Stage Dockerfile

If you don't have a Dockerfile yet, here's a production-ready example for a Next.js application:

# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Production
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production

# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Copy built assets
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000
ENV PORT=3000

CMD ["node", "server.js"]

Need a Dockerfile for your specific framework? Use our GitHub Actions Generator or Dockerfile Generator to create one automatically.

Step 4: Zero-Downtime Deployment Script

The basic deployment above has a problem: there's a brief period where your app is unavailable between stopping the old container and starting the new one. For production apps, you need zero-downtime deployment.

The solution is a blue-green deployment: start the new container alongside the old one, verify it's healthy, then switch traffic over.

Create a deployment script on your VPS at /home/deploy/deploy.sh:

#!/bin/bash
set -e

APP_NAME="myapp"
IMAGE="ghcr.io/your-username/your-repo:latest"
PORT=3000
HEALTH_ENDPOINT="/"
MAX_HEALTH_CHECKS=10
HEALTH_CHECK_INTERVAL=3

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color

log() {
    echo -e "${GREEN}[DEPLOY]${NC} $1"
}

error() {
    echo -e "${RED}[ERROR]${NC} $1"
    exit 1
}

# Pull the latest image
log "Pulling latest image..."
docker pull "$IMAGE"

# Find an available port for staging
STAGING_PORT=$((PORT + 1000))

# Start staging container
log "Starting staging container on port $STAGING_PORT..."
docker run -d \
    --name "${APP_NAME}-staging" \
    --restart unless-stopped \
    -p "${STAGING_PORT}:3000" \
    --env-file "/home/deploy/.env.${APP_NAME}" \
    "$IMAGE"

# Wait for container to be ready
log "Waiting for container to start..."
sleep 5

# Health check
log "Running health checks..."
HEALTHY=false
for i in $(seq 1 $MAX_HEALTH_CHECKS); do
    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${STAGING_PORT}${HEALTH_ENDPOINT}" || echo "000")

    if [ "$HTTP_CODE" = "200" ]; then
        HEALTHY=true
        log "Health check passed ($i/$MAX_HEALTH_CHECKS)"
        break
    else
        log "Health check attempt $i/$MAX_HEALTH_CHECKS (HTTP $HTTP_CODE)"
        sleep $HEALTH_CHECK_INTERVAL
    fi
done

if [ "$HEALTHY" = false ]; then
    error "Health checks failed. Rolling back..."
    docker stop "${APP_NAME}-staging" || true
    docker rm "${APP_NAME}-staging" || true
    exit 1
fi

# Stop old production container
log "Stopping old production container..."
docker stop "${APP_NAME}" 2>/dev/null || true
docker rm "${APP_NAME}" 2>/dev/null || true

# Rename staging to production
log "Promoting staging to production..."
docker stop "${APP_NAME}-staging"
docker rm "${APP_NAME}-staging"

# Start fresh on the production port
docker run -d \
    --name "${APP_NAME}" \
    --restart unless-stopped \
    -p "${PORT}:3000" \
    --env-file "/home/deploy/.env.${APP_NAME}" \
    "$IMAGE"

# Final health check
sleep 3
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}${HEALTH_ENDPOINT}" || echo "000")

if [ "$HTTP_CODE" = "200" ]; then
    log "Deployment successful! App is live on port $PORT"
else
    error "Final health check failed (HTTP $HTTP_CODE). Manual intervention required."
fi

# Cleanup old images
log "Cleaning up old images..."
docker image prune -f

log "Done!"

Make the script executable:

chmod +x /home/deploy/deploy.sh

Update your workflow to use the script:

- name: Deploy to VPS
  uses: appleboy/[email protected]
  with:
    host: ${{ secrets.VPS_HOST }}
    username: ${{ secrets.VPS_USERNAME }}
    key: ${{ secrets.VPS_SSH_KEY }}
    script: |
      echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
      /home/deploy/deploy.sh

Step 5: Rollback Strategy

Things go wrong. A bad deploy can take down your production app. You need a fast, reliable way to roll back to the previous version.

Automatic Rollback on Failure

The deployment script above already handles this: if health checks fail, it removes the staging container and keeps the old production container running. But what if the new container passes health checks but has a bug that surfaces later?

Manual Rollback Workflow

Create a separate workflow for manual rollbacks. This lets you quickly revert to any previous version:

name: Rollback

on:
  workflow_dispatch:
    inputs:
      commit_sha:
        description: 'Git commit SHA to rollback to'
        required: true
        type: string

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

jobs:
  rollback:
    runs-on: ubuntu-latest

    steps:
      - name: Rollback to previous version
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USERNAME }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.commit_sha }}"

            echo "Rolling back to $IMAGE"

            # Log in and pull the specific version
            echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
            docker pull "$IMAGE"

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

            # Start the rollback version
            docker run -d \
              --name myapp \
              --restart unless-stopped \
              -p 3000:3000 \
              --env-file /home/deploy/.env.myapp \
              "$IMAGE"

            echo "Rollback complete. Running version: ${{ github.event.inputs.commit_sha }}"

To rollback, go to Actions → Rollback → Run workflow and enter the commit SHA you want to deploy. Since every build is tagged with its SHA, you can roll back to any version that's still in the registry.

Keep Multiple Versions in the Registry

By default, the workflow tags images with both latest and the commit SHA. This means you always have a history of deployable versions. To prevent storage costs from growing indefinitely, set up a retention policy:

# Add to your build job:
- name: Delete old package versions
  uses: actions/delete-package-versions@v5
  with:
    package-name: 'your-app-name'
    package-type: 'container'
    min-versions-to-keep: 10
    delete-only-untagged-versions: 'true'

Example Workflows for Common Scenarios

Node.js Deployment (Without Docker)

If you're not using Docker, here's a workflow that deploys a Node.js app directly with PM2:

name: Deploy Node.js App

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USERNAME }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /var/www/myapp

            # Fetch latest changes
            git fetch origin main
            git reset --hard origin/main

            # Install dependencies
            npm ci --only=production

            # Build the app
            npm run build

            # Reload with PM2 (zero-downtime)
            pm2 reload myapp --update-env

            # Save PM2 process list
            pm2 save

Docker Compose Deployment

For apps with multiple services (app + database + cache), use Docker Compose:

name: Deploy with Docker Compose

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

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

      - name: Copy files to VPS
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USERNAME }}
          key: ${{ secrets.VPS_SSH_KEY }}
          source: "docker-compose.yml,docker-compose.prod.yml"
          target: "/home/deploy/myapp"

      - name: Deploy
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USERNAME }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /home/deploy/myapp

            # Pull latest images
            docker compose -f docker-compose.yml -f docker-compose.prod.yml pull

            # Deploy with zero downtime
            docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans

            # Clean up
            docker image prune -f

Multi-Stage Build with Testing

Run tests before deploying to catch issues early:

name: Test, Build, and Deploy

on:
  push:
    branches: [main]
  pull_request:
    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

      - name: Run type check
        run: npm run type-check

  build:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    # ... build steps from earlier

  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    # ... deploy steps from earlier

Server Compass Auto-Deploy Feature

Everything above works great, but it's a lot of YAML to write and maintain. If you'd rather skip the manual setup, Server Compass's Auto-Deploy feature generates production-ready GitHub Actions workflows automatically.

Server Compass auto-deploy feature showing automated GitHub Actions deployment

Here's what Server Compass handles for you:

  • Workflow generation — Creates a complete .github/workflows file based on your framework (Next.js, Django, Go, etc.)
  • Dockerfile generation — Auto-detects your framework and generates an optimized multi-stage Dockerfile
  • SSH key setup — Generates an Ed25519 key pair, adds the public key to your VPS, and stores the private key as a GitHub Secret
  • Secret encryption — Encrypts your environment variables with libsodium sealed boxes before storing them in GitHub Secrets
  • Zero-downtime deployment — Built-in blue-green deployment with health checks
  • Real-time tracking — Watch your deployment progress without switching to GitHub

The setup takes about 60 seconds: connect your GitHub account, select a repository, and deploy. Server Compass commits the workflow file to your repo and triggers the first deployment automatically.

Read the full deep-dive: How Server Compass Uses GitHub Actions for Zero-Downtime VPS Deployments

Comparison: GitHub Actions vs Vercel/Netlify Builds

How does a self-hosted CI/CD pipeline compare to managed platforms? Let's break it down.

FeatureGitHub Actions + VPSVercelNetlify
Build minutes (free)2,000/month6,000/month300/month
Build minutes (paid)$0.008/min$0.01/min$0.02/min
Concurrent builds20 (free tier)1 (free), 12 (Pro)1 (free), 3 (Pro)
Custom domainsUnlimited50100
Server-side codeFull controlServerless onlyServerless only
Database accessDirect (same server)External onlyExternal only
Persistent storageYes (disk)NoNo
WebSocketsYesLimitedNo
Long-running processesYesNo (10s limit)No (10s limit)
Vendor lock-inNoneMedium-HighMedium

When to Choose GitHub Actions + VPS

  • You need full server control (databases, cron jobs, WebSockets)
  • You want to avoid per-seat pricing ($20/user/month on Vercel)
  • Your app has long-running processes or background workers
  • You need persistent file storage
  • You want to run multiple apps on one server
  • You're cost-conscious and willing to manage infrastructure

When Vercel/Netlify Might Be Better

  • You're building a static site or JAMstack app
  • You need global edge deployment out of the box
  • You don't want to manage any infrastructure
  • Your team is small and cost isn't a concern

Frequently Asked Questions

How many free GitHub Actions minutes do I actually get?

2,000 minutes/month for private repositories on the free tier. Public repositories get unlimited minutes. If you need more, GitHub Pro includes 3,000 minutes, and Team/Enterprise plans include more.

Can I use my VPS as a GitHub Actions runner?

Yes! You can install a self-hosted runner on your VPS. This gives you unlimited minutes but uses your server's resources for builds. Best for powerful servers (4+ GB RAM) or when you need access to local services during the build.

How do I handle environment variables securely?

Store sensitive values in GitHub Secrets. For runtime env vars, create a .env file on your VPS (e.g., /home/deploy/.env.myapp) and reference it with --env-file when running Docker containers.

How do I run database migrations during deployment?

Add a migration step to your deployment script:

# Before starting the new container:
docker run --rm \
  --env-file /home/deploy/.env.myapp \
  $IMAGE \
  npm run migrate

Can I deploy multiple apps to the same VPS?

Absolutely. Each app gets its own container, its own port, and its own env file. Use a reverse proxy like Nginx or Traefik to route traffic based on domain names. Server Compass includes built-in Traefik integration for automatic routing and SSL.

How do I monitor deployment status?

GitHub Actions provides real-time logs in the Actions tab. For notifications, add a Slack or Discord step to your workflow:

- name: Notify Slack
  if: always()
  uses: 8398a7/action-slack@v3
  with:
    status: ${{ job.status }}
    fields: repo,message,commit,author
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

What happens if I push twice quickly?

By default, both deployments run in parallel, which can cause issues. Add concurrency settings to cancel in-progress runs:

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

Conclusion

Self-hosted CI/CD with GitHub Actions and your VPS is a powerful combination. You get the convenience of automatic deployments, the flexibility of full server control, and significant cost savings compared to managed platforms.

The key components:

  1. GitHub Actions workflows for build automation and deployment triggers
  2. Secure SSH deployment with dedicated keys stored in GitHub Secrets
  3. Docker images for portable, versioned deployments
  4. Blue-green deployment for zero downtime
  5. Rollback strategy with SHA-tagged images

If you want to skip the manual setup, try Server Compass. It generates production-ready GitHub Actions workflows, handles SSH key management, encrypts your secrets, and gives you a visual interface for managing deployments. One-time purchase, no subscription.

Related resources:

Related reading