Tutorial

CI/CD for Small Teams: GitHub Actions to Your VPS in 10 Minutes

March 31, 2026

Back to Blog

What Is CI/CD and Why Does Your Small Team Need It?

Every developer has done the manual deploy dance. SSH into the server, git pull, npm install, pray nothing breaks, restart the service, check the logs, realize you forgot to run migrations, fix that, restart again. Repeat for every release.

It works. Until it doesn't. Until you push a typo to production at 5 PM on a Friday because you were in a hurry. Until two developers deploy at the same time and overwrite each other's changes. Until the server goes down during a deploy and nobody knows what state the app is in.

CI/CD — Continuous Integration and Continuous Deployment — is the practice of automating all of that. Every push to your repository triggers a pipeline that tests your code, builds your artifacts, and deploys to your server. No manual steps. No forgotten migrations. No Friday surprises.

CI: Continuous Integration

Every time a developer pushes code, automated tests run. If tests fail, the team is notified immediately — before broken code reaches production. The key rule: the main branch is always deployable. Nobody merges code that doesn't pass tests.

CD: Continuous Deployment (or Delivery)

Continuous Delivery means every successful build is a release candidate — ready to deploy with one click. Continuous Deployment goes one step further: successful builds are automatically deployed to production. No human approval needed.

For most small teams, Continuous Delivery is the right choice — automatic deployment to staging, one-click to production. But for internal tools or low-risk apps, full Continuous Deployment makes sense.

The Business Case

  • Faster releases — Deploy 10 times a day instead of once a week. Small, frequent changes are safer than large, infrequent ones.
  • Fewer bugs in production — Automated tests catch regressions before deployment.
  • No more manual deploys — Developers focus on writing code, not running deployment checklists.
  • Reproducible deployments — The exact same process runs every time, on every deploy.
  • Audit trail — Every deployment is logged in your Git history and CI system.

You don't need a DevOps team or a Kubernetes cluster to get these benefits. A single YAML file in your repository and a VPS can give you a world-class deployment pipeline in about 10 minutes.


The Simplest Possible Setup

Before we dive into specific workflows, let's be clear about what we're building:

  1. Developer pushes to the main branch
  2. GitHub Actions runs your test suite
  3. If tests pass, GitHub Actions SSHs into your VPS and deploys the new code
  4. Your app is live with the new changes

That's it. No Docker registry. No Kubernetes. No Helm charts. Just SSH and a shell script — the same tools you've been using manually, now automated.

This setup works for 90% of small teams and solo developers. The remaining 10% usually discover they need more complexity only after they've outgrown this approach — and that's fine. Start simple, scale when you need to.


GitHub Actions Basics

GitHub Actions is GitHub's built-in CI/CD platform. It's free for public repositories and includes 2,000 minutes per month for private repositories on the free plan — more than enough for small teams.

Workflow File Structure

Every workflow lives in .github/workflows/ in your repository. The filename can be anything; the content is YAML:

.github/
  workflows/
    deploy.yml        # production deployment
    test.yml          # run tests on every PR
    nightly.yml       # scheduled tasks

Anatomy of a Workflow

name: Deploy to Production    # displayed in GitHub UI

on:                           # triggers
  push:
    branches: [main]

jobs:                         # parallel execution units
  deploy:
    runs-on: ubuntu-latest    # GitHub-hosted runner

    steps:                    # sequential steps within a job
      - name: Checkout code
        uses: actions/checkout@v4       # use a pre-built action

      - name: Run tests
        run: npm test                   # run a shell command

Trigger Types

TriggerExampleUse Case
pushPush to mainDeploy to production
pull_requestOpen a PRRun tests before merge
scheduleCron syntaxNightly builds, backups
workflow_dispatchManual triggerOn-demand deploy, rollback
releasePublish a releaseDeploy tagged version

Secrets Management

Never put passwords, API keys, or private keys in your workflow files. GitHub provides encrypted secrets that are injected as environment variables at runtime:

Go to Settings → Secrets and variables → Actions → New repository secret.

Access them in your workflow as ${{ secrets.SECRET_NAME }}. Secrets are masked in logs — even if you accidentally echo them, GitHub replaces the value with ***.


SSH Key Setup for GitHub Actions

The most important prerequisite: GitHub Actions needs to SSH into your server without a password. You do this with an ED25519 deploy key.

Step 1: Generate the Deploy Key

# Run this on your local machine (NOT on the server)
ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -C "github-actions-deploy" -N ""

