How to Deploy Web Apps from GitHub Actions to Linux VPS via SSH

Continuous Integration and Continuous Deployment (CI/CD) eliminates repetitive manual deployments, human configuration errors, and unexpected downtime. While managed cloud platforms offer convenient git-push integrations, deploying directly to your own self-hosted Linux VPS provides complete hardware ownership, unlimited deployment frequency, and drastically lower infrastructure costs.

In this guide, you will learn how to architect an automated, production-grade deployment pipeline using GitHub Actions and SSH keys. We will establish dedicated deployment users, configure hardened cryptographic authentication, deploy code using atomic symlinks and rsync, and reload production services without dropping client connections.

1. The Production CI/CD Deployment Architecture

A resilient CI/CD pipeline separates build-time validation from deployment orchestration. Below is the end-to-end execution workflow:

  1. A developer merges code into the main production branch.
  2. GitHub Actions triggers an ephemeral runner to execute automated linting, security scanning, and unit tests.
  3. Upon passing tests, the runner establishes an encrypted SSH connection to your remote VPS using an isolated deployment key.
  4. The runner synchronizes release artifacts using rsync into a versioned release folder.
  5. The pipeline executes database migrations, switches an atomic symlink to point to the new release, and reloads systemd or Docker services in zero downtime.

2. Configuring a Hardened Deploy User on Linux VPS

Never execute CI/CD deployments using the root administrative account. If an attacker compromises your repository secrets or a malicious pull request slips through, they gain unrestricted root privileges across your server. Instead, create an isolated deploy user with tightly constrained sudo permissions:

# Create an unprivileged deploy user
sudo adduser --disabled-password --gecos "" deployer

# Create SSH configuration directory
sudo mkdir -p /home/deployer/.ssh
sudo chmod 700 /home/deployer/.ssh

# Configure target release web directory
sudo mkdir -p /var/www/my-app/releases
sudo mkdir -p /var/www/my-app/shared
sudo chown -R deployer:deployer /var/www/my-app

Next, grant the deployer user permission to reload your web server or systemd services without requesting an interactive password:

# Add sudoers rule using visudo
echo "deployer ALL=(ALL) NOPASSWD: /bin/systemctl reload nginx, /bin/systemctl restart my-app.service" | sudo tee /etc/sudoers.d/deployer-service-reload

3. Generating and Securing Ed25519 SSH Keys

Generate a modern Ed25519 SSH keypair on your local administrative machine specifically for GitHub Actions deployment:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./id_deploy_github

Append the public key (id_deploy_github.pub) to the VPS deployer authorized keys:

cat ./id_deploy_github.pub | ssh administrator@your-vps-ip "sudo tee -a /home/deployer/.ssh/authorized_keys"
ssh administrator@your-vps-ip "sudo chmod 600 /home/deployer/.ssh/authorized_keys && sudo chown -R deployer:deployer /home/deployer/.ssh"

Next, open your GitHub repository, navigate to Settings > Secrets and variables > Actions, and configure the following Repository Secrets:

  • SSH_PRIVATE_KEY: The entire private key content (including -----BEGIN OPENSSH PRIVATE KEY-----).
  • SSH_HOST: The public IP address or hostname of your VPS.
  • SSH_USER: deployer
  • SSH_PORT: Your custom SSH port (e.g., 22 or hardened custom port like 2222).

4. Complete Production GitHub Actions Workflow

Create the workflow definition file inside your repository at .github/workflows/deploy.yml:

name: Production Deployment Pipeline

on:
  push:
    branches:
      - main

jobs:
  test_and_lint:
    name: Run Test Suite & Linters
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

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

      - name: Install Dependencies
        run: npm ci

      - name: Execute Tests
        run: npm test

  deploy_to_vps:
    name: Zero-Downtime VPS Deployment
    needs: test_and_lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Source Code
        uses: actions/checkout@v4

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

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

      - name: Execute Atomic Release Deployment
        env:
          HOST: ${{ secrets.SSH_HOST }}
          USER: ${{ secrets.SSH_USER }}
          PORT: ${{ secrets.SSH_PORT }}
        run: |
          RELEASE_TIMESTAMP=$(date +%Y%m%d%H%M%S)
          TARGET_DIR="/var/www/my-app"
          NEW_RELEASE="$TARGET_DIR/releases/$RELEASE_TIMESTAMP"

          echo "Deploying release $RELEASE_TIMESTAMP to $HOST..."

          # 1. Create release directory
          ssh -p $PORT $USER@$HOST "mkdir -p $NEW_RELEASE"

          # 2. Sync application files via rsync
          rsync -avz -e "ssh -p $PORT" --exclude='.git' --exclude='.github' --exclude='node_modules' ./ $USER@$HOST:$NEW_RELEASE/

          # 3. Execute installation and atomic symlink swap
          ssh -p $PORT $USER@$HOST << 'EOF'
            cd '"$NEW_RELEASE"'
            
            # Symlink persistent shared configuration
            ln -nfs /var/www/my-app/shared/.env '"$NEW_RELEASE"'/.env
            
            # Install production dependencies
            npm ci --omit=dev
            
            # Run database migrations
            npm run db:migrate --if-present
            
            # Atomic symlink swap
            ln -sfn '"$NEW_RELEASE"' /var/www/my-app/current
            
            # Purge older releases (keep last 5)
            cd /var/www/my-app/releases && ls -t | tail -n +6 | xargs -r rm -rf
            
            # Gracefully reload system service
            sudo systemctl restart my-app.service
            sudo systemctl reload nginx
          EOF

          echo "Deployment successfully certified!"

5. Why Atomic Symlink Switching Prevents Downtime

Traditional deployment scripts that run git pull && npm install directly in the live web root introduce serious downtime windows. If an HTTP request arrives while files are being replaced, users encounter broken classes, 404 assets, and incomplete database transactions.

By preparing the new release in an isolated timestamped directory (/releases/20260912160000) and executing the Linux atomic symlink switch command:

ln -sfn /var/www/my-app/releases/20260912160000 /var/www/my-app/current

The Linux filesystem swaps the pointer in a single atomic inode update. Incoming HTTP requests are seamlessly routed from the previous version to the new version without a millisecond of service disruption.

Power Your CI/CD Pipelines with CpanelFree Cloud VPS

Deploy modern Git-driven pipelines without limits. Enjoy unmetered bandwidth, enterprise SSD/NVMe performance, and dedicated IPv4 addresses built for production uptime.

Get Started with CpanelFree VPS →

Leave a Comment