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.

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 effectStep 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 myappThis 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 domainVPS_USERNAME: The SSH username (e.g.,deployorubuntu)VPS_SSH_KEY: The entire contents of your private key file (~/.ssh/github_actions_deploy)

Security Best Practices
- Use a dedicated deploy user — Create a
deployuser with limited permissions instead of usingroot - 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 -fKey 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.shUpdate 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.shStep 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 saveDocker 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 -fMulti-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 earlierServer 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.

Here's what Server Compass handles for you:
- Workflow generation — Creates a complete
.github/workflowsfile 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.
| Feature | GitHub Actions + VPS | Vercel | Netlify |
|---|---|---|---|
| Build minutes (free) | 2,000/month | 6,000/month | 300/month |
| Build minutes (paid) | $0.008/min | $0.01/min | $0.02/min |
| Concurrent builds | 20 (free tier) | 1 (free), 12 (Pro) | 1 (free), 3 (Pro) |
| Custom domains | Unlimited | 50 | 100 |
| Server-side code | Full control | Serverless only | Serverless only |
| Database access | Direct (same server) | External only | External only |
| Persistent storage | Yes (disk) | No | No |
| WebSockets | Yes | Limited | No |
| Long-running processes | Yes | No (10s limit) | No (10s limit) |
| Vendor lock-in | None | Medium-High | Medium |
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 migrateCan 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: trueConclusion
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:
- GitHub Actions workflows for build automation and deployment triggers
- Secure SSH deployment with dedicated keys stored in GitHub Secrets
- Docker images for portable, versioned deployments
- Blue-green deployment for zero downtime
- 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:
- GitHub Actions Workflow Generator — Create custom workflows interactively
- Dockerfile Generator — Generate optimized Dockerfiles for your framework
- Auto-Deploy Feature — Automatic deployments on every git push
- How Server Compass Uses GitHub Actions — Deep dive into the implementation
Related reading