Deploying to a VPS from GitHub Actions over SSH is the simplest way to ship a project automatically: you push a commit to the main branch, and GitHub uploads the files to the server, switches the application to the new version and checks that it responds. If the new version fails to come up, the server goes back to the previous one and the GitHub job is marked as failed. Below is the whole setup: a dedicated key and user for deployment, secrets, the workflow, release directories with rollback, and two environments, production and test.

Tested on servers running Ubuntu 24.04 and 26.04: the workflow steps were run from a separate container with the same ssh and rsync versions as in the GitHub ubuntu-24.04 image, and the workflow syntax was checked with actionlint 1.7.12. Replace 203.0.113.10 with your server's address and example.com with your domain.

Prerequisites

  • An Ubuntu server you log in to with a key and where you can run sudo; see initial server setup.
  • A GitHub repository where you can change the settings (Settings).
  • An application that runs on the server as a systemd service. Below, a simple service serves as the example; how to run Next.js this way is covered in Deploying Next.js to a VPS (in Russian).

How the deployment works

  1. GitHub Actions is GitHub's built-in system that runs commands on a temporary virtual machine, the runner, in response to an event (for example, a push to a branch).
  2. The runner connects to the server over SSH with a dedicated key and copies the files to an incoming directory with rsync, which transfers only the changes.
  3. A script on the server copies incoming to a new releases/<date>-<commit> directory, points the current symlink at it and restarts the service.
  4. The script waits for the application to respond at a health check URL. If it does not, the symlink goes back to the previous release and the job fails.

Old releases stay on disk, so a rollback means switching a symlink, not running a new build.

Step 1. Create a deploy user on the server

The runner should log in neither as root nor as your admin user. Create a separate deploy user with no password and no sudo rights:

sudo useradd -m -s /bin/bash deploy
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
sudo install -d -o deploy -g deploy /srv/app /srv/app/prod /srv/app/test

If you followed the initial setup guide and already named your sudo admin deploy, pick another name for deployment, such as ci, and use it everywhere below.

/srv/app/prod will hold the production version (the main branch), and /srv/app/test the test version (the dev branch). Each environment gets a settings file for the release script:

sudo tee /srv/app/prod/release.conf > /dev/null <<'EOF'
SERVICE=app@prod.service
HEALTH_URL=http://127.0.0.1:8080/healthz
KEEP=5
EOF
sudo tee /srv/app/test/release.conf > /dev/null <<'EOF'
SERVICE=app@test.service
HEALTH_URL=http://127.0.0.1:8081/healthz
KEEP=3
EOF
echo 'PORT=8080' | sudo tee /srv/app/prod/app.env
echo 'PORT=8081' | sudo tee /srv/app/test/app.env
sudo chown deploy:deploy /srv/app/*/release.conf /srv/app/*/app.env

KEEP is how many recent releases to keep; HEALTH_URL is the address the script uses to check that the application is alive.

Step 2. Create a systemd service for the application

One template service handles both environments: in the name app@prod.service, the part after @ replaces %i. As an example, the service serves the release files with Python's built-in web server; replace ExecStart with the command that starts your application.

sudo tee /etc/systemd/system/app@.service > /dev/null <<'EOF'
[Unit]
Description=Application (%i)
After=network.target

[Service]
User=deploy
Group=deploy
EnvironmentFile=/srv/app/%i/app.env
WorkingDirectory=/srv/app/%i/current
ExecStart=/usr/bin/python3 -m http.server ${PORT} --bind 127.0.0.1
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable app@prod.service app@test.service

Do not start the services yet: the current directory does not exist, and the first deployment will create it.

Step 3. Allow restarting the service without a password

The deploy user needs exactly one action with root privileges: restarting its own service. Allow exactly these two commands:

echo 'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app@prod.service, /usr/bin/systemctl restart app@test.service' \
  | sudo tee /etc/sudoers.d/deploy
sudo chmod 440 /etc/sudoers.d/deploy
sudo visudo -cf /etc/sudoers.d/deploy

The last command should print parsed OK. An error in a sudoers file can break sudo entirely, so always check it.

Check: sudo -l -U deploy shows the two allowed commands. Any other sudo command run as deploy is refused: on Ubuntu 24.04 with sudo: a password is required, and on 26.04, where sudo-rs replaces sudo, with I'm sorry deploy. I'm afraid I can't do that.

Step 4. Create a dedicated SSH key for deployment

On your computer, create an ed25519 key without a passphrase, since the runner cannot type one:

ssh-keygen -t ed25519 -N "" -C "github-actions-deploy" -f ./deploy_key

You get two files: deploy_key (the private key, which goes into GitHub secrets) and deploy_key.pub (the public key, which goes to the server). This key is used only for deployment: if it leaks, you revoke it by deleting one line, without touching your personal keys. How SSH keys work is covered in detail in the SSH key authentication article.

Copy the public key to the server as your sudo user (here and below, replace admin with your user name and 22746 with your SSH port; if you use the standard port, write 22):

scp -P 22746 deploy_key.pub admin@203.0.113.10:

Then, on the server, add it for the deploy user with the word restrict in front:

echo "restrict $(cat deploy_key.pub)" | sudo tee -a /home/deploy/.ssh/authorized_keys > /dev/null
sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys
rm deploy_key.pub

restrict disables port forwarding, agent forwarding and the terminal for this key: running commands and rsync works, while opening an interactive session or a tunnel does not. Check:

ssh -i deploy_key -p 22746 deploy@203.0.113.10 'whoami; sudo -n -l'

The output is deploy followed by the list of two allowed commands.

Step 5. Pin the server's host key: known_hosts

On the first connection, SSH asks whether to trust the server's key. The runner cannot answer, so advice online often suggests StrictHostKeyChecking=no. That turns verification off completely, and the runner will send your code to anyone who answers at that address. The right way is to save the server's key in a secret in advance and require an exact match.

On your computer:

ssh-keyscan -p 22746 -t ed25519 203.0.113.10 > known_hosts_deploy
ssh-keygen -lf known_hosts_deploy

The file contains a single line like [203.0.113.10]:22746 ssh-ed25519 AAAA… (on port 22, without the brackets and port), and the second command shows its SHA256:… fingerprint. ssh-keyscan records the key of whoever answered, so compare the fingerprint with the real one on the server itself, through a connection you already trust or your hosting provider's web console:

ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

The SHA256:… lines must match. If the server is reinstalled, the key changes; repeat this step then.

Step 6. Add secrets to the repository

In the repository, open Settings → Secrets and variables → Actions → New repository secret and add:

Secret Value
DEPLOY_HOST 203.0.113.10
DEPLOY_PORT 22746 (or 22)
DEPLOY_USER deploy
DEPLOY_SSH_KEY the entire contents of deploy_key, including the BEGIN and END lines
DEPLOY_KNOWN_HOSTS the contents of known_hosts_deploy

GitHub masks secret values in logs and does not show them even to the repository owner. After saving, delete deploy_key from your computer or move it to a password manager.

Step 7. Release script with rollback

Add a deploy/release.sh file to the repository. It runs on the server as deploy:

#!/usr/bin/env bash
# Release deployment and rollback.
#   release.sh deploy   /srv/app/prod <version>
#   release.sh rollback /srv/app/prod
set -euo pipefail

ACTION="${1:?specify deploy or rollback}"
APP_DIR="${2:?specify the app directory, e.g. /srv/app/prod}"
cd "$APP_DIR"
source ./release.conf              # SERVICE, HEALTH_URL, KEEP
mkdir -p releases shared

# Atomically point current at a release and restart the service
switch_to() {
  ln -sfn "releases/$1" current.new
  mv -T current.new current
  sudo -n systemctl restart "$SERVICE"
}

# Wait for the application to respond at the health check URL (up to 30 seconds)
healthy() {
  for _ in $(seq 1 15); do
    curl -fs -o /dev/null --max-time 3 "$HEALTH_URL" && return 0
    sleep 2
  done
  return 1
}

case "$ACTION" in
deploy)
  VERSION="${3:?specify the version, e.g. a commit hash}"
  NEW="$(date -u +%Y%m%d-%H%M%S)-${VERSION:0:7}"
  PREV="$(basename "$(readlink current)" 2>/dev/null || true)"

  cp -a incoming "releases/$NEW"
  [ -e shared/.env ] && ln -sfn "$APP_DIR/shared/.env" "releases/$NEW/.env"
  # Build or install dependencies if needed, for example:
  # (cd "releases/$NEW" && npm ci --omit=dev)

  echo "==> switching to $NEW"
  switch_to "$NEW"
  if ! healthy; then
    echo "==> $NEW is not responding at $HEALTH_URL" >&2
    if [ -n "$PREV" ]; then
      switch_to "$PREV"
      rm -rf "releases/$NEW"
      echo "==> restored $PREV, removed $NEW" >&2
    fi
    exit 1
  fi
  echo "==> $NEW is up"

  # Keep the last KEEP releases
  ls -1 releases | sort | head -n -"${KEEP:-5}" | while read -r old; do
    rm -rf "releases/$old"
  done
  ;;
rollback)
  CUR="$(basename "$(readlink current)")"
  PREV="$(ls -1 releases | sort | grep -x -B1 "$CUR" | head -n1)"
  if [ -z "$PREV" ] || [ "$PREV" = "$CUR" ]; then
    echo "==> no release older than $CUR" >&2
    exit 1
  fi
  echo "==> rollback: $CUR -> $PREV"
  switch_to "$PREV"
  if ! healthy; then
    switch_to "$CUR"
    echo "==> $PREV is not responding, kept $CUR" >&2
    exit 1
  fi
  echo "==> $PREV is up"
  ;;
*)
  echo "unknown action: $ACTION" >&2
  exit 1
  ;;
esac

What matters here:

  • Atomic switch. ln -sfn creates a new symlink next to the old one, and mv -T replaces current in a single operation, so there is no moment when the symlink is missing.
  • Shared files. Keep anything that should not change between releases (a .env with passwords, user uploads) in shared/ and link it in.
  • Build. If the application needs dependencies or a build on the server, put the command where the comment is. A heavy build is better done in the workflow itself, uploading the finished result.
  • Health check URL. The application needs a URL that returns 200 only when everything works. In the example, it is a healthz file at the repository root; in a real application, it is a /healthz handler that also checks the database connection.

Step 8. Workflow: deploy on push to main and dev

The .github/workflows/deploy.yml file:

name: deploy

# main → /srv/app/prod (example.com), dev → /srv/app/test (test.example.com)
on:
  push:
    branches: [main, dev]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: deploy-${{ github.ref_name }}
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    env:
      APP_DIR: ${{ github.ref_name == 'main' && '/srv/app/prod' || '/srv/app/test' }}
      SITE_URL: ${{ github.ref_name == 'main' && 'https://example.com' || 'https://test.example.com' }}
    steps:
      - uses: actions/checkout@v7

      # A build can go here: npm ci && npm run build, etc.

      - name: Set up SSH
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
          HOST: ${{ secrets.DEPLOY_HOST }}
          PORT: ${{ secrets.DEPLOY_PORT }}
          SSH_USER: ${{ secrets.DEPLOY_USER }}
        run: |
          install -d -m 700 ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          cat > ~/.ssh/config <<EOF
          Host server
            HostName $HOST
            Port $PORT
            User $SSH_USER
            IdentityFile ~/.ssh/deploy_key
            IdentitiesOnly yes
            BatchMode yes
            StrictHostKeyChecking yes
          EOF

      - name: Upload files
        run: |
          ssh server "mkdir -p $APP_DIR/incoming"
          rsync -az --delete --exclude .git --exclude .github ./ "server:$APP_DIR/incoming/"

      - name: Switch release
        run: ssh server "bash $APP_DIR/incoming/deploy/release.sh deploy $APP_DIR $GITHUB_SHA"

      - name: Check the site from outside
        run: curl -fsS --retry 5 --retry-delay 3 --retry-all-errors -o /dev/null "$SITE_URL/healthz"

The less obvious parts:

  • concurrency queues deployments of the same branch, so two pushes in a row are not deployed at the same time. cancel-in-progress: false prevents a deployment from being cut off halfway.
  • permissions: contents: read means the GitHub token in this job can only read the code.
  • Secrets are passed through env instead of being inserted directly into the command, so special characters in a value cannot break the script.
  • printf '%s\n' adds a newline at the end of the key. Without it, OpenSSH refuses to read the key with error in libcrypto.
  • ~/.ssh/config defines a short host name server with the address, port and key, so ssh server is enough from then on. A non-standard port is set here once. StrictHostKeyChecking yes and BatchMode yes forbid connecting to a server with an unknown key and asking any questions.
  • actions/checkout@v7 is the current major version as of October 2026 and runs on Node.js 24. rsync and ssh are already installed in the ubuntu-24.04 image.
  • $APP_DIR in the ssh commands is expanded on the runner before the command is sent to the server, which is intended. That is why actionlint with shellcheck shows hint SC2029 on these lines; you can leave it as is.
  • The last step checks the site from outside, through the domain, Nginx and HTTPS. The script on the server checks only the application itself.

Push a commit to dev, then to main, and open the Actions tab. The log of the "Switch release" step will show:

==> switching to 20261011-112455-e2f7ec6
==> 20261011-112455-e2f7ec6 is up

Check on the server:

ls /srv/app/prod/releases
readlink /srv/app/prod/current
curl -s http://127.0.0.1:8080/healthz

You will see the release directories, current pointing at the latest one, and the application responding.

If the new version fails to start

If the application does not respond for 30 seconds after the switch, the script restores the previous release on its own and exits with an error, and the GitHub job turns red:

==> switching to 20261011-112458-b87c621
==> 20261011-112458-b87c621 is not responding at http://127.0.0.1:8080/healthz
==> restored 20261011-112455-e2f7ec6, removed 20261011-112458-b87c621
Error: Process completed with exit code 1.

Meanwhile the site keeps running on the previous version. The script deletes the failed release directory so that you do not roll back to it by mistake later. The service log shows the cause: journalctl -u app@prod -n 50.

Roll back to the previous version

Quick rollback on the server is for when the new version started but does not work correctly. Log in to the server as your own user and run the script as deploy:

sudo -u deploy bash /srv/app/prod/current/deploy/release.sh rollback /srv/app/prod
==> rollback: 20261011-112455-e2f7ec6 -> 20261011-112452-eb6d1cd
==> 20261011-112452-eb6d1cd is up

Each subsequent call rolls back one more release, as long as there are any; when there is nothing older, the script replies no release older than …. If the previous release does not respond at the health check URL, the script restores the current one and exits with an error.

Rollback through git is for when the bug must be removed from history so that the next deployment does not bring it back:

git revert <commit-hash>
git push

git revert creates a new commit that undoes the changes, and the workflow deploys it like any other version. A quick rollback on the server does not change the repository: the next push to main will deploy the branch's code again, so revert or fix the commit afterward anyway.

A separate test server: environments

If the test environment lives on another server, it is more convenient to define GitHub environments instead of computing addresses in the workflow: sets of secrets and variables for each environment (Settings → Environments). Create production and test, put their own DEPLOY_* secrets and the APP_DIR and SITE_URL variables in each, and replace the beginning of the job:

jobs:
  deploy:
    runs-on: ubuntu-24.04
    environment:
      name: ${{ github.ref_name == 'main' && 'production' || 'test' }}
      url: ${{ vars.SITE_URL }}
    env:
      APP_DIR: ${{ vars.APP_DIR }}
      SITE_URL: ${{ vars.SITE_URL }}

Environment secrets with the same names override repository secrets; the other steps stay the same. For production, you can require manual approval of each deployment and allow it only from the main branch. One limitation: according to GitHub's documentation, on the Free plan environments are available only in public repositories; private ones need GitHub Pro or Team.

rsync or git pull

Instead of uploading files from the runner, you can run git pull on the server. Then the server needs access to the repository (a separate read-only deploy key in the repository settings), the build runs on the server itself, and a rollback means a git checkout of an old commit followed by a rebuild. The rsync approach is simpler in terms of permissions: the server never connects to GitHub, and you can build on the runner, where memory and CPU are not an issue. Release directories give you a rollback without a rebuild in both cases.

Troubleshooting

What you see in the log What to do
No ED25519 host key is known for [203.0.113.10]:22746, Host key verification failed DEPLOY_KNOWN_HOSTS is empty or wrong, the port is different, or the server was reinstalled. Repeat step 5 with the correct port
Load key "…/deploy_key": error in libcrypto The key was not copied completely. Paste the whole file into the secret, including the -----BEGIN and -----END lines
Permission denied (publickey) The public key is missing from /home/deploy/.ssh/authorized_keys, the permissions are wrong (700 on .ssh, 600 on the file), or DEPLOY_USER has a different name
The step hangs, then Connection timed out The firewall blocks the SSH port. GitHub runner addresses change, so the port must be open to everyone: sudo ufw allow 22746/tcp
sudo: a password is required or I'm afraid I can't do that The command in the script does not match the line in /etc/sudoers.d/deploy character for character: check the service name and the /usr/bin/systemctl path
PTY allocation request failed when logging in with the deploy key This is intended: restrict disables the terminal. Use your own key to work on the server
is not responding at http://127.0.0.1:8080/healthz The application did not start: journalctl -u app@prod -n 50. Check that the address in release.conf matches the application's port

What's next

  • Deploying Next.js to a VPS: Node.js, systemd, Nginx and HTTPS (in Russian): how to run the application this workflow deploys.
  • Initial server setup on Ubuntu: firewall, fail2ban and updates before the first deployment.
  • SSH key authentication: how keys work and what to do if the server will not let you in.
  • If the application runs in containers: Docker Compose in production, updating the stack without downtime.
  • Your own Git server with CI on your own VPS instead of GitHub: Forgejo in Docker with Actions.
  • If you would rather not write workflows and release scripts: a self-hosted platform with deployment by git push, Coolify on a VPS.
  • A Python application deployed by a workflow like this: FastAPI and Django on a VPS; copying files: rsync.