Setting Up a CI/CD Pipeline on a Budget VPS with GitHub Actions

A CI/CD pipeline automates testing, building, and deploying your application — but many developers assume you need a paid CI service or a second server to run one. In reality, you can set up a complete pipeline using GitHub Actions (free for public repos, 2,000 minutes/month for private repos) that deploys directly to your single VPS. This tutorial shows how to build a GitHub Actions workflow that tests your code, builds a Docker image, deploys it to your VPS via SSH, and runs the new container — all without any additional infrastructure costs.

Prerequisites

  • A Linux VPS with Docker and Docker Compose installed
  • Your application code in a GitHub repository
  • SSH key-based access to your VPS (passwordless)
  • A Dockerfile and docker-compose.yml for your application (see our companion guide on containerizing a Node.js app)

If you need a VPS for this setup, compare budget-friendly VPS plans on our table. A 2 GB RAM VPS is sufficient for most small-to-medium applications.

Step 1: Prepare Your VPS for Automated Deployments

First, create a dedicated directory on your VPS to hold the application code and Docker Compose files:

# On your VPS
mkdir -p /opt/myapp
cd /opt/myapp

# Create a directory for the deployment script
mkdir -p /opt/deploy

Create a deployment script that GitHub Actions will call via SSH. This script pulls the latest code, rebuilds the Docker image, and restarts the container:

#!/bin/bash
# /opt/deploy/deploy.sh
set -e

APP_DIR="/opt/myapp"
REPO_URL="https://github.com/your-org/your-repo.git"
BRANCH="main"

echo "[$(date)] Starting deployment..."

# Clone or pull the latest code
if [ -d "$APP_DIR/.git" ]; then
  cd "$APP_DIR"
  git pull origin "$BRANCH"
else
  git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
  cd "$APP_DIR"
fi

# Build and restart containers
docker compose build
docker compose up -d --no-deps

# Clean up old images
docker image prune -f

echo "[$(date)] Deployment complete."

Make the script executable:

chmod +x /opt/deploy/deploy.sh

Step 2: Generate a Deploy SSH Key

GitHub Actions needs an SSH key to connect to your VPS. Generate a dedicated key pair for deployments:

# On your local machine (not the VPS)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github-actions-deploy -N ""

Add the public key to your VPS’s authorized keys:

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

# Or manually append the public key to ~/.ssh/authorized_keys on the VPS

Test the connection:

ssh -i ~/.ssh/github-actions-deploy user@your-vps-ip "echo 'SSH connection works'"

Step 3: Add Repository Secrets to GitHub

Go to your GitHub repository → SettingsSecrets and variablesActionsNew repository secret. Add these secrets:

  • VPS_HOST — Your VPS IP address (e.g., 192.168.1.100)
  • VPS_USER — The SSH username on your VPS (e.g., ubuntu or deploy)
  • VPS_SSH_KEY — The entire contents of the github-actions-deploy private key file (including the -----BEGIN OPENSSH PRIVATE KEY----- header)
  • VPS_KNOWN_HOSTS — The fingerprint of your VPS host key. Get it by running: ssh-keyscan -t ed25519 your-vps-ip on your local machine

Step 4: Create the GitHub Actions Workflow

Create a workflow file at .github/workflows/deploy.yml in your repository:

name: Deploy to VPS

on:
  push:
    branches: [ main ]

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

      - name: Use Node.js 20
        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: success()
    steps:
      - uses: actions/checkout@v4

      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          echo "${{ secrets.VPS_KNOWN_HOSTS }}" > ~/.ssh/known_hosts

      - name: Deploy to VPS
        run: |
          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
            "cd /opt/myapp && \
             git pull origin main && \
             docker compose build && \
             docker compose up -d --no-deps && \
             docker image prune -f"

      - name: Verify deployment
        run: |
          sleep 10
          curl -s -o /dev/null -w "%{http_code}" \
            http://${{ secrets.VPS_HOST }}:3000/health

This workflow does two things:

  • test job — Runs on every push to main. Checks out code, installs dependencies, runs the linter, and runs tests.
  • deploy job — Only runs if tests pass. Connects to the VPS via SSH, pulls the latest code, rebuilds the Docker image, and restarts the container with zero-downtime flags.

Step 5: Push and Test the Pipeline

Commit the workflow file and push to your repository:

git add .github/workflows/deploy.yml
git commit -m "Add CI/CD deployment workflow"
git push origin main

Go to your repository on GitHub → Actions tab. You should see the workflow running. Click into it to watch the test and deploy steps execute in real time.

Step 6: Add a Health Check and Rollback

A production CI/CD pipeline needs rollback capability. Add a Docker Compose label to track versions, and a rollback script:

# In docker-compose.yml
services:
  app:
    labels:
      - "app.version=${GIT_COMMIT:-unknown}"

Create a rollback script on your VPS:

#!/bin/bash
# /opt/deploy/rollback.sh
# Usage: ./rollback.sh [commit-hash]

COMMIT=$1
if [ -z "$COMMIT" ]; then
  echo "Usage: $0 "
  exit 1
fi

cd /opt/myapp
git checkout "$COMMIT"
docker compose build
docker compose up -d --no-deps
echo "Rolled back to $COMMIT"

Step 7: Secure the Pipeline

Security considerations for VPS-based CI/CD:

  • Use a dedicated deploy user — Create a user on your VPS with minimal permissions: sudo adduser --disabled-password deploy. Only grant it access to /opt/myapp and the Docker socket.
  • Restrict SSH access — Limit the deploy user to running only the deployment script via authorized_keys command restriction: command="/opt/deploy/deploy.sh",no-agent-forwarding,no-port-forwarding,no-pty
  • Never store secrets in the repository — Use GitHub Secrets for all sensitive values.
  • Use a private repository — If your code is proprietary, keep the repository private. GitHub Actions offers 2,000 free minutes per month for private repos.
  • Monitor disk usage — Docker images accumulate on the VPS. Add docker image prune -f to your deployment script to clean up old images.

Troubleshooting Common Issues

  • SSH connection refused: Check that your VPS firewall allows SSH (port 22). Verify the VPS is running with sudo systemctl status ssh.
  • Permission denied (publickey): The private key in GitHub Secrets may be incorrect or the public key is not in ~/.ssh/authorized_keys on the VPS.
  • Host key verification failed: The VPS_KNOWN_HOSTS secret is incorrect. Re-run ssh-keyscan -t ed25519 your-vps-ip and copy the exact output.
  • Docker command not found: The SSH user on the VPS may not have Docker permissions. Add the user to the docker group: sudo usermod -aG docker deploy.
  • Tests passing but deploy failing: Check the VPS has enough disk space (df -h). Docker builds can consume several GB of temporary space.

Going Further: Slack Notifications

Add a Slack notification step to your workflow so your team knows when deployments succeed or fail:

      - name: Notify Slack on success
        if: success()
        uses: slackapi/[email protected]
        with:
          payload: |
            {
              "text": "✅ Deployment successful! ${{ github.repository }}@${{ github.sha }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

      - name: Notify Slack on failure
        if: failure()
        uses: slackapi/[email protected]
        with:
          payload: |
            {
              "text": "❌ Deployment failed! ${{ github.repository }}@${{ github.sha }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Setting up CI/CD on a budget VPS with GitHub Actions gives you professional-grade deployment automation without any additional infrastructure costs. The same pipeline works for Node.js, Python, Go, Rust, or any language that runs in Docker. For more VPS deployment guides and provider comparisons, visit the main site.

Leave a Reply