# This creates two files:
# ~/.ssh/deploy_key      (private key — goes to GitHub)
# ~/.ssh/deploy_key.pub  (public key — goes to server)

Step 2: Create a Deploy User on Your Server

Never deploy as root. Create a dedicated deploy user with minimal permissions:

# On your server
useradd -m -s /bin/bash deploy
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh

# Add the public key
cat ~/.ssh/deploy_key.pub >> /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh

# Give deploy user ownership of app directory
chown -R deploy:deploy /var/www/myapp

Step 3: Add Secrets to GitHub

Add these three secrets to your repository:

  • SSH_PRIVATE_KEY — Contents of ~/.ssh/deploy_key (the private key file, including -----BEGIN... and -----END... lines)
  • SERVER_IP — Your VPS IP address or hostname
  • SSH_KNOWN_HOSTS — Run ssh-keyscan -H your.server.ip and paste the output

Step 4: Test the Connection

# Test locally before committing the workflow
ssh -i ~/.ssh/deploy_key [email protected] "echo 'SSH works!'"
Security note: The private key only exists in GitHub's encrypted secrets store and in memory during workflow runs. It is never written to disk or logged. GitHub Actions environments are ephemeral — the virtual machine is destroyed after each run.

Complete Workflow: Node.js Application

Here's a production-ready workflow for a Node.js app using PM2 as the process manager:

name: Deploy Node.js App

on:
  push:
    branches: [main]

jobs:
  # Job 1: Run tests
  test:
    runs-on: ubuntu-latest

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

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Run linter
        run: npm run lint

  # Job 2: Deploy (only runs if tests pass)
  deploy:
    needs: test          # depends on test job
    runs-on: ubuntu-latest

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

      - name: Set up SSH
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Add server to known hosts
        run: echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts

      - name: Deploy to server
        run: |
          ssh deploy@${{ secrets.SERVER_IP }} << 'DEPLOY'
            set -e
            cd /var/www/myapp

            echo "Pulling latest code..."
            git pull origin main

            echo "Installing dependencies..."
            npm ci --omit=dev

            echo "Restarting application..."
            pm2 restart myapp --update-env

            echo "Deploy complete!"
          DEPLOY

      - name: Verify deployment
        run: |
          sleep 5
          curl -f https://myapp.com/health || exit 1

What Each Step Does

  • actions/checkout@v4 — Clones your repository into the runner
  • actions/setup-node@v4 — Installs the specified Node.js version, with npm cache for speed
  • npm ci — Installs exact versions from package-lock.json (faster and more reproducible than npm install)
  • needs: test — The deploy job only runs if the test job succeeded
  • webfactory/ssh-agent — Loads the private key into an SSH agent for the duration of the job
  • pm2 restart --update-env — Restarts the process with updated environment variables
  • curl -f /health — Verifies the app is responding after deployment

Complete Workflow: PHP/Laravel Application

Laravel deployments require a few extra steps: Composer install, database migrations, and cache clearing.

name: Deploy Laravel App

on:
  push:
    branches: [main]

env:
  PHP_VERSION: '8.3'

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: testing
        options: --health-cmd="mysqladmin ping" --health-interval=10s

    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ env.PHP_VERSION }}
          extensions: mbstring, pdo, pdo_mysql, redis

      - name: Install Composer dependencies
        run: composer install --no-dev --optimize-autoloader

      - name: Set up test environment
        run: |
          cp .env.example .env.testing
          php artisan key:generate --env=testing

      - name: Run tests
        run: php artisan test --env=testing

  deploy:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up SSH
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Add known hosts
        run: echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts

      - name: Deploy to server
        run: |
          ssh deploy@${{ secrets.SERVER_IP }} << 'DEPLOY'
            set -e
            cd /var/www/myapp

            # Pull latest code
            git pull origin main

            # Install/update dependencies
            composer install --no-dev --optimize-autoloader --no-interaction

            # Run database migrations (non-destructive only!)
            php artisan migrate --force

            # Clear and rebuild caches
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache
            php artisan event:cache

            # Restart PHP-FPM to clear OPcache
            sudo systemctl reload php8.3-fpm

            echo "Deploy complete!"
          DEPLOY

Allowing sudo for PHP-FPM Reload

The deploy user needs permission to reload PHP-FPM without a password. Add this to /etc/sudoers.d/deploy:

deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm

Complete Workflow: Static Site (Hugo, Jekyll, Next.js)

For static sites, build on GitHub's runners and rsync the output to your server — no Node.js or anything else needed on the server itself:

name: Deploy Static Site

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # needed for Hugo's .GitInfo

      - name: Set up Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.140.0'
          extended: true

      - name: Build site
        run: hugo --minify

      - name: Set up SSH
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Add known hosts
        run: echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts

      - name: Deploy via rsync
        run: |
          rsync -avz --delete \
            --exclude='.htaccess' \
            --exclude='uploads/' \
            public/ \
            deploy@${{ secrets.SERVER_IP }}:/var/www/mysite/

Next.js Static Export

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Build Next.js
        run: |
          npm ci
          npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

      - name: Deploy via rsync
        run: |
          rsync -avz --delete out/ deploy@${{ secrets.SERVER_IP }}:/var/www/mysite/

The --delete flag removes files from the server that no longer exist in the build output. The --exclude flags protect files that should not be overwritten (user uploads, custom .htaccess rules).


Complete Workflow: Go Binary

Go's cross-compilation makes CI/CD particularly clean: compile for Linux on any platform, then copy the binary to the server:

name: Deploy Go Service

on:
  push:
    branches: [main]
    tags: ['v*']  # also deploy on version tags

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'
          cache: true
      - run: go test ./...
      - run: go vet ./...

  build-and-deploy:
    needs: test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'
          cache: true

      - name: Build binary
        run: |
          GOOS=linux GOARCH=amd64 go build \
            -ldflags="-s -w -X main.version=${{ github.sha }}" \
            -o myservice \
            ./cmd/server/main.go

      - name: Set up SSH
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Add known hosts
        run: echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts

      - name: Copy binary to server
        run: |
          scp myservice deploy@${{ secrets.SERVER_IP }}:/tmp/myservice-new

      - name: Restart service
        run: |
          ssh deploy@${{ secrets.SERVER_IP }} << 'DEPLOY'
            set -e

            # Stop service, swap binary, start service
            sudo systemctl stop myservice
            mv /tmp/myservice-new /usr/local/bin/myservice
            chmod +x /usr/local/bin/myservice
            sudo systemctl start myservice

            # Verify it's running
            sleep 2
            systemctl is-active myservice || exit 1

            echo "Service restarted successfully"
          DEPLOY

The -ldflags="-s -w" flag strips debug symbols and DWARF information, reducing binary size by 20-30%. The -X main.version embeds the Git SHA into the binary for version tracking.


Zero-Downtime Deployment Pattern

The workflows above have a brief downtime window while the service restarts. For production applications where every second matters, use the symlink deployment strategy.

Directory Structure

/var/www/myapp/
├── current -> releases/20260525_143022/   # symlink, always points to live release
├── shared/                                 # persistent across releases
│   ├── .env
│   ├── storage/
│   └── uploads/
└── releases/
    ├── 20260525_143022/                    # current release
    ├── 20260524_091500/                    # previous release (for rollback)
    └── 20260523_162300/                    # older release (will be cleaned)

The Deployment Script

#!/bin/bash
set -e

APP_DIR="/var/www/myapp"
RELEASE_DIR="$APP_DIR/releases/$(date +%Y%m%d_%H%M%S)"
SHARED_DIR="$APP_DIR/shared"
CURRENT_LINK="$APP_DIR/current"

echo "Creating release directory: $RELEASE_DIR"
mkdir -p "$RELEASE_DIR"

# Copy code to new release directory
# (In a full workflow, you'd rsync from GitHub Actions runner)
git -C "$APP_DIR" archive HEAD | tar -x -C "$RELEASE_DIR"

# Link shared files
ln -sf "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
ln -sf "$SHARED_DIR/storage" "$RELEASE_DIR/storage"
ln -sf "$SHARED_DIR/uploads" "$RELEASE_DIR/public/uploads"

# Install dependencies in new release (not live yet)
cd "$RELEASE_DIR"
composer install --no-dev --optimize-autoloader --no-interaction

# Run migrations
php artisan migrate --force

# Build caches
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Atomically switch the symlink
# ln -sfn is atomic on Linux — no downtime window
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"

# Reload PHP-FPM (reads new code from new symlink)
sudo systemctl reload php8.3-fpm

echo "Deployment complete: $RELEASE_DIR"

# Clean up old releases (keep last 5)
ls -dt "$APP_DIR/releases/"*/ | tail -n +6 | xargs -r rm -rf
echo "Old releases cleaned up"

