Skip to main content

GitHub Actions: Automated Deployment to a VPS

Cloud & DevOps · 19.04.2026 · 5 min read
Illustration for “GitHub Actions: Automated Deployment to a VPS”

GitHub Actions: CI/CD Deployment to a VPS

GitHub Actions lets you automatically test and deploy an application to a VPS on every push to the repository. This is the foundation of modern DevOps without paid tools.

Preparing the VPS

# Create the deploy user
adduser deploy --disabled-password
usermod -aG www-data deploy
su - deploy
mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys
chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys

Configuring GitHub Secrets

In the repository: Settings → Secrets and variables → Actions → New secret:

  • VPS_HOST — server IP or domain
  • VPS_USER — deploy
  • VPS_SSH_KEY — contents of ~/.ssh/id_rsa (private key)
  • VPS_PORT — 22 (or a non-standard port)

Workflow for a PHP Project

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

on:
  push:
    branches: [main]

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

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'

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

      - name: Run tests
        run: ./vendor/bin/phpunit

      - name: Deploy via SSH
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            cd /var/www/html
            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
            sudo systemctl reload php8.3-fpm

Deploying via rsync

      - name: Deploy files via rsync
        uses: burnett01/rsync-deployments@7.0.1
        with:
          switches: -avzr --delete --exclude='.env' --exclude='storage/'
          path: ./
          remote_path: /var/www/html/
          remote_host: ${{ secrets.VPS_HOST }}
          remote_user: ${{ secrets.VPS_USER }}
          remote_key: ${{ secrets.VPS_SSH_KEY }}

Telegram Deployment Notifications

      - name: Notify Telegram
        if: always()
        run: |
          STATUS=${{ job.status }}
          EMOJI=$([[ "$STATUS" == "success" ]] && echo "OK" || echo "FAIL")
          curl -s -X POST "https://api.telegram.org/bot${{ secrets.TG_TOKEN }}/sendMessage"             -d "chat_id=${{ secrets.TG_CHAT }}&text=${EMOJI} Deploy ${STATUS}: $GITHUB_REF_NAME"
Zero-downtime: For PHP, use rolling deploys: deploy to /releases/$(date), switch the symlink, and reload PHP-FPM. This prevents errors from showing during deployment.

Real Deployment Errors Explained

Two errors cover almost every problem with SSH deployment through GitHub Actions.

The Key Fails Authentication

ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain

The most common cause is that the VPS_SSH_KEY secret has an incomplete private key — the first or last line got lost (-----BEGIN OPENSSH PRIVATE KEY----- / -----END...-----), or the key is protected by a passphrase that the action cannot enter interactively. Generate a separate passwordless key specifically for deployment: ssh-keygen -t ed25519 -N "" -f deploy_key.

Git Cannot Update the Repository on the Server

fatal: could not read Username for 'https://github.com': No such device or address

The repository on the server was cloned over HTTPS instead of SSH, and for a private repository git asks for a login/password in non-interactive mode — and fails. The fix: re-clone over SSH with the server's deploy key added to GitHub as a Deploy Key, or use a Personal Access Token embedded in the URL as part of a secret.

GitHub Actions vs. GitLab CI

CriterionGitHub ActionsGitLab CI
Config location.github/workflows/*.yml.gitlab-ci.yml at the repo root
Free minutes (public repos)UnlimitedUnlimited
SecretsSettings → Secrets and variablesSettings → CI/CD → Variables

When GitHub Actions Is Not a Good Fit

  • The deployment must happen inside a closed network with no internet access — GitHub-hosted runners cannot reach an internal network; you need a self-hosted runner with separate network configuration.
  • The repository already lives in a self-hosted GitLab — it makes more sense to use the built-in GitLab CI instead of dragging in a second CI service out of habit.
  • Secrets must live in a corporate Vault with an audit trail for every read — GitHub Secrets is just encrypted storage without a detailed access log for compliance reporting.
  • You need a build on specific hardware (ARM, GPU) that is not in the GitHub-hosted runner queue — you will have to stand up a self-hosted runner on that hardware regardless.
  • The team already has a working Jenkins or TeamCity setup with a full pipeline history — migrating just for the trendy name does not offset the risk of breaking a working process.

Verifying the Result

# The latest workflow's status from the terminal
gh run list --limit 5
gh run view --log

# Make sure the correct commit is actually deployed on the server
ssh deploy@vps "cd /var/www/html && git rev-parse HEAD"

# A health check after deployment
curl -sf https://yourdomain.com/health || echo "DEPLOY BROKEN"

Comparing the commit hash on the server against git rev-parse HEAD from the workflow itself is the most reliable way to confirm that the version which passed tests is actually what got deployed, not a stale cache. For a zero-downtime scheme with release directories and a symlink, also verify the previous release is still available for rollback, and move monitoring of the site itself into Uptime Kuma rather than relying only on the workflow's status.

Was this article helpful?
← Back to Knowledge Base Ask Support