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
- 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).
- The runner connects to the server over SSH with a dedicated key and copies the files to an
incomingdirectory withrsync, which transfers only the changes. - A script on the server copies
incomingto a newreleases/<date>-<commit>directory, points thecurrentsymlink at it and restarts the service. - 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 -sfncreates a new symlink next to the old one, andmv -Treplacescurrentin a single operation, so there is no moment when the symlink is missing. - Shared files. Keep anything that should not change between releases (a
.envwith passwords, user uploads) inshared/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
healthzfile at the repository root; in a real application, it is a/healthzhandler 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:
concurrencyqueues deployments of the same branch, so two pushes in a row are not deployed at the same time.cancel-in-progress: falseprevents a deployment from being cut off halfway.permissions: contents: readmeans the GitHub token in this job can only read the code.- Secrets are passed through
envinstead 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 witherror in libcrypto.~/.ssh/configdefines a short host nameserverwith the address, port and key, sossh serveris enough from then on. A non-standard port is set here once.StrictHostKeyChecking yesandBatchMode yesforbid connecting to a server with an unknown key and asking any questions.actions/checkout@v7is the current major version as of October 2026 and runs on Node.js 24.rsyncandsshare already installed in theubuntu-24.04image.$APP_DIRin thesshcommands 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.