Rollback in One Command

# List recent releases
ls -dt /var/www/myapp/releases/*/

# Roll back to previous release
PREVIOUS=$(ls -dt /var/www/myapp/releases/*/ | sed -n '2p')
ln -sfn "$PREVIOUS" /var/www/myapp/current
sudo systemctl reload php8.3-fpm
echo "Rolled back to: $PREVIOUS"

Because ln -sfn is atomic (it's a single syscall), there is zero downtime. The Nginx worker processes serving existing requests continue using the old code; new requests immediately see the new symlink target.


Database Migrations in CI/CD

Database migrations are the trickiest part of any deployment pipeline. Get this wrong and you can take down production.

The Golden Rules

  • Never run destructive migrations automatically — DROP TABLE, DROP COLUMN, or RENAME COLUMN should require manual approval
  • Migrations must be backwards compatible — the new code must work with both the old and new schema during the transition
  • Test migrations on a staging database first — never run an untested migration on production
  • Always have a rollback plan — every migration should have a corresponding down migration

The Expand-Migrate-Contract Pattern

When you need to rename or restructure a column:

  1. Expand — Add the new column alongside the old one. Deploy code that writes to both.
  2. Migrate — Copy data from old column to new column. This can be a background job.
  3. Contract — Deploy code that only reads/writes the new column. Then drop the old column.

This pattern eliminates the risky "old code + new schema" or "new code + old schema" window that causes production incidents.

Safe Migration Workflow

  - name: Check for destructive migrations
    run: |
      # Fail the pipeline if any migration contains dangerous keywords
      if grep -r "DROP\|TRUNCATE\|RENAME" database/migrations/*.php 2>/dev/null | grep -v "\.php:#"; then
        echo "ERROR: Destructive migration detected. Manual review required."
        exit 1
      fi

  - name: Run migrations on staging
    run: |
      ssh deploy@${{ secrets.STAGING_SERVER_IP }} << 'MIGRATE'
        cd /var/www/myapp
        php artisan migrate --force --env=staging
      MIGRATE

  - name: Run migrations on production
    run: |
      ssh deploy@${{ secrets.SERVER_IP }} << 'MIGRATE'
        cd /var/www/myapp
        php artisan migrate --force
      MIGRATE

Environment Variables and Secrets

Managing environment-specific configuration is one of the most common sources of deployment bugs. Here is a clean approach.

GitHub Secrets (Encrypted)

Store all sensitive values as GitHub Secrets:

  • Database passwords
  • API keys for third-party services
  • SSH private keys
  • Stripe/payment keys
  • JWT secrets

GitHub Variables (Non-sensitive)

For non-sensitive configuration, use Settings → Variables (not Secrets). These appear in logs without masking:

APP_URL: https://myapp.com
NODE_ENV: production
LOG_LEVEL: error

Environment-Specific Workflows

name: Deploy

on:
  push:
    branches:
      - main        # deploy to production
      - staging     # deploy to staging

jobs:
  deploy:
    runs-on: ubuntu-latest

    environment: ${{ github.ref_name }}   # use environment-specific secrets

    steps:
      - name: Deploy
        run: |
          ssh deploy@${{ secrets.SERVER_IP }} "cd /var/www/myapp && git pull && ..."

GitHub Environments let you create separate secret sets for production and staging, with optional approval gates for production deployments.

.env Management on the Server

The .env file on your server should never be in version control. Instead:

# Create .env once manually on the server
# Link it from shared/ in the symlink deployment pattern

# Or generate it during deployment from secrets:
      - name: Generate .env
        run: |
          ssh deploy@${{ secrets.SERVER_IP }} << 'ENV'
            cat > /var/www/myapp/shared/.env << ENVFILE
            APP_KEY=${{ secrets.APP_KEY }}
            DB_PASSWORD=${{ secrets.DB_PASSWORD }}
            REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}
            ENVFILE
          ENV

Deployment Notifications

Your team should know when deployments happen — and especially when they fail.

Slack Notification

      - name: Notify Slack on success
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Deployment succeeded :white_check_mark:",
              "blocks": [{
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": "*${{ github.repository }}* deployed successfully\nCommit: `${{ github.sha }}`\nBy: @${{ github.actor }}"
                }
              }]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

      - name: Notify Slack on failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Deployment FAILED :x:",
              "blocks": [{
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": "*${{ github.repository }}* deployment FAILED\nBranch: `${{ github.ref_name }}`\nBy: @${{ github.actor }}\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View logs>"
                }
              }]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Discord Webhook

      - name: Notify Discord
        if: always()
        run: |
          STATUS="${{ job.status }}"
          COLOR=$([ "$STATUS" = "success" ] && echo "3066993" || echo "15158332")
          curl -X POST "${{ secrets.DISCORD_WEBHOOK_URL }}" \
            -H 'Content-Type: application/json' \
            -d "{
              \"embeds\": [{
                \"title\": \"Deployment $STATUS\",
                \"color\": $COLOR,
                \"description\": \"Repo: ${{ github.repository }}\nCommit: \`${{ github.sha }}\`\"
              }]
            }"

GitHub Deployment Status

      - name: Create deployment
        uses: chrnorm/deployment-action@v2
        id: deployment
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          environment: production

      - name: Update deployment status
        if: always()
        uses: chrnorm/deployment-status@v2
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          deployment-id: ${{ steps.deployment.outputs.deployment_id }}
          state: ${{ job.status }}

Rollback Strategy

Every deployment system needs a "big red button." Things go wrong. You need to be able to undo a deployment in under 60 seconds.

Code Rollback: git revert

The safest code rollback is git revert — it creates a new commit that undoes the problematic changes. This is preferred over git reset --hard because it preserves history and triggers your normal CI/CD pipeline.

# On your local machine
git revert HEAD --no-edit
git push origin main
# CI/CD pipeline runs, deploys the revert commit

Infrastructure Rollback: Symlink

With the symlink deployment pattern, rollback is instant:

# Manual rollback workflow (triggered from GitHub UI)
name: Rollback

on:
  workflow_dispatch:
    inputs:
      release:
        description: 'Release to roll back to (leave empty for previous)'
        required: false

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production  # require approval

    steps:
      - name: Set up SSH
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Add known hosts
        run: echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts

      - name: Execute rollback
        run: |
          ssh deploy@${{ secrets.SERVER_IP }} << 'ROLLBACK'
            TARGET="${{ github.event.inputs.release }}"

            if [ -z "$TARGET" ]; then
              # Roll back to previous release
              TARGET=$(ls -dt /var/www/myapp/releases/*/ | sed -n '2p')
            else
              TARGET="/var/www/myapp/releases/$TARGET"
            fi

            echo "Rolling back to: $TARGET"
            ln -sfn "$TARGET" /var/www/myapp/current
            sudo systemctl reload php8.3-fpm

            echo "Rollback complete!"
          ROLLBACK

