Guide
Updated 35 min read

GitHub Actions Deploy to VPS: Complete CI/CD Guide

Set up automated deployments from GitHub to your VPS using GitHub Actions with SSH, Docker, and zero-downtime strategies.

GitHub Actions Deploy to VPS: Complete CI/CD Guide

Set up automated deployments from GitHub to your VPS. Push to main, and your changes go live automatically.

Overview

This guide covers three deployment strategies:

  1. SSH and pull - Simple, good for small projects
  2. Docker image via registry - Better for production
  3. Zero-downtime with health checks - Best for critical apps

Pick the one that matches your needs.

Prerequisites on your VPS

Ensure Docker is installed:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER

Create a deploy user (recommended over using root):

sudo adduser deploy
sudo usermod -aG docker deploy
sudo usermod -aG sudo deploy

Step 1: Set up SSH key authentication

On your local machine, generate a deployment key:

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

Copy the public key to your VPS:

ssh-copy-id -i ~/.ssh/deploy_key.pub deploy@YOUR_VPS_IP

Test the connection:

ssh -i ~/.ssh/deploy_key deploy@YOUR_VPS_IP "echo 'Connection successful'"

Step 2: Add secrets to GitHub

Go to your repository on GitHub:

  1. Settings > Secrets and variables > Actions
  2. Add these repository secrets:
Secret Name Value
VPS_HOST Your VPS IP address
VPS_USER deploy (or your deploy user)
VPS_SSH_KEY Contents of ~/.ssh/deploy_key (private key)
VPS_PORT 22 (or your SSH port)

To copy the private key:

cat ~/.ssh/deploy_key

Copy everything including the -----BEGIN and -----END lines.

Strategy 1: SSH and pull (simplest)

This strategy SSHes into your VPS, pulls the latest code, and rebuilds.

Create .github/workflows/deploy.yml:

name: Deploy to VPS

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_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            cd ~/apps/your-app
            git pull origin main
            docker compose down
            docker compose up -d --build
            docker image prune -f

Pros:

  • Simple setup
  • No registry needed
  • Good for small projects

Cons:

  • Build happens on VPS (uses VPS resources)
  • Downtime during rebuild
  • Slower for large builds

Build on GitHub, push to a registry, pull on VPS.

Set up GitHub Container Registry

Add another secret to your repository:

Secret Name Value
GHCR_TOKEN Your GitHub Personal Access Token with write:packages scope

Or use the automatic GITHUB_TOKEN (simpler).

Create the workflow

Create .github/workflows/deploy.yml:

name: Build and Deploy

on:
  push:
    branches:
      - main

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 repository
        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 }}
          cache-from: type=gha
          cache-to: type=gha,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_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            cd ~/apps/your-app

            # Pull the latest image
            docker pull ghcr.io/${{ github.repository }}:latest

            # Update the running container
            docker compose down
            docker compose up -d

            # Clean up old images
            docker image prune -f

Update your docker-compose.yml on the VPS to use the registry image:

services:
  app:
    image: ghcr.io/yourusername/your-repo:latest
    restart: unless-stopped
    # ... rest of your config

Log in to GHCR on your VPS (one-time setup):

echo $GITHUB_TOKEN | docker login ghcr.io -u yourusername --password-stdin

Pros:

  • Build happens on GitHub runners (free)
  • Faster deploys (just pull, no build)
  • Versioned images for rollbacks

Cons:

  • More setup
  • Requires registry access on VPS

Strategy 3: Zero-downtime deployment

Use Docker Compose with health checks and rolling updates.

Update docker-compose.yml

services:
  app:
    image: ghcr.io/yourusername/your-repo:latest
    restart: unless-stopped
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        failure_action: rollback
      rollback_config:
        parallelism: 1
        delay: 10s
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 40s
    labels:
      - traefik.enable=true
      - traefik.http.routers.app.rule=Host(`yourdomain.com`)
      - traefik.http.routers.app.tls=true
      - traefik.http.routers.app.tls.certresolver=letsencrypt
      - traefik.http.services.app.loadbalancer.server.port=3000

Add a health endpoint to your app:

// Express example
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok' });
});

Update the workflow

name: Zero-Downtime Deploy

on:
  push:
    branches:
      - main

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

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

    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

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

    steps:
      - name: Zero-downtime deploy
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            cd ~/apps/your-app

            # Pull new image
            docker pull ghcr.io/${{ github.repository }}:${{ github.sha }}

            # Tag as latest
            docker tag ghcr.io/${{ github.repository }}:${{ github.sha }} \
                       ghcr.io/${{ github.repository }}:latest

            # Rolling update
            docker compose up -d --no-deps --scale app=2 app

            # Wait for health checks
            sleep 30

            # Remove old containers
            docker compose up -d --no-deps --scale app=1 app

            # Cleanup
            docker image prune -f

      - name: Verify deployment
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            curl -f https://yourdomain.com/health || exit 1

