← Back to Blog
CI/CDTutorial

CI/CD for Bare Metal: GitHub Actions, GitLab CI, and RAW CLI

The biggest misconception about bare metal servers is that you lose the “git push to deploy” workflow. You don’t. With a CI/CD pipeline and SSH deploy keys, pushing to main triggers a build, deploys to your server, runs health checks, and rolls back automatically if something breaks — all in under 60 seconds.

Prerequisites

  • A RAW bare metal server — any plan works. Deploy with npx rawhq deploy
  • SSH key pair — for passwordless deployment from CI to your server
  • Your app — Node.js, Python, Go, or any stack that runs on Linux

Step 1: SSH Deploy Keys

Generate a dedicated key pair for CI deployments. Never reuse your personal SSH key.

# Generate a deploy key (no passphrase)
ssh-keygen -t ed25519 -C "ci-deploy" -f deploy_key -N ""

# Copy public key to your RAW server
ssh-copy-id -i deploy_key.pub root@your-server-ip

# Test the connection
ssh -i deploy_key root@your-server-ip "echo connected"

Add the private key as a secret in your CI system. In GitHub: Settings → Secrets → DEPLOY_SSH_KEY. In GitLab: Settings → CI/CD → Variables → DEPLOY_SSH_KEY (type: File).

Step 2: Server-Side Deploy Script

Create a deploy script on your server that handles the full deployment cycle:

# /opt/app/deploy.sh
#!/bin/bash
set -euo pipefail

APP_DIR="/opt/app"
REPO="https://github.com/you/your-app.git"
BRANCH="main"

cd "$APP_DIR"

# Pull latest code
git fetch origin "$BRANCH"
git reset --hard "origin/$BRANCH"

# Install dependencies and build
npm ci --production
npm run build 2>/dev/null || true

# Restart with zero downtime
pm2 reload all --update-env

# Health check
sleep 3
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/health)
if [ "$HTTP_STATUS" != "200" ]; then
  echo "Health check failed with status $HTTP_STATUS"
  exit 1
fi

echo "Deploy successful"
chmod +x /opt/app/deploy.sh

GitHub Actions Workflow

Create .github/workflows/deploy.yml in your repository:

# .github/workflows/deploy.yml
name: Deploy to Bare Metal
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: your-server-ip
          username: root
          key: $SECRETS_DEPLOY_SSH_KEY
          script: /opt/app/deploy.sh

That is the entire workflow. Push to main, GitHub Actions SSHs into your server and runs the deploy script. Typical execution time: 15–30 seconds.

Adding Tests Before Deploy

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: your-server-ip
          username: root
          key: $SECRETS_DEPLOY_SSH_KEY
          script: /opt/app/deploy.sh

GitLab CI Workflow

Create .gitlab-ci.yml in your repository root:

# .gitlab-ci.yml
stages:
  - test
  - deploy

test:
  stage: test
  image: node:22
  script:
    - npm ci
    - npm test

deploy:
  stage: deploy
  only:
    - main
  before_script:
    - apt-get update -qq && apt-get install -y -qq openssh-client
    - eval $(ssh-agent -s)
    - cat "$DEPLOY_SSH_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - ssh-keyscan your-server-ip >> ~/.ssh/known_hosts
  script:
    - ssh root@your-server-ip "/opt/app/deploy.sh"

RAW CLI Deployments

The RAW CLI integrates directly into any CI system. Use it to provision servers, manage infrastructure, and trigger deploys:

# Install RAW CLI in your CI pipeline
npm install -g rawhq

# Deploy to an existing server
raw deploy --server my-app

# Or use in GitHub Actions
- name: Deploy with RAW CLI
  env:
    RAW_API_KEY: $SECRETS_RAW_API_KEY
  run: |
    npx rawhq deploy --server my-app

Zero-Downtime Deploys

The pm2 reload command in the deploy script performs a rolling restart — new workers start before old ones stop. No dropped connections. For even more safety, keep the previous release available for instant rollback:

# /opt/app/rollback.sh
#!/bin/bash
set -euo pipefail
cd /opt/app
git reset --hard HEAD~1
npm ci --production
npm run build 2>/dev/null || true
pm2 reload all --update-env
sleep 3
curl -sf http://127.0.0.1:3000/health || { echo "Rollback failed"; exit 1; }
echo "Rolled back successfully"

Trigger with ssh root@server /opt/app/rollback.sh or add a GitHub Actions workflow dispatch trigger for one-click rollbacks from the UI.

Health Checks

Your app needs a health endpoint. This is non-negotiable for automated deploys:

// Express / Fastify example
app.get('/health', (req, res) => {
  // Check DB connection, Redis, etc.
  res.status(200).json({ status: 'ok' })
})

The deploy script checks this endpoint after restart. If it returns anything other than 200, the deploy fails and triggers a rollback. No manual intervention needed.

CI/CD Comparison

FeatureGitHub ActionsGitLab CI
Free minutes/mo2,000 (public repos unlimited)400
SSH deployVia appleboy/ssh-actionBuilt-in ssh-agent
Secrets managementRepository secretsCI/CD variables
Deploy speed15–30s20–40s

Security Best Practices

  • Dedicated deploy keys — never reuse personal SSH keys in CI
  • Restrict deploy key permissions — use a non-root user with sudo only for service restarts
  • Rotate keys quarterly — update CI secrets and server authorized_keys
  • Pin action versions — use appleboy/ssh-action@v1 not @master
  • Audit deploy logs — store CI output and server deploy logs for post-mortems

Deploy to RAW

npx rawhq deploy

7-day free trial. 13 seconds to deploy. Set up CI/CD once and never think about deployments again.