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_keysConfiguring GitHub Secrets
In the repository: Settings → Secrets and variables → Actions → New secret:
VPS_HOST— server IP or domainVPS_USER— deployVPS_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-fpmDeploying 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"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 remainThe 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 addressThe 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
| Criterion | GitHub Actions | GitLab CI |
|---|---|---|
| Config location | .github/workflows/*.yml | .gitlab-ci.yml at the repo root |
| Free minutes (public repos) | Unlimited | Unlimited |
| Secrets | Settings → Secrets and variables | Settings → 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.