Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
Deploy to a VPS with GitHub Actions over SSH: a safe 2026 setup A working GitHub Actions workflow that deploys to a Linux VPS over SSH: a restricted deploy user, pinned host keys, atomic release folders, health checks and…
A common starting point: one VPS, one app, and a deploy that means someone logging in, running git pull, rebuilding and restarting. It works until that person is on leave, or a build fails halfway and the site serves a broken mix of old and new files. GitHub Actions fixes both, if the SSH side is set up with care.
The short answer: to deploy to a VPS with GitHub Actions, build and test on a GitHub-hosted runner, then connect over SSH with a dedicated deploy key stored as a secret. Copy the build into a new release folder, switch a symlink to it, restart the service and check a health endpoint. Pin the host key and keep the deploy user's permissions minimal.
Below is the setup we use for Node.js and Python apps run by systemd, with a short Docker variant near the end.
Prepare the server: a deploy user that can do one job Do not deploy as root, and do not reuse a person's account. Create a deploy user that owns the app directory and can restart exactly one service. Generate the key pair on your own machine, not on the server, and use it for nothing else.
# on your laptop: a key used only by this pipeline
ssh-keygen -t ed25519 -f ./deploy_key -C "github-actions-deploy" -N ""
# on the server, as an admin user
sudo adduser --disabled-password --gecos "" deploy
sudo mkdir -p /srv/myapp/releases /srv/myapp/shared /home/deploy/.ssh
sudo chown -R deploy:deploy /srv/myapp /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
# allow restarting this one service, nothing else
echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.service' \
| sudo tee /etc/sudoers.d/deploy
sudo chmod 440 /etc/sudoers.d/deploy
sudo visudo -cf /etc/sudoers.d/deployAdd the public key to /home/deploy/.ssh/authorized_keys with the restrict option in front of it. That disables port, agent and X11 forwarding and terminal allocation for this key, while still allowing commands and rsync to run. The file must be owned by deploy and not writable by anyone else, or sshd will ignore it.
# /home/deploy/.ssh/authorized_keys (owner deploy, mode 600)
restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... github-actions-deploy
# then, as the admin user
sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keysMake sure rsync is installed on the server (sudo apt install rsync on Ubuntu or Debian). The systemd unit for the app should point its WorkingDirectory and ExecStart at /srv/myapp/current, which is the symlink the deploy will switch.
Store the key and the host fingerprint in GitHub Two secrets matter: the private key, and the server's host key, so the runner can check it is talking to your server and not something in the middle. Many tutorials skip the second with StrictHostKeyChecking=no. Don't. Scan the host key using exactly the name or IP you will put in DEPLOY_HOST, and compare its fingerprint with the one the server reports locally.
# from your laptop: save the known_hosts line and print its fingerprint
ssh-keyscan -t ed25519 vps.example.com | tee known_hosts.line | ssh-keygen -lf -
# on the server (console or an existing session): the fingerprints must match
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pubIn the repository settings, create an environment called production and add these to it:
SSH_PRIVATE_KEY (secret): the full contents of deploy_key, including the BEGIN and END lines. SSH_KNOWN_HOSTS (secret): the contents of known_hosts.line. DEPLOY_HOST (variable): the server's host name or IP address. Optionally, required reviewers and a deployment branch rule limiting it to main. Availability of these rules for private repositories depends on your GitHub plan. Keeping deploy secrets in an environment with a branch rule means a workflow running on a feature branch cannot read them, because GitHub refuses to start a job for that environment from any other branch. Without the rule, any branch that names the environment gets the secrets. Delete deploy_key from your laptop once it is stored.
The workflow This workflow runs on every push to main and can also be started by hand. It builds and tests, uploads the build into a timestamped release folder, then calls a script on the server to switch over.
name: deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-production
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- run: npm prune --omit=dev
- name: Configure SSH
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/deploy_key ~/.ssh/known_hosts
cat > ~/.ssh/config <<CONF
Host prod
HostName $DEPLOY_HOST
User deploy
IdentityFile ~/.ssh/deploy_key
IdentitiesOnly yes
CONF
- name: Upload and activate release
run: |
RELEASE="$(date -u +%Y%m%d%H%M%S)-${GITHUB_SHA::7}"
rsync -az --delete dist node_modules package.json \
"prod:/srv/myapp/releases/$RELEASE/"
ssh prod "/srv/myapp/bin/activate $RELEASE"A few details are deliberate. The concurrency group stops two pushes deploying at once, and cancel-in-progress is false so a deploy is never killed halfway. Secrets go through env rather than being pasted into the script, which avoids quoting problems and script injection. The permissions block keeps GITHUB_TOKEN read-only. For a stricter setup, pin each action to a full commit SHA.
Atomic switch, health check and rollback The activate script lives on the server at /srv/myapp/bin/activate, owned by deploy and made executable. Keeping the logic on the server means the workflow stays short and the same script can be run by hand in an emergency.
#!/usr/bin/env bash
set -euo pipefail
APP=/srv/myapp
RELEASE="$1"
[[ "$RELEASE" =~ ^[0-9]{14}-[0-9a-f]{7}$ ]] || { echo "bad release name"; exit 1; }
NEW="$APP/releases/$RELEASE"
[[ -d "$NEW" ]] || { echo "no such release"; exit 1; }
PREV=""
[[ -L "$APP/current" ]] && PREV="$(readlink -f "$APP/current")"
ln -sfn "$APP/shared/.env" "$NEW/.env"
switch_to() {
ln -sfn "$1" "$APP/current.tmp"
mv -T "$APP/current.tmp" "$APP/current"
sudo systemctl restart myapp.service
}
switch_to "$NEW"
if ! curl -fsS --retry 10 --retry-delay 2 --retry-all-errors \
http://127.0.0.1:3000/healthz > /dev/null; then
echo "health check failed"
if [[ -n "$PREV" && -d "$PREV" ]]; then
echo "rolling back to $PREV"
switch_to "$PREV"
fi
exit 1
fi
# keep the five most recent releases (names sort by time)
ls -1d "$APP"/releases/*/ | sort -r | tail -n +6 | xargs -r rm -rf
echo "deployed $RELEASE"The mv -T step replaces the current symlink in one rename, so it never points nowhere. The name check stops odd arguments, and PREV is only set when a current symlink exists, so a failed first deploy does not roll back to itself. If the health check fails, the previous release goes back and the script exits non-zero, which turns the workflow run red. A manual rollback is the same switch with an older release name.
Make the health endpoint check something real, such as a database connection, rather than returning OK unconditionally. Otherwise the rollback will rarely trigger when it should.
If the app runs in Docker For containerised apps the pattern changes slightly. Build the image in the workflow, push it to a registry such as GitHub Container Registry tagged with the commit SHA, then tell the server to pull and start that tag. For a private image, log the server in to ghcr.io once with a read-only token (a personal access token with read:packages). The Compose file references the tag through a variable, for example image: ghcr.io/your-org/myapp:${IMAGE_TAG}.
ssh prod "cd /srv/myapp && IMAGE_TAG=${GITHUB_SHA} docker compose up -d --pull always"Be aware that membership of the docker group is effectively root on that host. If the deploy user needs Docker, consider a forced command in authorized_keys, for example restrict,command="/srv/myapp/bin/deploy-image" ssh-ed25519 AAAA..., so the key can only run one script. That script reads the requested tag from SSH_ORIGINAL_COMMAND and checks it against a pattern such as ^[0-9a-f]{40}$ before use.
Firewalls, private servers and other gotchas GitHub-hosted runners do not use a small fixed set of IP addresses. GitHub publishes the ranges, but they are large and change, so allowlisting them on port 22 is rarely practical. Use key-only SSH with fail2ban, or keep SSH off the internet entirely. For servers on a private network, run a self-hosted runner inside that network, or join the runner to your Tailscale tailnet with Tailscale's GitHub Action, using an OAuth client or an ephemeral, tagged auth key. Secrets are not passed to workflows triggered by pull requests from forks, so never put a deploy step on pull_request, and avoid pull_request_target for anything that checks out and runs the pull request's code. Building on a small VPS can starve the live app of memory. Build on the runner and ship artefacts or images. Shared state such as uploads, logs and the .env file belongs in /srv/myapp/shared, not inside a release folder that will be deleted. Rotate the deploy key when someone with access to repository settings leaves, and whenever you rebuild the server. Checklist before you trust it A dedicated deploy user with a restricted key and sudo for one systemctl command only. The host key pinned in known_hosts, with its fingerprint checked against the server. Secrets stored in a production environment limited to the main branch. A concurrency group on the deploy job. Releases in timestamped folders, switched with a symlink, with the last few kept. A health check that tests real dependencies and rolls back on failure. A rollback you have actually run once on purpose, not only read about. That last item is the one most teams skip. Break a deploy deliberately on staging and watch the rollback happen. With several apps or servers, the same pieces scale with reusable workflows and one environment per target; the deploy user, pinned host key and health-checked switch stay the same.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789