Manual deployments — SSH into your server, pull the latest code, restart services — work when you're starting out, but they're error-prone, unrepeatable, and don't scale. CI/CD (Continuous Integration / Continuous Deployment) automates this: every push to your main branch triggers a pipeline that tests your code, builds it, and deploys it to your VPS without human intervention.
GitHub Actions is the most accessible CI/CD platform for projects hosted on GitHub. This guide walks through the complete setup: creating SSH deploy keys, writing workflows for rsync-based and Docker-based deployments, implementing zero-downtime rolling restarts, managing secrets and environment variables, adding build and test stages, setting up deployment notifications, and implementing rollback strategies. By the end, pushing to main will automatically and safely deploy your application to your Ubuntu VPS.
Prerequisites
Before starting, you need:
- An Ubuntu 24.04 VPS. Deploy a Cloud VPS with 2 vCPU / 2 GB RAM — sufficient for most web applications. If your deployment involves Docker builds on the VPS, consider 4 GB RAM.
- Root or sudo access. Follow our Ubuntu VPS setup guide and security hardening guide to set up a non-root deploy user.
- A GitHub repository with your application code.
- A working application deployment on the VPS (even if it's manual). This guide automates an existing deployment, not create one from scratch.
- Basic familiarity with Git and YAML.
MassiveGRID Ubuntu VPS — Ubuntu 24.04 LTS pre-installed, Proxmox HA cluster with automatic failover, Ceph 3x replicated NVMe storage, independent CPU/RAM/storage scaling, 12 Tbps DDoS protection, 4 global datacenter locations, 100% uptime SLA, and 24/7 human support rated 9.5/10. Deploy a self-managed VPS from $1.99/mo.
Setting Up SSH Deploy Keys
GitHub Actions needs SSH access to your VPS to deploy code. We'll use a dedicated SSH key pair — separate from your personal keys — so you can revoke it independently.
Generate a Deploy Key
On your local machine (not the VPS), generate a new Ed25519 key pair:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy -N ""
This creates two files:
~/.ssh/github_actions_deploy— the private key (goes into GitHub Secrets)~/.ssh/github_actions_deploy.pub— the public key (goes on the VPS)
Add the Public Key to Your VPS
Copy the public key to the deploy user's authorized keys on the VPS:
ssh-copy-id -i ~/.ssh/github_actions_deploy.pub deploy@your-server-ip
Or manually:
# On the VPS, as the deploy user
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "ssh-ed25519 AAAA...your-public-key... github-actions-deploy" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
Test the connection:
ssh -i ~/.ssh/github_actions_deploy deploy@your-server-ip "echo 'SSH connection successful'"
Add the Private Key to GitHub Secrets
GitHub Actions reads sensitive values from encrypted repository secrets.
- Go to your GitHub repository
- Navigate to Settings > Secrets and variables > Actions
- Click New repository secret
- Add these secrets:
| Secret Name | Value |
|---|---|
SSH_PRIVATE_KEY |
Contents of ~/.ssh/github_actions_deploy (the entire private key file) |
SSH_HOST |
Your VPS IP address (e.g., 203.0.113.50) |
SSH_USER |
The deploy username (e.g., deploy) |
SSH_PORT |
SSH port (e.g., 22 or your custom port) |
To copy the private key contents:
cat ~/.ssh/github_actions_deploy
Copy the entire output including the -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY----- lines.
Restrict the Deploy Key (Optional but Recommended)
For additional security, restrict what the deploy key can do on the VPS. Edit the authorized_keys entry to limit commands:
# On the VPS
nano ~/.ssh/authorized_keys
You can add restrictions before the key:
from="140.82.112.0/20,185.199.108.0/22,192.30.252.0/22",no-pty,no-X11-forwarding ssh-ed25519 AAAA...your-key... github-actions-deploy
The from= directive restricts connections to GitHub Actions IP ranges. Check GitHub's documentation for the current IP ranges, as they may change.
Basic Deploy Workflow with rsync
The simplest deployment strategy: sync your project files to the VPS using rsync over SSH. This works well for static sites, PHP applications, and any deployment that doesn't need a build step on the server.
Create the workflow file in your repository:
mkdir -p .github/workflows
Create .github/workflows/deploy.yml:
name: Deploy to VPS
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via rsync
run: |
rsync -avz --delete \
--exclude='.git' \
--exclude='.github' \
--exclude='node_modules' \
--exclude='.env' \
--exclude='storage/logs/*' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/
- name: Run post-deploy commands
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
cd /var/www/myapp
npm install --production
pm2 reload ecosystem.config.js
echo "Deploy completed at $(date)"
DEPLOY_SCRIPT
This workflow:
- Triggers on every push to the
mainbranch - Checks out the repository code
- Sets up the SSH key from GitHub Secrets
- Syncs files to the VPS using rsync (excluding sensitive and unnecessary files)
- Runs post-deploy commands: installs dependencies and reloads the application
The --delete flag in rsync removes files on the server that no longer exist in the repository. Remove it if you don't want this behavior (e.g., if user-uploaded files live in the deployment directory).
Build and Test Stages
A proper CI/CD pipeline tests and builds your code before deploying. This catches errors before they reach production.
Create .github/workflows/deploy.yml with build and test stages:
name: Test, Build, and Deploy
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: testpass
POSTGRES_DB: myapp_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
env:
DATABASE_URL: postgresql://test:testpass@localhost:5432/myapp_test
REDIS_URL: redis://localhost:6379
NODE_ENV: test
run: npm test
build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build production assets
run: npm run build
env:
NODE_ENV: production
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 1
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via rsync
run: |
rsync -avz --delete \
--exclude='.git' \
--exclude='.github' \
--exclude='node_modules' \
--exclude='.env' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/
- name: Run post-deploy commands
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
cd /var/www/myapp
npm install --production
pm2 reload ecosystem.config.js
echo "Deploy completed at $(date)"
DEPLOY_SCRIPT
This pipeline has three stages:
- Test — runs on every push and pull request. Spins up PostgreSQL and Redis service containers, installs dependencies, runs the linter and test suite.
- Build — only runs on pushes to
mainafter tests pass. Compiles production assets and uploads them as artifacts. - Deploy — only runs after a successful build. Syncs code and built assets to the VPS.
Pull requests trigger only the test stage, giving you confidence before merging. The deploy stage only runs on actual pushes to main.
Docker-Based Deployment Workflow
For Docker-based applications, the workflow builds a Docker image, pushes it to a container registry, then pulls and runs it on the VPS. This ensures identical environments between CI and production.
If you haven't installed Docker on your VPS yet, follow our Docker installation guide.
name: Docker Deploy
on:
push:
branches:
- main
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Login to GitHub 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 Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to VPS
env:
IMAGE_TAG: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
COMMIT_SHA: ${{ github.sha }}
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << DEPLOY_SCRIPT
set -e
echo "==> Logging into GitHub Container Registry..."
echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
echo "==> Pulling latest image..."
docker pull ${IMAGE_TAG}
echo "==> Stopping old container..."
docker stop myapp || true
docker rm myapp || true
echo "==> Starting new container..."
docker run -d \
--name myapp \
--restart unless-stopped \
--env-file /var/www/myapp/.env \
-p 3000:3000 \
${IMAGE_TAG}
echo "==> Cleaning up old images..."
docker image prune -f
echo "==> Deploy completed (${COMMIT_SHA:0:7}) at \$(date)"
DEPLOY_SCRIPT
For this workflow, add one additional secret to your repository:
| Secret Name | Value |
|---|---|
GHCR_PAT |
A GitHub Personal Access Token with read:packages scope (for the VPS to pull images) |
Docker Compose Deployment
For multi-container applications, use Docker Compose on the VPS. Update the deploy step:
- name: Deploy with Docker Compose
run: |
# First, sync the docker-compose file
scp -i ~/.ssh/deploy_key -P ${{ secrets.SSH_PORT }} \
docker-compose.prod.yml \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/docker-compose.yml
# Then deploy
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
cd /var/www/myapp
echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
echo "Deploy completed at $(date)"
DEPLOY_SCRIPT
Zero-Downtime Deployments
The basic deployment strategy has a brief downtime window when the old process stops and the new one starts. Here are strategies to eliminate that gap.
PM2 Reload (Node.js)
PM2's reload command starts new workers, waits for them to be ready, then gracefully shuts down old workers. No requests are dropped:
- name: Zero-downtime deploy
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
cd /var/www/myapp
# Pull latest code
git fetch origin main
git reset --hard origin/main
# Install dependencies
npm install --production
# Zero-downtime reload (requires cluster mode)
pm2 reload ecosystem.config.js
pm2 save
echo "Zero-downtime deploy completed at $(date)"
DEPLOY_SCRIPT
This requires your application to run in PM2 cluster mode (exec_mode: 'cluster' in the ecosystem file). See our guide on running Node.js with PM2 for the complete cluster mode setup.
Docker Rolling Update
For Docker deployments, use a blue-green strategy with Nginx as the load balancer:
- name: Zero-downtime Docker deploy
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
set -e
cd /var/www/myapp
IMAGE="ghcr.io/youruser/yourapp:latest"
echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker pull $IMAGE
# Start new container on a different name
docker run -d --name myapp-new \
--env-file .env \
-p 3001:3000 \
$IMAGE
# Wait for health check
echo "Waiting for new container to be healthy..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3001/health > /dev/null 2>&1; then
echo "New container is healthy!"
break
fi
if [ $i -eq 30 ]; then
echo "Health check failed — rolling back"
docker stop myapp-new && docker rm myapp-new
exit 1
fi
sleep 2
done
# Switch Nginx upstream to new container
sudo sed -i 's/127.0.0.1:3000/127.0.0.1:3001/' /etc/nginx/sites-available/myapp
sudo nginx -t && sudo systemctl reload nginx
# Stop old container
docker stop myapp || true
docker rm myapp || true
# Rename new container and update port
docker stop myapp-new
docker rm myapp-new
# Start final container on the standard port
docker run -d --name myapp \
--restart unless-stopped \
--env-file .env \
-p 3000:3000 \
$IMAGE
# Restore Nginx config to standard port
sudo sed -i 's/127.0.0.1:3001/127.0.0.1:3000/' /etc/nginx/sites-available/myapp
sudo nginx -t && sudo systemctl reload nginx
docker image prune -f
echo "Zero-downtime deploy completed at $(date)"
DEPLOY_SCRIPT
Health Check Endpoint
Both strategies above rely on a health check endpoint. Add one to your application:
// Express.js example
app.get('/health', (req, res) => {
// Check database connection, cache, etc.
const checks = {
uptime: process.uptime(),
timestamp: Date.now(),
status: 'ok'
};
// Add database check
try {
await db.query('SELECT 1');
checks.database = 'connected';
} catch (err) {
checks.database = 'disconnected';
checks.status = 'degraded';
return res.status(503).json(checks);
}
res.status(200).json(checks);
});
Environment Variables and Secrets Management
Never commit secrets to your repository. GitHub Actions and your VPS each have their own mechanism for managing sensitive values.
GitHub Actions Secrets
Store deployment credentials in GitHub Secrets (Settings > Secrets and variables > Actions). Reference them in workflows with ${{ secrets.SECRET_NAME }}.
For application-specific environment variables that need to be on the VPS, you have two options:
Option 1: .env File on the VPS
Manually create a .env file on the VPS that your application reads at startup. This is the simplest approach and keeps secrets completely off GitHub:
# On the VPS
nano /var/www/myapp/.env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
SESSION_SECRET=your-random-secret
API_KEY=sk-xxxxxxxxxxxx
chmod 600 /var/www/myapp/.env
Exclude .env from rsync in your deploy workflow (which we already did with --exclude='.env').
Option 2: GitHub Secrets to .env File
If you want GitHub Actions to manage environment variables (useful for multi-environment deployments), create the .env file during deployment:
- name: Create .env file on VPS
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << ENVSCRIPT
cat > /var/www/myapp/.env << 'ENVFILE'
NODE_ENV=production
PORT=3000
DATABASE_URL=${{ secrets.DATABASE_URL }}
REDIS_URL=${{ secrets.REDIS_URL }}
SESSION_SECRET=${{ secrets.SESSION_SECRET }}
API_KEY=${{ secrets.API_KEY }}
ENVFILE
chmod 600 /var/www/myapp/.env
ENVSCRIPT
GitHub Environments
For multi-environment deployments (staging + production), use GitHub Environments. Each environment has its own set of secrets and can require manual approval.
- Go to Settings > Environments
- Create environments: staging and production
- Add environment-specific secrets (different database URLs, API keys, etc.)
- Enable Required reviewers for production
Reference environments in your workflow:
deploy-staging:
needs: test
runs-on: ubuntu-latest
environment: staging
steps:
# ... deploy to staging server using staging secrets
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
steps:
# ... deploy to production server using production secrets
# Requires manual approval before running
Deployment Notifications
Get notified when deployments succeed or fail so you can respond quickly to issues.
Slack Notifications
Add a Slack webhook URL as a GitHub Secret (SLACK_WEBHOOK_URL), then add a notification step at the end of your deploy job:
- name: Notify Slack on success
if: success()
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":white_check_mark: *Deployment Successful*\n*Repo:* ${{ github.repository }}\n*Branch:* ${{ github.ref_name }}\n*Commit:* `${{ github.sha }}` by ${{ github.actor }}\n*Message:* ${{ github.event.head_commit.message }}"
}
}
]
}' \
${{ secrets.SLACK_WEBHOOK_URL }}
- name: Notify Slack on failure
if: failure()
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":x: *Deployment Failed*\n*Repo:* ${{ github.repository }}\n*Branch:* ${{ github.ref_name }}\n*Commit:* `${{ github.sha }}` by ${{ github.actor }}\n*Action:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"
}
}
]
}' \
${{ secrets.SLACK_WEBHOOK_URL }}
Discord Notifications
Discord webhooks accept a similar format. Add DISCORD_WEBHOOK_URL as a secret:
- name: Notify Discord
if: always()
run: |
if [ "${{ job.status }}" == "success" ]; then
COLOR=3066993
STATUS="Successful"
else
COLOR=15158332
STATUS="Failed"
fi
curl -X POST -H "Content-Type: application/json" \
-d "{
\"embeds\": [{
\"title\": \"Deployment ${STATUS}\",
\"color\": ${COLOR},
\"fields\": [
{\"name\": \"Repository\", \"value\": \"${{ github.repository }}\", \"inline\": true},
{\"name\": \"Branch\", \"value\": \"${{ github.ref_name }}\", \"inline\": true},
{\"name\": \"Author\", \"value\": \"${{ github.actor }}\", \"inline\": true},
{\"name\": \"Commit\", \"value\": \"\`${{ github.sha }}\`\"},
{\"name\": \"Message\", \"value\": \"${{ github.event.head_commit.message }}\"}
],
\"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"
}]
}" \
${{ secrets.DISCORD_WEBHOOK_URL }}
Email Notifications
GitHub Actions sends email notifications for workflow failures by default. You can configure this in your GitHub notification settings. For custom email notifications, use a step with curl to call a service like SendGrid or Mailgun.
Rollback Strategies
When a deployment goes wrong, you need to quickly revert to the previous working version. Plan your rollback strategy before you need it.
Git-Based Rollback
The simplest rollback: deploy a previous commit. Create a manual workflow trigger:
name: Rollback Deployment
on:
workflow_dispatch:
inputs:
commit_sha:
description: 'Commit SHA to roll back to'
required: true
type: string
reason:
description: 'Reason for rollback'
required: true
type: string
jobs:
rollback:
runs-on: ubuntu-latest
steps:
- name: Checkout specific commit
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.commit_sha }}
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy rollback
run: |
rsync -avz --delete \
--exclude='.git' \
--exclude='.github' \
--exclude='node_modules' \
--exclude='.env' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'DEPLOY_SCRIPT'
cd /var/www/myapp
npm install --production
pm2 reload ecosystem.config.js
echo "ROLLBACK to ${{ github.event.inputs.commit_sha }} completed at $(date)"
echo "Reason: ${{ github.event.inputs.reason }}"
DEPLOY_SCRIPT
Trigger this workflow manually from the Actions tab in GitHub: Actions > Rollback Deployment > Run workflow.
Docker Image Rollback
If you use Docker deployments with tagged images, rolling back is even simpler — just run the previous image tag:
name: Docker Rollback
on:
workflow_dispatch:
inputs:
image_tag:
description: 'Docker image tag to roll back to (commit SHA)'
required: true
type: string
jobs:
rollback:
runs-on: ubuntu-latest
steps:
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Rollback container
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << DEPLOY_SCRIPT
set -e
IMAGE="ghcr.io/${{ github.repository }}:${{ github.event.inputs.image_tag }}"
echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker pull \$IMAGE
docker stop myapp || true
docker rm myapp || true
docker run -d --name myapp \
--restart unless-stopped \
--env-file /var/www/myapp/.env \
-p 3000:3000 \
\$IMAGE
echo "ROLLBACK to tag ${{ github.event.inputs.image_tag }} completed at \$(date)"
DEPLOY_SCRIPT
Keeping Rollback Versions on the VPS
Another approach: keep the last N deployments on the VPS and symlink the current one. This makes rollback instant (no re-download):
- name: Deploy with release directories
run: |
RELEASE_DIR="releases/$(date +%Y%m%d_%H%M%S)_${GITHUB_SHA:0:7}"
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << DEPLOY_SCRIPT
set -e
BASE="/var/www/myapp"
RELEASE="\$BASE/${RELEASE_DIR}"
mkdir -p \$RELEASE
DEPLOY_SCRIPT
# Sync to the release directory
rsync -avz --delete \
--exclude='.git' \
--exclude='.github' \
--exclude='node_modules' \
--exclude='.env' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/${RELEASE_DIR}/
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << DEPLOY_SCRIPT
set -e
BASE="/var/www/myapp"
RELEASE="\$BASE/${RELEASE_DIR}"
# Install dependencies
cd \$RELEASE
npm install --production
# Copy persistent files
cp \$BASE/shared/.env \$RELEASE/.env 2>/dev/null || true
# Symlink current to new release
ln -sfn \$RELEASE \$BASE/current
# Reload application
cd \$BASE/current
pm2 reload ecosystem.config.js
# Keep only the last 5 releases
cd \$BASE/releases
ls -dt */ | tail -n +6 | xargs rm -rf
echo "Deployed ${RELEASE_DIR} at \$(date)"
DEPLOY_SCRIPT
To rollback, simply re-symlink to a previous release directory:
# Manual rollback on the VPS
cd /var/www/myapp
ls releases/ # See available releases
ln -sfn releases/20260226_143000_abc1234 current
cd current && pm2 reload ecosystem.config.js
Securing the Deployment Pipeline
A few additional security measures for production deployments:
Branch Protection Rules
In GitHub, go to Settings > Branches > Add rule for main:
- Require pull request reviews before merging
- Require status checks to pass (your test job)
- Require branches to be up to date before merging
- Do not allow bypassing the above settings
This ensures only tested, reviewed code reaches main and triggers deployment.
Deployment Concurrency
Prevent multiple deployments from running simultaneously, which could cause race conditions:
jobs:
deploy:
runs-on: ubuntu-latest
concurrency:
group: production-deploy
cancel-in-progress: false # Don't cancel running deploys
Firewall Rules for SSH
If your VPS firewall allows SSH from any IP, consider restricting it to GitHub Actions IP ranges for the deploy key. See our security hardening guide for comprehensive firewall configuration.
Complete Workflow Template
Here's a production-ready workflow combining all the pieces — test, build, deploy, notify, with rollback available as a separate manual workflow:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run build
env:
NODE_ENV: production
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy
run: |
rsync -avz --delete \
--exclude='.git' --exclude='.github' \
--exclude='node_modules' --exclude='.env' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/myapp/
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
cd /var/www/myapp
npm install --production
pm2 reload ecosystem.config.js
pm2 save
EOF
- name: Notify success
if: success()
run: |
curl -sS -X POST -H 'Content-type: application/json' \
--data '{"text":"Deployed `${{ github.sha }}` to production by ${{ github.actor }}"}' \
${{ secrets.SLACK_WEBHOOK_URL }} || true
- name: Notify failure
if: failure()
run: |
curl -sS -X POST -H 'Content-type: application/json' \
--data '{"text":"FAILED deploy `${{ github.sha }}` — <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Logs>"}' \
${{ secrets.SLACK_WEBHOOK_URL }} || true
Prefer Managed Deployment?
Setting up and maintaining a CI/CD pipeline requires ongoing attention — updating workflow files, rotating deploy keys, troubleshooting failed deploys, and managing server-side dependencies. If you'd rather focus on your application code, MassiveGRID's Managed Dedicated Cloud Servers include infrastructure administration: OS updates, security patching, monitoring, backups, and 24/7 incident response. Every managed server runs on a Proxmox HA cluster with automatic failover and Ceph triple-replicated NVMe storage.
What's Next
- Ubuntu VPS initial setup guide — configure the deploy user, SSH keys, and base server setup referenced throughout this guide
- Security hardening guide — firewall rules, SSH hardening, and fail2ban to protect your VPS
- Install Docker on Ubuntu VPS — required for Docker-based deployment workflows
- Nginx reverse proxy guide — set up Nginx in front of your application for SSL and load balancing
- VPS monitoring guide — track deployment metrics and application health post-deploy
- Automatic backups guide — back up your application and database before each deployment