VPS & deployment
Push-to-Deploy on a Hostinger VPS with GitHub Actions and Plain SSH
GitHub Actions deploy to a Hostinger VPS on every push to main: a CI-only SSH key, forced command, pinned host key and a workflow with no third-party actions.
By Mr. Ahmed · 20+ years in tech
Published · 8 min read
The short answer
- 01The workflow SSHes into the VPS on every push to
mainand runs adeploy.shthat already lives on the server. - 02Use a dedicated CI key restricted by a forced command in
authorized_keys, so a leaked key can only trigger a deploy of your own code. - 03Store three GitHub secrets: SSH_HOST, SSH_KEY and SSH_KNOWN_HOSTS (from
ssh-keyscan), so the runner never trusts an unknown host. - 04A
concurrencygroup queues deploys, andset -euo pipefailin the script turns a failed build into a red workflow while the old build keeps serving. - 05Because the build runs on the VPS, each deploy uses well under a minute of Actions time.
The design: keep CI dumb
The most reliable pipeline I know asks GitHub to do almost nothing. On each push to main, a workflow opens SSH to the VPS and the server runs a deploy script it already has: pull, build, restart. If the script works when you run it by hand, it works from CI, and you debug it on the server rather than in YAML.
This assumes a working deploy.sh, such as the one at the end of the Next.js deploy guide. Laravel, Django or anything else uses the same pattern; only the script differs.
Generate a key that only CI uses
Do not reuse your personal SSH key. Create a separate one without a passphrase, so you can cut off CI without touching your own access.
ssh-keygen -t ed25519 -N "" -C "github-actions" -f ./gha_deploy
# gha_deploy -> private key, goes into a GitHub secret
# gha_deploy.pub -> public key, goes onto the serverRestrict the key to one command
Add the public key to the deploy user's authorized_keys with a forced command in front of it. Whatever the client requests, sshd runs deploy.sh and nothing else. If the private key ever leaks, the worst an attacker can do is redeploy your own code.
nano ~/.ssh/authorized_keys
command="/var/www/myapp/deploy.sh",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA...your-public-key... github-actionsAdd the secrets in GitHub
In the repository, go to Settings, then Secrets and variables, then Actions, and create three secrets. The known-hosts entry is not optional. Without it the runner would trust whatever machine answers on that address.
| Secret | Value |
|---|---|
| SSH_HOST | VPS IP address or domain |
| SSH_KEY | Entire contents of gha_deploy (the private key) |
| SSH_KNOWN_HOSTS | The line ssh-keyscan printed |
ssh-keyscan -t ed25519 YOUR_SERVER_IPThe workflow file
Commit this file. The concurrency group queues deploys so two quick pushes never build on top of each other. workflow_dispatch adds a Run workflow button, so you can redeploy without a new commit. There is no command after the host because the forced command on the server decides what runs.
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Deploy
run: ssh deploy@${{ secrets.SSH_HOST }}Failure handling and hardening
Since deploy.sh begins with set -euo pipefail, a failed build exits non-zero, the SSH step fails and the workflow turns red. GitHub emails you, and the site keeps serving the previous build because PM2 was never restarted. That is the failure mode you want: loud, and harmless to users.
- Run tests in an earlier job and add
needs: testto the deploy job, so broken code never reaches the server. - Building on the server is fine while builds are quick. Once they start eating CPU the live app needs, build in Actions and rsync the output instead.
- Add a protected environment in GitHub if production deploys should wait for a manual approval.
Common errors
These four account for most failed first runs:
| Error | Cause and fix |
|---|---|
| Host key verification failed | SSH_KNOWN_HOSTS is empty or holds a different key type. Re-run ssh-keyscan and paste the whole line. |
| Permission denied (publickey) | The public key is missing from the deploy user's authorized_keys, or the file is group-writable. Run chmod 600 ~/.ssh/authorized_keys. |
| pm2: command not found | Non-interactive SSH does not load your shell profile. Use the full path from which pm2 inside deploy.sh. |
| Build killed / exit 137 | The build ran out of memory. Add swap, or move the build into Actions. |
Verify it worked
A deploy pipeline is only trustworthy once you have watched it succeed and fail.
- 1Push a trivial commit to
main, or press Run workflow on the Actions tab. - 2Open the run and confirm the Deploy step shows your script's output and ends green.
- 3On the server, confirm the checked-out commit matches the one you just pushed.
- 4Load the site and confirm the change is live.
- 5Break the build on purpose in a branch, merge it, and check that the workflow goes red while the site keeps serving the old version. Then revert.
git -C /var/www/myapp log -1 --onelineRelated reading
If you are coming off a managed platform, moving from Vercel to a Hostinger VPS covers the rest of the migration. Make sure the server itself is locked down first with the security checklist.
Questions people ask
Can GitHub Actions deploy to Hostinger shared hosting?
Yes, but over FTP or SFTP rather than SSH commands. Build in Actions and upload the output folder, because shared hosting cannot run a build or a long-lived process for you.
Is an SSH key safe in GitHub secrets?
Reasonably, yes. Secrets are encrypted and masked in logs, and when the key is CI-only and restricted by a forced command, a leak can only trigger a deploy of your own code.
Does this setup cost anything?
Usually nothing. Actions is free for public repos and includes free minutes for private ones, and this workflow uses well under a minute per deploy because the build runs on the VPS.
Independent site, not Hostinger. Buy through my links and Hostinger pays me a referral fee; your price is the same or lower. How that works.