# Push-to-Deploy on a Hostinger VPS with GitHub Actions and Plain SSH

URL: https://www.savemores.store/vps/github-actions-deploy
Author: Mr. Ahmed · Published 2026-10-17 · Updated 2026-10-17 · VPS & deployment

## Short answer

- The workflow SSHes into the VPS on every push to `main` and runs a `deploy.sh` that already lives on the server.
- Use 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.
- Store three GitHub secrets: SSH_HOST, SSH_KEY and SSH_KNOWN_HOSTS (from `ssh-keyscan`), so the runner never trusts an unknown host.
- A `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.
- Because 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](https://www.savemores.store/vps/deploy-nextjs). 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
```

> From the bench: Edit `authorized_keys` from a session you are already logged into, and append the line rather than replacing the file. Overwrite your own key line by accident and the forced-command key will be the only one left, which means your next login runs a deploy instead of giving you a shell. For a private repo, the server also needs read access: generate a separate key on the server with `ssh-keygen -t ed25519`, add it as a read-only deploy key under the repo's Settings, Deploy keys, and clone over SSH rather than HTTPS.

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

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

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:

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

1. Push a trivial commit to `main`, or press Run workflow on the Actions tab.
2. Open the run and confirm the Deploy step shows your script's output and ends green.
3. On the server, confirm the checked-out commit matches the one you just pushed.
4. Load the site and confirm the change is live.
5. Break 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
```

> From the bench: Test the failure path once, deliberately, before you rely on it. A pipeline that has only ever gone green tells you nothing about what happens on the day a build breaks.

## Related reading

If you are coming off a managed platform, [moving from Vercel to a Hostinger VPS](https://www.savemores.store/vps/vercel-to-hostinger-vps) covers the rest of the migration. Make sure the server itself is locked down first with [the security checklist](https://www.savemores.store/vps/secure-new-vps).

## FAQ

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