A GitHub Actions deploy to a VPS over SSH means GitHub builds or packages your code, then connects to your server with a key and copies the result into place. This guide sets up a dedicated deploy user, stores the private key as a repository secret and runs a workflow that syncs files and restarts your service on every push to main.
The result is a repeatable pipeline you control: no shared root password, no manual uploads, and a clear audit trail in the Actions tab.
Before you start
You need:
- A Linux VPS with root or sudo access, and the IP address (we use
203.0.113.10). - A GitHub repository with a
mainbranch. - SSH access from your own machine — see Connect to your Linux VPS with SSH if this is new.
rsyncinstalled on the VPS:sudo apt install rsyncon Ubuntu or Debian,sudo dnf install rsyncon AlmaLinux or Rocky.
Note: Run the workflow from a branch you trust. Anyone who can push to that branch can trigger a deployment, so protect main with branch rules if the repository is shared.
Create a deploy user and key on the VPS
Do not deploy as root. Create a service account with only the rights it needs.
- On the VPS, create the user and its
.sshdirectory:
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chown deploy:deploy /home/deploy/.ssh- On your local machine, generate a key pair used only by CI:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/gh_deploy -N ""- Install the public key on the server:
ssh-copy-id -i ~/.ssh/gh_deploy.pub deploy@203.0.113.10- Confirm it works before going further:
ssh -i ~/.ssh/gh_deploy deploy@203.0.113.10 'whoami; hostname'You should see deploy and the hostname. If you get Permission denied (publickey), check the key landed in /home/deploy/.ssh/authorized_keys and that the file is mode 600.
- Give the account permission to restart only your service. Create a sudoers drop-in:
echo 'deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp' | sudo tee /etc/sudoers.d/deploy-restart
sudo chmod 440 /etc/sudoers.d/deploy-restart
sudo visudo -cReplace myapp with your unit name. visudo -c reports syntax errors before they lock you out.
Warning: A wildcard sudo rule such as deploy ALL=(ALL) NOPASSWD: ALL turns a leaked CI key into full root access. Keep the rule to the exact commands the pipeline runs.
Add the secrets in GitHub
In the repository, go to Settings → Secrets and variables → Actions → New repository secret and add:
VPS_HOST—203.0.113.10VPS_USER—deployVPS_SSH_KEY— the full contents of~/.ssh/gh_deploy, including the-----BEGINand-----ENDlines.VPS_KNOWN_HOSTS— the server's host key line, generated withssh-keyscan -H 203.0.113.10.
Pinning the host key in a secret is stronger than scanning at run time. Scanning on every run accepts whatever key answers, which is exactly the weakness described in SSH host key verification.
Write the workflow
Create .github/workflows/deploy.yml in your repository:
name: Deploy to VPS
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v4
- name: Prepare SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.VPS_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Sync files
run: |
rsync -az --delete \
-e "ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes" \
./app/ "${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/var/www/myapp/"
- name: Restart the service
run: |
ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes \
"${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}" \
'sudo /bin/systemctl restart myapp'What each part does:
concurrencystops two pushes from deploying at the same time.IdentitiesOnly=yesprevents the runner from offering unrelated keys, which avoids the "too many authentication failures" error.rsync -az --deletemirrors theapp/directory to the server. The trailing slashes matter:./app/to/var/www/myapp/copies the contents, not the folder itself.
Warning: --delete removes files on the server that no longer exist in the repository. If /var/www/myapp/ also holds uploads, logs or a .env file, exclude them with --exclude '.env' --exclude 'uploads/' or point the sync at a release directory instead.
If your build produces artifacts, add a build step before the sync and copy the output directory, for example ./dist/. For container-based apps, the same pattern works with a docker compose up -d --build command in the final step — see Deploy a self-hosted app with Docker Compose.
Check it works
- Commit and push the workflow file:
git add .github/workflows/deploy.yml
git commit -m "Add deploy workflow"
git push origin main- Open the Actions tab in GitHub and watch the run. Each step should show a green tick within a minute or two.
- Confirm the files arrived and the service is running:
ssh -i ~/.ssh/gh_deploy deploy@203.0.113.10 'ls -l /var/www/myapp; systemctl is-active myapp'- Make a small change, push again and check that the timestamp on the server updates. That proves the pipeline, not just the first manual run.
Troubleshooting
Permission denied (publickey)— the secret holds a truncated key, or the wrong user. Check thatVPS_SSH_KEYincludes the header and footer lines, and read/var/log/auth.logon the VPS for the exact reason.Host key verification failed— the server's host key changed or theVPS_KNOWN_HOSTSsecret is stale. Regenerate it withssh-keyscan -H 203.0.113.10and update the secret.rsync: command not found— rsync is missing on the VPS. Install it withsudo apt install rsyncorsudo dnf install rsync.sudo: a terminal is required— the sudoers rule is missing or the command path differs. Confirm withsudo -l -U deployand match the exact binary path.- Workflow succeeds but the site is unchanged — the rsync source or destination path is wrong, or the service reads from a different directory. Print both paths in the workflow with
pwdandlsto compare.
Next steps
- Harden the account you just created: Disable root login and password authentication over SSH.
- Plan key replacement before the CI key ages out: Enforcing SSH key rotation policies.
- Move files interactively when you need to: Secure file transfer with SFTP over SSH keys.
