Tutorial

GitHub Actions for Server Deployment: Build, Test, Deploy

May 29, 2026

Back to Blog
A modern alternative to cPanel, Plesk and CyberPanel — isolated, secure, AI-assisted.
Start free

Why Automate Deployments with GitHub Actions?

Manual deployments are error-prone, time-consuming, and stressful. You SSH into a server, pull the latest code, run a build, restart services, and hope nothing breaks. One missed step or typo in a command can bring down your entire application. GitHub Actions eliminates this human error by codifying your deployment process into repeatable, version-controlled workflows that run automatically when you push code.

GitHub Actions is a CI/CD platform built directly into GitHub. It runs your workflows on GitHub-hosted or self-hosted runners, supports any language or framework, and integrates with thousands of community actions. Best of all, it is free for public repositories and includes 2,000 minutes per month for private repos on the free tier.

72%
Reduction in deployment errors with CI/CD
2,000
Free CI/CD minutes per month

GitHub Actions Fundamentals

Before we dive into deployment workflows, let us understand the core concepts of GitHub Actions.

ConceptDescriptionExample
WorkflowA YAML file in .github/workflows/deploy.yml
TriggerEvent that starts the workflowpush, pull_request, workflow_dispatch
JobA set of steps that run on one runnerbuild, test, deploy
StepIndividual task within a jobrun: npm test
ActionReusable unit of codeactions/checkout@v4
RunnerMachine that executes workflowsubuntu-latest, self-hosted
SecretEncrypted variable for sensitive datasecrets.SSH_PRIVATE_KEY

Your First Deployment Workflow

Let us start with a simple workflow that builds your application and deploys it to a server via SSH whenever you push to the main branch.

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup 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: Build
        run: npm run build

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          script: |
            cd /var/www/myapp
            git pull origin main
            npm ci --production
            pm2 reload ecosystem.config.js
Security First: Never hardcode server credentials in your workflow file. Always use GitHub Secrets (Settings > Secrets and variables > Actions). Secrets are encrypted, masked in logs, and never exposed in forked repositories.

Setting Up GitHub Secrets

Before your deployment workflow can connect to your server, you need to configure the necessary secrets in your GitHub repository.

1
Generate an SSH key pair: Create a dedicated deployment key that will only be used by GitHub Actions. Do not reuse personal SSH keys.
$ ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
Your identification has been saved in ~/.ssh/deploy_key
Your public key has been saved in ~/.ssh/deploy_key.pub
2
Add the public key to your server: Append the public key to ~/.ssh/authorized_keys on your deployment server.
$ cat ~/.ssh/deploy_key.pub | ssh user@server "cat >> ~/.ssh/authorized_keys"
3
Add secrets to GitHub: Navigate to your repository Settings, then Secrets and variables, then Actions. Add these secrets:
Secret NameValue
SERVER_HOSTYour server IP or hostname
SERVER_USERSSH username (e.g., deploy)
SERVER_PORTSSH port (default: 22)
SSH_PRIVATE_KEYContents of ~/.ssh/deploy_key

Rsync Deployment: The Faster Alternative

SSH deployment with git pull is simple but has drawbacks: it requires Git installed on the server, keeps the .git directory on production, and re-runs the build on the server. A better approach is to build locally (in CI) and rsync only the production artifacts to the server.

# .github/workflows/deploy-rsync.yml
name: Deploy with Rsync

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci
      - run: npm test
      - run: npm run build

      - 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.SERVER_PORT }} \
            ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts

      - name: Deploy with rsync
        run: |
          rsync -az --delete \
            -e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SERVER_PORT }}" \
            --exclude='.env' \
            --exclude='storage' \
            --exclude='node_modules' \
            ./dist/ \
            ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:/var/www/myapp/

      - name: Restart application
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          script: |
            cd /var/www/myapp
            pm2 reload all

Multi-Environment Deployment: Staging and Production

Real-world projects need at least two environments: staging for testing and production for live traffic. GitHub Actions supports this with environment-specific secrets, branch-based triggers, and manual approval gates.

Branch-Based Deployment Strategy

develop branch
Staging Deploy
QA Testing
PR to main
Production Deploy
# .github/workflows/deploy-multi-env.yml
name: Multi-Environment Deploy

on:
  push:
    branches: [develop, main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci && npm test && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: build-artifact
          path: dist/

  deploy-staging:
    needs: build
    if: github.ref == 'refs/heads/develop'
    environment: staging
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with: { name: build-artifact, path: dist/ }
      - name: Deploy to staging
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.STAGING_HOST }}
          ...

  deploy-production:
    needs: build
    if: github.ref == 'refs/heads/main'
    environment: production
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with: { name: build-artifact, path: dist/ }
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          ...

Manual Approval Gates

For production deployments, you often want a human to review and approve before the deployment proceeds. GitHub Environments support required reviewers, which pause the workflow until an authorized team member approves.

Setting Up Required Reviewers: Go to Settings > Environments > production > Required reviewers. Add team members who must approve production deployments. The workflow will pause at the deploy-production job and wait for approval before proceeding.

