CI/CD (continuous integration and continuous deployment) means every push is tested automatically and, if the tests pass, deployed without manual steps. GitHub Actions is built into GitHub and free for public repositories with a monthly allowance for private ones, which makes it the easiest way to add CI/CD for a VPS.
Before you start
- A GitHub repository with your app.
- A Linux VPS with a deploy user (not root) that can write to the app folder.
- Your app already deployable by hand, for example with
git pullor by copying a build folder.
How the pipeline works
- You push to
main. - A GitHub-hosted runner checks out the code, installs dependencies and runs tests (the CI part).
- If tests pass, the runner connects to your VPS over SSH and deploys (the CD part).
Create a dedicated deploy key
- On your computer, generate a key pair used only by GitHub Actions:
ssh-keygen -t ed25519 -f gha_deploy -N "" -C "github-actions" - Append
gha_deploy.pubto/home/deploy/.ssh/authorized_keyson the VPS. - Get the server's host key so the runner can verify it:
ssh-keyscan -t ed25519 203.0.113.10
Add repository secrets
In GitHub open Settings → Secrets and variables → Actions → New repository secret and add:
| Name | Value |
|---|---|
SSH_PRIVATE_KEY | Contents of gha_deploy (private key) |
SSH_KNOWN_HOSTS | Output of the ssh-keyscan command |
SSH_HOST | Server IP or hostname |
SSH_USER | deploy |
Then delete the local private key file or store it in a password manager.
Write the workflow
Create .github/workflows/deploy.yml. This example builds a Node.js app and copies the result with rsync; adapt the build steps to your stack:
name: CI/CD
on:
push:
branches: [main]
jobs:
test-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- name: Set up SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Deploy
run: |
rsync -az --delete dist/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/var/www/app/
ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 'sudo systemctl restart myapp'For a PHP site, swap the Node steps for composer install --no-dev and tests with PHPUnit. For a server-side git pull deployment, replace the rsync line with ssh ... 'cd /var/www/app && git pull --ff-only'.
Limit what the deploy user can do
- Allow only the restart command via sudo, in
/etc/sudoers.d/deploy:deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp - Give the deploy user write access only to the app folder.
- Protect the
mainbranch so only reviewed code triggers deploys.
Check the pipeline
Push a commit and open the Actions tab. Each step shows its log; a green tick means tested and deployed. Break a test on purpose once to confirm a failure stops the deploy.
Troubleshooting
- Host key verification failed:
SSH_KNOWN_HOSTSis missing or for a different key type. Re-run ssh-keyscan. - Permission denied (publickey): the public key is not in the deploy user's
authorized_keys, or the private key secret lost its line breaks; paste it again including the BEGIN/END lines. - sudo: a password is required: the sudoers rule does not match the exact command path.
- Firewall blocks the runner: GitHub runners use many changing IPs. Allow SSH from them, use a self-hosted runner, or connect through a VPN.
CI/CD FAQ
Is it safe to store an SSH key in GitHub secrets?
Secrets are encrypted and hidden from logs. Reduce the risk further with a dedicated key, a limited user and narrow sudo rules.
GitHub Actions or GitLab CI?
Both work the same way. Use the one built into the platform that hosts your code.
Can I deploy to cPanel hosting with GitHub Actions?
Yes, over SSH or SFTP if your account has SSH access, using the same rsync approach with your cPanel username and home path.
