Skip to content
savemores

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 · 20+ years in tech

Published · 8 min read

The short answer

  • 01The workflow SSHes into the VPS on every push to main and runs a deploy.sh that 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 concurrency group queues deploys, and set -euo pipefail in 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.

On your laptop:
bash
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 server

Restrict 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.

On the VPS, as deploy, add one line (it must stay on a single line):
bash
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-actions

Add 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.

SecretValue
SSH_HOSTVPS IP address or domain
SSH_KEYEntire contents of gha_deploy (the private key)
SSH_KNOWN_HOSTSThe line ssh-keyscan printed
Produce the SSH_KNOWN_HOSTS value:
bash
ssh-keyscan -t ed25519 YOUR_SERVER_IP

The 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.

.github/workflows/deploy.yml
yaml
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: test to 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:

ErrorCause and fix
Host key verification failedSSH_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 foundNon-interactive SSH does not load your shell profile. Use the full path from which pm2 inside deploy.sh.
Build killed / exit 137The 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.

  1. 1Push a trivial commit to main, or press Run workflow on the Actions tab.
  2. 2Open the run and confirm the Deploy step shows your script's output and ends green.
  3. 3On the server, confirm the checked-out commit matches the one you just pushed.
  4. 4Load the site and confirm the change is live.
  5. 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.
On the VPS (adjust the path to your app):
bash
git -C /var/www/myapp log -1 --oneline

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.