Manual Trigger with Inputs

Sometimes you want to trigger a deployment manually with specific options. The workflow_dispatch trigger supports input parameters:

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options:
          - staging
          - production
      skip_tests:
        description: 'Skip test suite'
        required: false
        type: boolean
        default: false

Caching Dependencies for Faster Builds

Installing dependencies on every workflow run wastes time and bandwidth. GitHub Actions provides built-in caching to speed up repeated builds. The setup-node action's cache option handles npm automatically, but you can cache anything with the generic cache action.

# Cache PHP Composer dependencies
- name: Cache Composer packages
  uses: actions/cache@v4
  with:
    path: vendor
    key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
    restore-keys: |
      ${{ runner.os }}-composer-

# Cache Go modules
- name: Cache Go modules
  uses: actions/cache@v4
  with:
    path: ~/go/pkg/mod
    key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}

PHP Laravel Deployment Workflow

Here is a complete workflow for deploying a Laravel application with Composer dependencies, database migrations, and cache optimization:

name: Deploy Laravel
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.3', extensions: mbstring,pdo_mysql }
      - run: composer install --no-interaction --prefer-dist
      - run: php artisan test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/laravel-app
            php artisan down --render="errors::503"
            git pull origin main
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache
            php artisan queue:restart
            php artisan up

Deployment Notifications

Knowing when a deployment succeeds or fails is critical. Here is how to add Slack notifications to your workflow:

- name: Notify Slack on success
  if: success()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
    webhook-type: incoming-webhook
    payload: |
      {"text": "Deployed ${{ github.sha }} to production by ${{ github.actor }}"}

- name: Notify Slack on failure
  if: failure()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
    webhook-type: incoming-webhook
    payload: |
      {"text": "FAILED: Deployment to production failed! Check logs."}

Self-Hosted Runners

GitHub-hosted runners are convenient but have limitations: 2,000 minutes per month on free plans, no persistent storage, and no access to private networks. Self-hosted runners solve all of these. You can run them on your own servers, giving you unlimited minutes, faster builds (local cache), and direct network access to your deployment targets.

# Install self-hosted runner on your server
$ mkdir actions-runner && cd actions-runner
$ curl -o actions-runner.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.320.0/...
$ tar xzf actions-runner.tar.gz
$ ./config.sh --url https://github.com/YOUR/REPO --token YOUR_TOKEN
$ sudo ./svc.sh install
$ sudo ./svc.sh start

# Use in workflow
jobs:
  deploy:
    runs-on: self-hosted
FeatureGitHub-HostedSelf-Hosted
Cost2,000 min freeUnlimited
SetupZero configRequires setup
SpeedCold start each runPersistent cache
NetworkPublic internet onlyPrivate network
MaintenanceManaged by GitHubYour responsibility

Rollback Workflow

Every deployment pipeline needs a rollback plan. Here is a manually triggered workflow that reverts to the previous deployment:

name: Rollback Deployment
on:
  workflow_dispatch:
    inputs:
      commit_sha:
        description: 'Commit SHA to rollback to'
        required: true

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Rollback on server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            git fetch origin
            git checkout ${{ github.event.inputs.commit_sha }}
            npm ci --production
            pm2 reload all

Deployment Status Badges

Add a deployment status badge to your README to show the current state of your workflow at a glance:

# Add to your README.md
![Deploy](https://github.com/USER/REPO/actions/workflows/deploy.yml/badge.svg)

# Branch-specific badge
![Deploy](https://github.com/USER/REPO/actions/workflows/deploy.yml/badge.svg?branch=main)

Best Practices Checklist

Before shipping your GitHub Actions deployment pipeline, make sure you have covered these essential points:

  • All secrets stored in GitHub Secrets, never in workflow files
  • Tests run before deployment and block on failure
  • Separate workflows or jobs for staging and production
  • Production deployments require manual approval
  • Dependencies are cached for faster builds
  • Deployment notifications sent to Slack or Discord
  • Rollback workflow exists and has been tested
  • Build artifacts uploaded for traceability
  • Workflow uses specific action versions (not @latest)
  • SSH keys are dedicated to deployment, not personal keys

Wrapping Up

GitHub Actions transforms server deployment from a manual, error-prone process into a reliable, automated pipeline. Start with a simple workflow that runs tests and deploys via SSH. As your confidence grows, add environment separation, caching, notifications, and approval gates. The key is to start simple and iterate. Your first workflow does not need to be perfect — it just needs to be better than manually SSHing into your server and running commands by hand.

Remember that CI/CD is not just about automation — it is about confidence. When every code change goes through the same tested pipeline, you can deploy on a Friday afternoon without breaking a sweat. And that is the real superpower of GitHub Actions.

Security-first hosting panel

Stop bolting tools onto a legacy panel.

Panelica is a modern, security-first hosting panel — isolated services, built-in Docker and AI-assisted management, with one-click migration from any panel.

Zero-downtime migration Fully isolated services Cancel anytime
Share:
No CloudLinux needed.