Deploy to Your VPS Automatically with GitHub Actions and SSH

Logging into the server, running git pull and restarting the app by hand works until the day you forget a step. A GitHub Actions workflow runs the same steps every time you push. This guide builds one that connects to your VPS over SSH with a dedicated user and a restricted key, keeps credentials in GitHub secrets, and leaves you a way to roll back.
How the pieces fit together
You push to
main.GitHub starts a runner, checks out the code and runs your tests.
The runner connects to the server over SSH as a user called
deploy.Either the server pulls and builds the new code, or the runner uploads a finished build with
rsync.The app restarts, and the workflow checks that it responds.
The security goal is simple: if the private key stored in GitHub ever leaks, the damage should be limited to "someone can trigger a deploy", not "someone has a shell on the server".
Step 1: Create a deploy user
Do not deploy as root or as your personal account. A separate user owns only the application folder.
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /var/www/app
sudo chown -R deploy:deploy /var/www/app
Do not add this user to the sudo group. If the deploy needs to restart a service, allow that one command only. Edit the rule with visudo, which checks the syntax before saving:
sudo visudo -f /etc/sudoers.d/deploy
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp
The path must match exactly. If the script calls systemctl from another path or with other arguments, sudo asks for a password and the job fails with sudo: a terminal is required to read the password. If you run the app with PM2 under the deploy user, you do not need sudo at all.
Step 2: Generate a key just for deploys
Create a new key pair on your own machine. Do not reuse your personal SSH key, and do not set a passphrase, because the runner cannot type one.
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key -N ""
This creates deploy_key (private, goes to GitHub) and deploy_key.pub (public, goes to the server).
Restrict what the key can do
OpenSSH lets you put options in front of a key in authorized_keys. The most useful one here is a forced command: whatever the client asks to run, the server runs only your script.
sudo -u deploy mkdir -p /home/deploy/.ssh
sudo -u deploy nano /home/deploy/.ssh/authorized_keys
command="/var/www/app/deploy.sh",restrict ssh-ed25519 AAAAC3Nza... github-actions-deploy
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
restrict turns off port forwarding, agent forwarding, X11 and terminal allocation. command= means a stolen key can only start a deploy. You could add from="..." to limit source IPs, but GitHub-hosted runners use large, changing address ranges, so that is only practical with a self-hosted runner.
A forced command does not work with rsync, because rsync needs to run its own process on the server. If you choose the rsync approach below, use rrsync instead, which limits the key to one directory:
command="rrsync /var/www/app/releases",restrict ssh-ed25519 AAAAC3Nza... github-actions-upload
On recent Debian and Ubuntu releases, rrsync is installed with the rsync package. On older releases it ships as a script under /usr/share/doc/rsync/scripts/.
Step 3: Store secrets in GitHub
In the repository, open Settings → Secrets and variables → Actions and add:
SSH_PRIVATE_KEY: the full contents ofdeploy_key, including the BEGIN and END lines.SSH_HOSTandSSH_USER: the server address anddeploy.SSH_KNOWN_HOSTS: the server's host key, so the runner can verify it is talking to your server.
Get the host key and check its fingerprint against the one on the server:
# from your machine
ssh-keyscan -t ed25519 203.0.113.10
# on the server, compare with
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
Do not use StrictHostKeyChecking=no to make the error go away. It removes the protection against connecting to the wrong machine. Delete the local deploy_key file once the secret is saved.
For extra control, create an Environment called production under Settings → Environments, move the secrets there and add required reviewers. Deploys then wait for approval. Note that secrets are not passed to workflows triggered by pull requests from forks.
Step 4, option A: the server pulls and builds
This is the simplest setup and works well with the forced command. The server needs read access to the repository. Add a second key, generated on the server, as a read-only Deploy key under the repository's settings.
The script the forced command runs, /var/www/app/deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
exec 9>/tmp/deploy.lock
flock -n 9 || { echo "deploy already running"; exit 1; }
cd /var/www/app/repo
git rev-parse HEAD > /var/www/app/PREVIOUS_SHA
git fetch --prune origin
git reset --hard origin/main
npm ci
npm run build
sudo /usr/bin/systemctl restart myapp
echo "deployed $(git rev-parse --short HEAD)"
chmod +x /var/www/app/deploy.sh
The lock stops two deploys from running at once. Saving the previous commit hash makes rollback easy. The workflow, .github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- name: Configure SSH
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Deploy
run: ssh -i ~/.ssh/deploy_key ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}
- name: Health check
run: curl -fsS --retry 5 --retry-delay 3 https://example.com/health
Passing secrets through env instead of pasting them into the run line keeps them out of the command text. GitHub also masks secret values in the logs.
Step 4, option B: build on the runner, upload with rsync
If the server is small, building there competes with live traffic for CPU and memory. Building on the runner and uploading the result keeps the server's work to a minimum.
- run: npm run build
- name: Upload release
run: |
rsync -az -e "ssh -i ~/.ssh/deploy_key" dist/ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ github.sha }}/
Because rrsync limits the key to /var/www/app/releases, the remote path is relative to that folder. Each release lands in a folder named after its commit. To switch to it, use a second key whose forced command points the current symlink to the newest release and restarts the app. Two keys with one job each are easier to reason about than one key that can do everything.
Rollback: plan it before you need it
Option A: the script saved
PREVIOUS_SHA. Rolling back meansgit reset --hard $(cat /var/www/app/PREVIOUS_SHA), a rebuild and a restart. Run it from a second script so you do not have to type it under pressure.Option B: old releases are still on disk. Point
currentat the previous folder and restart. This takes seconds because nothing is rebuilt.Automatic: if the health check step fails, a following step with
if: failure()can call the rollback.
Database migrations are the exception. Rolling back code does not undo a migration, so write migrations that the previous version of the app can still work with.
Common errors
Permission denied (publickey): the public key is not in the rightauthorized_keys, or the permissions on.sshare too open. Check/var/log/auth.logon the server.Load key "/home/runner/.ssh/deploy_key": error in libcrypto: the secret was pasted incompletely or lost its line breaks. Paste the whole file again.Host key verification failed.:SSH_KNOWN_HOSTSis empty or does not match. This also happens after reinstalling the server, because the host key changes.rsync: connection unexpectedly closed: rsync is not installed on the server, or a forced command that is notrrsyncblocks it.
Should SSH stay open to the internet?
It depends. GitHub-hosted runners connect from changing IP addresses, so you cannot easily allow only them. Common choices are key-only SSH with password login disabled plus a tool such as fail2ban, or a self-hosted runner inside your network so SSH can be limited to private addresses. Both are reasonable. Pick the one your team can maintain.
Run it on IM HOST
A Linux VPS starts from $5/month with full root access on KVM, so you can create users, edit sudoers and lock down SSH as described here. If you build on the server, Cloud VPS plans give you dedicated vCPU and RAM, from $16/month, with a free automatic backup every 3 days. There are no setup fees, support engineers are available 24/7, and if your app lives elsewhere today, our free migration service moves files and databases without downtime.
More from our blog
Discover more practical guides and product insights from the IM Host team.
View all articles