Database Rollback Considerations

Database rollback is the hardest part. The approach depends on the type of migration:

  • Additive migrations (new columns, new tables) — Safe to roll back. The previous code ignores the new columns.
  • Destructive migrations (DROP, TRUNCATE) — Require a database snapshot before deployment. Always take a backup before running these.
  • Data transformation migrations — Require the data to be restored from backup. Test the restore procedure before you need it.

The simplest approach: take an automated database backup as the first step of every production deployment.

      - name: Backup database before deploy
        run: |
          ssh deploy@${{ secrets.SERVER_IP }} << 'BACKUP'
            mysqldump -u myapp -p"$DB_PASSWORD" myapp_production | \
              gzip > /var/backups/myapp/pre-deploy-$(date +%Y%m%d_%H%M%S).sql.gz
          BACKUP

Beyond GitHub Actions

GitHub Actions is the right choice for most teams — it's free, deeply integrated with GitHub, and has thousands of pre-built actions. But it's not the only option.

GitLab CI/CD

GitLab's CI/CD lives in .gitlab-ci.yml at the root of your repository. The syntax is different but the concepts are identical. GitLab's free tier includes 400 CI minutes per month. If you're self-hosting GitLab, runners are unlimited.

# .gitlab-ci.yml (equivalent to GitHub Actions workflow)
stages:
  - test
  - deploy

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

deploy:
  stage: deploy
  only:
    - main
  script:
    - ssh deploy@$SERVER_IP "cd /var/www/myapp && git pull && npm ci && pm2 restart myapp"

Forgejo / Gitea Actions

If you self-host your Git server with Forgejo or Gitea, their Actions implementation is largely compatible with GitHub Actions syntax. You run your own runners, so there are no minute limits. Good choice for teams with on-premise requirements or strict data sovereignty needs.

Woodpecker CI

