Tutorials

Automating Web Deployment with GitHub Actions and SSH on Linux VPS

How to Automate Git Deployment with GitHub Actions (CI/CD Guide) - CpanelFree Guide
Written by Blog

Modernizing Deployment Pipelines: From Manual FTP to Zero-Downtime CI/CD

Deploying code updates via manual SFTP drag-and-drop or manual SSH logins is prone to human error, unexpected production downtime, missing environment variables, and synchronization mismatches. Modern DevOps engineering solves these risks using automated continuous integration and continuous deployment (CI/CD) pipelines. By leveraging GitHub Actions alongside secure cryptographic SSH keys, every code commit merged into your primary branch triggers automated validation, asset compilation, and atomic deployment directly to your cloud VPS.

In this technical walkthrough, we will configure an enterprise-grade GitHub Actions CI/CD workflow that establishes a secure SSH connection to an Ubuntu VPS, executes atomic zero-downtime symlink directory switching, runs database migrations, restarts background worker processes, and flushes application caches seamlessly.

Step 1: Generating Dedicated SSH Keypair for CI/CD Pipeline

To ensure security isolation, generate a dedicated ed25519 cryptographic keypair specifically for your automated deployment pipeline rather than reusing your personal administrator key:

# Generate high-security ed25519 keypair on local machine or server
ssh-keygen -t ed25519 -C "[email protected]" -f ~/.ssh/github_actions_deploy

# Restrict permissions on private and public keys
chmod 600 ~/.ssh/github_actions_deploy
chmod 644 ~/.ssh/github_actions_deploy.pub

Copy the contents of ~/.ssh/github_actions_deploy.pub and append it to your remote VPS deploy user’s authorized keys file (/home/deployer/.ssh/authorized_keys).

Step 2: Configuring GitHub Repository Action Secrets

Never commit raw SSH credentials, server IP addresses, or private keys to version control. In your GitHub repository, navigate to Settings > Secrets and variables > Actions and add the following encrypted repository secrets:

  • SSH_HOST: Your server’s public IPv4 address or hostname (e.g., 192.0.2.45)
  • SSH_USER: The unprivileged deploy user account (e.g., deployer)
  • SSH_PRIVATE_KEY: The complete private key string from ~/.ssh/github_actions_deploy (including -----BEGIN OPENSSH PRIVATE KEY----- headers)
  • SSH_PORT: Your custom SSH port (default: 22)

Step 3: Creating the GitHub Actions Workflow YAML

Create a workflow file in your repository at .github/workflows/deploy.yml. This configuration triggers on every push to the main branch, establishes an encrypted SSH agent session, pulls repository updates, installs Composer/NPM dependencies, and reloads server daemons:

name: Production Deployment Pipeline

on:
  push:
    branches:
      - main

jobs:
  deploy:
    name: Deploy Application to Cloud VPS
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code Repository
        uses: actions/checkout@v4

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

      - name: Add Remote Host to Known Hosts
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts

      - name: Execute Remote Atomic Deployment Script
        run: |
          ssh -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'EOF'
            set -e
            echo "🚀 Starting automated deployment pipeline on VPS..."
            cd /var/www/my-application

            # Fetch latest git commits
            git pull origin main

            # Install production backend dependencies
            composer install --no-dev --optimize-autoloader --no-interaction

            # Build production frontend assets
            npm ci
            npm run build

            # Run database migrations
            php artisan migrate --force

            # Optimize configuration and route caches
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache

            # Reload PHP-FPM process gracefully without dropping HTTP requests
            sudo systemctl reload php8.3-fpm
            echo "✅ Deployment finished successfully!"
          EOF

Step 4: Hardening Deploy User Permissions with Sudoers

To allow the unprivileged deployer user to reload web server services (like PHP-FPM or Nginx) without prompting for an interactive sudo password during automated runs, add a targeted sudoers rule:

# Open sudoers drop-in configuration
sudo visudo -f /etc/sudoers.d/deployer

# Add permission to reload PHP-FPM and Nginx only without password
deployer ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm, /usr/bin/systemctl reload nginx

CI/CD Deployment Strategies Comparison

Deployment Method Downtime Window Rollback Speed Setup Complexity
Manual SFTP Upload High (files overwritten live) Very Slow (Manual re-upload) Low
Git Pull Script Sub-second Fast (git checkout <hash>) Moderate
Atomic Symlink (Envoyer/Capistrano) Zero Downtime (Atomic symlink swap) Instantaneous (1 millisecond) Advanced

Security & Production Best Practices

  • Limit Branch Triggers: Restrict automatic deployments to protected production branches with mandatory pull request code reviews.
  • Enforce Secret Masking: GitHub automatically masks configured secrets in action logs, but avoid echo statements that print decrypted tokens.
  • Implement Pre-Deployment Unit Tests: Configure an automated test job (phpunit, jest, or pytest) that must pass with 100% success before triggering the SSH deployment step.
  • Configure Rollback Mechanisms: Keep timestamped release releases (e.g., releases/20260904_120000) linked to current so rollbacks require only a single symlink update.

Advanced Multi-Environment CI/CD Pipeline Architecture

When engineering high-availability web applications, deploying directly to production without an intermediary testing stage can introduce breaking bugs to live end-users. Professional engineering teams employ a tiered multi-environment branching strategy where code is automatically deployed to separate staging and production environments based on Git tag or branch names.

In this advanced workflow configuration, pushes to the staging branch automatically trigger deployment to a dedicated testing sandbox (staging.example.com), while official GitHub release tags (e.g., v1.0.4) or merges into main trigger atomic production releases with mandatory automated rollbacks on healthcheck failure:

name: Multi-Tier Production & Staging Pipeline

on:
  push:
    branches:
      - main
      - staging
    tags:
      - 'v*.*.*'

jobs:
  test_suite:
    name: Run Automated Test Suite
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup PHP Environment
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, xml, ctype, iconv, mysql, redis
      - name: Install Dependencies
        run: composer install --prefer-dist --no-progress
      - name: Execute Unit & Integration Tests
        run: vendor/bin/phpunit --colors=always

  deploy_staging:
    name: Deploy to Staging Sandbox
    needs: test_suite
    if: github.ref == 'refs/heads/staging'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Execute Staging SSH Deployment
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script: |
            cd /var/www/staging.example.com
            git pull origin staging
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            php artisan cache:clear

  deploy_production:
    name: Deploy to Live Production Cluster
    needs: test_suite
    if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Execute Zero-Downtime Atomic Symlink Deploy
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script: |
            RELEASE_DIR="/var/www/releases/$(date +%Y%m%d%H%M%S)"
            mkdir -p $RELEASE_DIR
            git clone --depth=1 --branch=main https://github.com/org/repo.git $RELEASE_DIR
            cd $RELEASE_DIR
            composer install --no-dev --optimize-autoloader
            npm ci && npm run build
            ln -nfs $RELEASE_DIR /var/www/current
            sudo systemctl reload php8.3-fpm
            echo "Deployment to production verified!"

Automated Rollback & Healthcheck Verification

An automated pipeline is only as reliable as its error handling. After updating the production symlink, the deployment script issues an HTTP health check request against an internal /healthz endpoint. If the HTTP status code is not 200 OK within 15 seconds, the script automatically reverts the /var/www/current symlink back to the previous release folder and notifies the DevOps on-call team via Discord or Slack webhook.

Launch Your High-Performance CI/CD Pipeline on Free VPS

Supercharge your continuous deployment pipelines with enterprise-grade cloud servers featuring pure NVMe SSDs and unmetered bandwidth.

Deploy Free Cloud Hosting Now →

About the author

Blog

DevOps architect and Linux sysadmin specializing in server hardening, OpenLiteSpeed performance optimization, and free cloud hosting infrastructure.

Leave a Comment