GUIDE, WITHOUT THE GUESSWORK

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 NameValue
VPS_HOSTYour VPS IP address
VPS_USERdeploy (or your deploy user)
VPS_SSH_KEYContents of ~/.ssh/deploy_key (private key)
VPS_PORT22 (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:

Cons:

Strategy 2: Docker image via registry (recommended)

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

Set up GitHub Container Registry

Add another secret to your repository:

Secret NameValue
GHCR_TOKENYour 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:

Cons:

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

Permission denied (publickey)

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

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.

From across the StoicSoft network

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