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:
- Code push to
maintriggers the workflow - GitHub Actions builds a Docker image and pushes it to a registry (GHCR)
- The workflow SSHs to your server using a deploy key
- On the server:
docker compose pullpulls the new image,docker compose up -drestarts with zero downtime (rolling update) - A health check confirms the new version is running
- 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 ofdeploy_key(private key)DEPLOY_HOST— your server's IP or hostnameDEPLOY_USER—deploy
Also add registry credentials if using a private registry:
REGISTRY_TOKEN— GitHub Personal Access Token withwrite:packagesscope (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:
docker compose pull— downloads the new image in the background while the old container runsdocker compose up -d— starts the new container, then stops and removes the old one- 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 →