GitHub Actions to Any VPS: The Zero-Downtime Deployment Template (2025)

on: push to main
Build Docker image
Push to registry
SSH to VPS
docker compose pull && up -d
Health check ✅
→ rollback on failure ↩

Every team should have a one-command deploy pipeline. This post gives you a complete, battle-tested GitHub Actions workflow that deploys to any Linux server — VeloxaHost, DigitalOcean, Hetzner, bare metal, anything with SSH — with zero downtime and automatic rollback on failure.

💡

The workflow uses Docker Compose on the server side. If you're not using Docker yet, check out Docker Compose in Production first.

The Deployment Architecture

Here's what we're building:

  1. Code push to main triggers the workflow
  2. GitHub Actions builds a Docker image and pushes it to a registry (GHCR)
  3. The workflow SSHs to your server using a deploy key
  4. On the server: docker compose pull pulls the new image, docker compose up -d restarts with zero downtime (rolling update)
  5. A health check confirms the new version is running
  6. If the health check fails, the workflow SSHs back and rolls back to the previous image

Step 1 — One-Time Server Setup

Create a deploy user

# On your server — create a restricted deploy user
sudo adduser deploy --disabled-password
sudo usermod -aG docker deploy   # Allow Docker commands without sudo

# Create SSH directory
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh

Generate a deploy key pair (on your local machine)

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key -N ""
# Creates: deploy_key (private) + deploy_key.pub (public)

Add the public key to the server

# Copy the public key content to the server
cat deploy_key.pub | ssh your-server "sudo tee -a /home/deploy/.ssh/authorized_keys"
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh

Add the private key to GitHub Secrets

In your GitHub repo: Settings → Secrets and variables → Actions → New secret

  • DEPLOY_KEY — paste the full contents of deploy_key (private key)
  • DEPLOY_HOST — your server's IP or hostname
  • DEPLOY_USERdeploy

Also add registry credentials if using a private registry:

  • REGISTRY_TOKEN — GitHub Personal Access Token with write:packages scope (for GHCR)

Step 2 — Project Setup

Your repo needs a docker-compose.yml at the root. The workflow will copy it to the server on first run.

# docker-compose.yml — production version
services:
  app:
    image: ghcr.io/YOUR_ORG/YOUR_REPO:latest
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s
    deploy:
      resources:
        limits:
          memory: 512M

Step 3 — The Complete GitHub Actions Workflow

Create .github/workflows/deploy.yml:

name: Build & Deploy

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

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

jobs:
  build-and-push:
    name: Build Docker Image
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}

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

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.REGISTRY_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

      - 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:
    name: Deploy to Server
    needs: build-and-push
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Checkout (for compose file)
        uses: actions/checkout@v4

      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts

      - name: Copy docker-compose.yml to server
        run: |
          scp -i ~/.ssh/deploy_key \
            docker-compose.yml \
            ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:~/app/docker-compose.yml

      - name: Deploy
        id: deploy
        run: |
          ssh -i ~/.ssh/deploy_key \
            ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
            "cd ~/app && \
             echo '${{ secrets.REGISTRY_TOKEN }}' | \
               docker login ghcr.io -u ${{ github.actor }} --password-stdin && \
             docker compose pull && \
             docker compose up -d --remove-orphans && \
             docker image prune -f"

      - name: Health check
        id: health
        run: |
          echo "Waiting 20s for service to become healthy..."
          sleep 20
          ssh -i ~/.ssh/deploy_key \
            ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
            "docker compose -f ~/app/docker-compose.yml ps --format json | \
             python3 -c \"
          import sys, json
          services = [json.loads(l) for l in sys.stdin if l.strip()]
          unhealthy = [s for s in services if s.get('Health','') not in ('healthy','')]
          if unhealthy:
              print('UNHEALTHY:', unhealthy)
              sys.exit(1)
          print('All services healthy')
          \""

      - name: Rollback on failure
        if: failure() && steps.deploy.outcome == 'success'
        run: |
          echo "Health check failed — rolling back..."
          ssh -i ~/.ssh/deploy_key \
            ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
            "cd ~/app && docker compose up -d --scale app=0 && \
             docker tag \$(docker images ghcr.io/${{ env.IMAGE_NAME }} --format '{{.ID}}' | sed -n '2p') \
               ghcr.io/${{ env.IMAGE_NAME }}:latest && \
             docker compose up -d"
          echo "::error::Deployment failed — rolled back to previous version"
          exit 1
ℹ️

The workflow_dispatch trigger lets you manually re-deploy from the GitHub Actions UI without a code push — useful for rolling back or forcing a redeploy.

How Zero-Downtime Works

Docker Compose achieves zero-downtime via rolling container replacement:

  1. docker compose pull — downloads the new image in the background while the old container runs
  2. docker compose up -d — starts the new container, then stops and removes the old one
  3. If you have a load balancer or nginx upstream, requests continue hitting the old container until the new one is ready

For true zero-downtime with nginx, add this to your nginx upstream config:

upstream app {
    server 127.0.0.1:3000;
    keepalive 32;
}

# nginx will continue serving existing connections from the old container
# while the new one starts — typically <2s gap on a fast server

Optional: Slack / Discord Notifications

      - name: Notify Slack on success
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "✅ Deployed ${{ github.sha }} to production"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Summary

Every push to main now:

  • Builds a tagged Docker image with layer caching (fast rebuilds)
  • Pushes to GHCR (free for public repos, $0 for image pulls)
  • Deploys to your server over SSH with zero downtime
  • Health checks the live deployment
  • Automatically rolls back if anything is unhealthy

See also: Docker Compose in Production: 12 Things You're Probably Missing →


Deploy to VeloxaHost — get a fresh Ubuntu 22.04 instance ready for this workflow in under 60 seconds. Start free →