Woodpecker CI is a lightweight self-hosted CI system that integrates with Forgejo, Gitea, GitHub, and GitLab. Configuration is simpler than GitHub Actions, and resource usage is minimal. A good choice when you want self-hosted CI without running a full GitLab instance.

When to Upgrade to More Complex Tools

The SSH-based deployment pattern in this guide scales remarkably well. You should only consider adding complexity when you have a concrete problem that simpler tools can't solve:

ProblemSolution
Multiple services that need coordinationDocker Compose deployments
Multiple servers, load balancingAnsible, Capistrano
Complex rollout strategies (canary, blue/green)Kubernetes
Many teams, complex permissionsArgoCD, Flux
Need to build/push Docker imagesDocker registry + Watchtower

Most applications never need Kubernetes. The team that deploys via SSH is not behind — they're pragmatic.


Security Checklist

Before you go live with your CI/CD pipeline, work through this checklist:

Server Security

  • Create a dedicated deploy user — never deploy as root
  • The deploy user should own only the application directory, nothing else
  • Use sudo with NOPASSWD only for the specific commands needed (service restart, etc.)
  • The deploy SSH key should be different from your personal SSH key
  • Restrict SSH access to your deploy user: disable password authentication, allow only key-based auth
  • Set up fail2ban to block brute-force attempts

GitHub Secrets

  • All sensitive values in GitHub Secrets, never hardcoded in workflow files
  • Use GitHub Environments with approval gates for production
  • Rotate secrets on a schedule (every 90 days for long-lived keys)
  • Review repository collaborators — only people who need to deploy should have write access
  • Enable branch protection on main: require PR reviews, require status checks to pass

Workflow Files

  • Pin action versions to a specific SHA, not a tag: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 — tags can be moved by the action author
  • Use the minimum required GitHub token permissions: add permissions: read-all and expand only what you need
  • Never echo secrets in shell scripts — GitHub masks them, but it's bad practice
  • Validate all inputs in workflow_dispatch inputs to prevent injection attacks

Network

  • Consider allowing SSH only from GitHub Actions IP ranges. GitHub publishes them at https://api.github.com/meta (the actions field)
  • Use a non-standard SSH port to reduce automated scanning noise
  • Monitor your server's SSH auth logs: journalctl -u ssh -f

Audit Trail

  • Every deployment is tied to a Git commit SHA — you always know exactly what code is running
  • GitHub Actions provides a complete log of every workflow run, with actor, timestamps, and output
  • Keep deployment logs on the server: write to /var/log/deployments.log at the end of each deploy script

Quick Reference

Workflow Triggers

TriggerYAMLUse Case
Push to mainon: push: branches: [main]Continuous deployment
Pull requeston: pull_request: branches: [main]Test before merge
Manual triggeron: workflow_dispatchRollback, on-demand deploy
Scheduledon: schedule: - cron: '0 2 * * *'Nightly builds, maintenance
Version tagon: push: tags: ['v*']Versioned releases

Recommended Actions

ActionPurpose
actions/checkout@v4Clone repository
actions/setup-node@v4Install Node.js
actions/setup-go@v5Install Go
shivammathur/setup-php@v2Install PHP
webfactory/[email protected]Load SSH key into agent
appleboy/ssh-action@v1Run commands via SSH
peaceiris/actions-hugo@v3Install Hugo
slackapi/slack-github-action@v1Slack notifications

Deployment Methods Comparison

MethodDowntimeRollbackComplexityBest For
git pull + restartSecondsgit revertLowGetting started
rsync + restartSecondsgit revertLowStatic sites, small apps
Symlink strategyZeroInstant (symlink)MediumProduction PHP/Node
Blue/green (2 servers)ZeroDNS switchHighHigh-traffic apps
Docker + WatchtowerSecondsImage tagMediumContainerized apps

You Already Have Everything You Need

The barrier to setting up a CI/CD pipeline is lower than most people think. You have a GitHub account. You have a VPS. You have SSH. That's everything you need to automate your deployments today.

Start with the simplest workflow: push to main, run tests, deploy via SSH. Get that working. Then add zero-downtime deployments when you need them. Add rollback automation when you have a close call. Add notifications when your team grows.

The goal is not a perfect pipeline on day one. The goal is to stop doing manual deploys — because the next outage caused by a rushed, manual, Friday-afternoon deployment will cost more time than setting this up ever will.

One YAML file. Ten minutes. Automated deploys forever.

Share: