Some links on this page are affiliate links: if you sign up through them, this site earns a commission at no extra cost to you. Prices and recommendations are not affected.
Every “deploy a static site with GitHub Actions” tutorial ends the same way: generate an SSH key, paste the private half into repository secrets, rsync the build to your server. It works on the first try, which is the problem. What you just created is an interactive shell account on your server, held by a system that runs code from pull requests.
This post is the version with the guard rails: a key that can do exactly one thing, a host key you pinned yourself, and three checks that stop a broken build from replacing a working site. It is the pipeline that ships this blog, and it is maybe fifteen extra minutes of work.
The server side assumes you have a box. If you don’t, we compared the cheap tiers in Best VPS for self-hosting in 2026.
What the naive deploy key actually grants
An entry in ~/.ssh/authorized_keys with no options is a login. Anything holding that private key can:
- open an interactive shell as that user,
- read every file that user can read, including other projects on the same box and any
.envlying around, sudoif the user has it,- forward ports and use your server as a jump host into whatever it can reach.
None of that is needed to copy a directory of HTML. And the private key lives in a CI system where a workflow change, a compromised third-party action, or a misconfigured pull_request_target trigger can read it.
We ran exactly this setup for months without thinking about it, which is the honest reason this post exists.
Lock the key to one command
OpenSSH lets you attach options to each key in authorized_keys. The one that matters is command=, which replaces whatever the client asked to run with what you specify:
command="/usr/bin/rrsync -wo /home/deploy/site",restrict ssh-ed25519 AAAA... github-actions
Piece by piece:
rrsyncships with rsync (/usr/bin/rrsyncon Debian and Ubuntu; it may be a gzipped script under/usr/share/doc/rsync/scripts/on older releases). It is a wrapper that accepts only rsync’s server-side invocation and refuses everything else.-womeans write-only: the key can upload into the directory and cannot download from it. If your deploy only pushes, take the stricter option.- The path argument is a jail. Paths outside it are rejected, including anything clever with
... restrictdisables port forwarding, agent forwarding, X11 and PTY allocation in one word. On any OpenSSH from the last several years, prefer it over listingno-port-forwarding,no-agent-forwarding,...by hand.
Test it the direct way, from your laptop with that private key:
ssh -i deploy_key deploy@your-server 'id' # must fail
rsync -rltz -e "ssh -i deploy_key" ./public/ deploy@your-server:/ # must succeed
The first command failing is the whole point. If it prints a user id, the options are not being applied - check that the whole thing is on one line and that the line starts with command=, not with the key type.
Note the destination path in the rsync call: it is relative to the jail, so / means the directory you locked the key into.
Pin the host key, don’t disable the check
The other half of the tutorials’ shortcut is StrictHostKeyChecking=no, which turns off the check that the server you are shipping to is the server you think it is. Pin it instead. On the server:
ssh-keyscan -t ed25519 your-server.example
Put that line into a repository secret and write it to known_hosts in the job. Now a changed host key fails the deploy loudly instead of silently shipping your site to whoever answers on that address.
The workflow
name: Build and deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
submodules: recursive
- name: Build
run: your-build-command
- name: Guard against an empty build
run: |
set -euo pipefail
test -s public/index.html
test -s public/sitemap.xml
- name: Deploy over rsync
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
rsync -rltz --delete --omit-dir-times \
-e "ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o UserKnownHostsFile=~/.ssh/known_hosts" \
public/ "$DEPLOY_USER@$DEPLOY_HOST:/"
- name: Smoke test
run: |
set -euo pipefail
for url in https://example.com/ https://example.com/sitemap.xml; do
code=$(curl -s -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 5 "$url")
echo "$url -> $code"
test "$code" = "200"
done
The parts that are not decoration:
concurrency without cancel-in-progress. Two deploys running at once against the same directory with --delete is how you serve a half-copied site. Queueing them costs nothing.
The empty-build guard. --delete is what makes rsync a deploy rather than an upload, and it is also what turns a build that silently produced nothing into a deleted website. Three test -s lines are cheaper than that outage. Assert on files that must exist and on at least one that only appears when the build really ran.
IdentitiesOnly=yes. Without it, ssh may offer other keys from the agent first and get refused before it reaches yours.
--omit-dir-times. With a write-only jail, rsync’s attempts to set directory timestamps can fail noisily. This keeps the output clean without weakening anything.
The smoke test runs against the public URL, not against the server over ssh. It is the only step that proves a visitor can see the result: it catches a dead container, an expired certificate and a proxy pointing at the wrong directory, none of which rsync knows about.
What this does and does not protect you from
It does protect you from the realistic failure: a leaked CI secret turning into shell access on your server. The key can write one directory and nothing else, cannot read back, cannot forward, cannot open a terminal.
It does not protect you from someone who can change the workflow file, because they can replace the content you ship. If the site is the thing you care about, protect the branch: require review, and keep the deploy on push to the default branch only - never on pull_request_target, which runs with your secrets available to a fork’s code.
It also does not protect against the directory itself. Whatever is served from that path can be overwritten by the key. Keep the deploy target separate from config, data volumes and anything else the server holds.
The retrofit, in order
If you already have an unrestricted deploy key, fixing it takes a few minutes:
- Confirm
rrsyncexists on the server:ls -l /usr/bin/rrsync. - Edit
authorized_keysand addcommand="/usr/bin/rrsync -wo /path/to/site",restrictin front of the existing key line. - From your laptop with that key, check that
ssh ... 'id'fails and a test rsync still succeeds. - Re-run the workflow with
workflow_dispatchand watch the smoke test pass. - Only then delete the backup of the old line.
Rotating the key at the same time is a reasonable habit, especially if it was ever printed in a log.
What’s next
The server side of the same setup: Caddy and Docker in production - automatic HTTPS, several sites on one box, and the cache headers that decide whether your readers see the page you just published.