Running tests before deploy

Add a test job:

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

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

      - run: npm ci
      - run: npm test
      - run: npm run lint

  build:
    needs: test
    # ... rest of build job

Environment-specific deploys

Deploy to staging on PR, production on main:

name: Deploy

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

jobs:
  deploy-staging:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Deploy to staging
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd ~/apps/your-app-staging
            docker compose pull
            docker compose up -d

  deploy-production:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy to production
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd ~/apps/your-app
            docker compose pull
            docker compose up -d

Deployment notifications

Add Slack or Discord notifications:

  notify:
    needs: deploy
    runs-on: ubuntu-latest
    if: always()
    steps:
      - name: Notify on success
        if: needs.deploy.result == 'success'
        uses: slackapi/[email protected]
        with:
          payload: |
            {
              "text": "Deployed ${{ github.repository }} to production",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Deployed to production*\nRepo: ${{ github.repository }}\nCommit: ${{ github.sha }}"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

      - name: Notify on failure
        if: needs.deploy.result == 'failure'
        uses: slackapi/[email protected]
        with:
          payload: |
            {
              "text": "Deployment failed for ${{ github.repository }}",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Deployment failed*\nRepo: ${{ github.repository }}\nCheck: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Rollback strategy

Keep the previous image for quick rollbacks:

# On VPS, before deploying
docker tag ghcr.io/you/app:latest ghcr.io/you/app:previous

# To rollback
docker tag ghcr.io/you/app:previous ghcr.io/you/app:latest
docker compose up -d

Or use specific commit SHA tags:

# Rollback to specific version
docker pull ghcr.io/you/app:abc1234
docker tag ghcr.io/you/app:abc1234 ghcr.io/you/app:latest
docker compose up -d

Troubleshooting

SSH connection refused

  • Verify the IP address is correct
  • Check SSH is running: sudo systemctl status ssh
  • Check firewall: sudo ufw status
  • Verify the key was added to authorized_keys

Permission denied (publickey)

  • Ensure the private key in GitHub Secrets includes all lines
  • Check key permissions on VPS: chmod 600 ~/.ssh/authorized_keys
  • Verify the username matches

Docker pull fails on VPS

Log in to the registry:

echo $GITHUB_TOKEN | docker login ghcr.io -u yourusername --password-stdin

Check the image visibility (must be public or you need auth).

Workflow never triggers

  • Check the branch name matches exactly
  • Verify the workflow file is in .github/workflows/
  • Check the YAML syntax is valid

Build succeeds but deploy fails

Check the SSH script output in GitHub Actions logs. Common issues:

Where to go next

Tutorials:

Comparisons:

ServerCompass:


Related in the StoicSoft network

If you work in AI-assisted coding, shared terminal sessions, or agent-driven shell workflows like the ones above, 1devtool is the StoicSoft network's tool for safer AI-assisted terminal work — shared sessions with auditing, preflight policy, and tiered model routing built in.

github-actions
ci-cd
deployment
vps
docker
automation

From across the StoicSoft network

Hand-curated reads on the same topic from sister sites in the StoicSoft family.

Keep reading

Guide
8 min

Automation builders need VPS sizing checks before moving n8n and databases out of Docker Desktop

Fresh n8n threads show small automation builders leaving local Docker Desktop and immediately hitting provider and resource-sizing uncertainty. One user wants to run n8n, Qdrant, Postgres, and Gotenberg on a production-like VPS without overpaying or…

Guide
7 min

Leaving SaaS for a One-Box VPS? Launch It Behind These Guardrails

Choosing root access and a one-box Docker stack to escape SaaS is the easy part. The launch checklist — provider, backups, deploys, monitoring — is what keeps the box from becoming a liability.

Guide
30 min

VPS Backup Strategy: Complete Guide

Implement reliable backups for your VPS including Docker volumes, databases, and application data with automated scheduling and offsite storage.

Guide
8 min

First-time self-hosted app launches need preflight maps before Docker, hosting plans, and per-client stacks

Fresh Reddit threads show beginners and small freelancers getting blocked before the application itself is the hard part. One Immich beginner wants to run it on Windows but gets stuck at Docker prerequisites. A Next.js freelancer is planning a separate app…

Guide
8 min

Small teams need a safe sequence for turning manual server edits into deployable operations

Fresh webdev and DevOps threads show small teams outgrowing hand-maintained servers. One new DevOps hire inherited Laravel and Node apps where .env files, Nginx configs, dependencies, background jobs, secrets, database changes, and logs all live partly…

Guide
6 min

The Smallest-Viable Production Runbook for a Web App

You were hired to write the frontend and inherited Docker, DNS, SSL, secrets, the database, and the pager. Here are the five questions every deployed web app has to answer — written down once so you stop rediscovering them during incidents.