blog
GitHub Actions deployment to your own VPS: four approaches
Published
Every way of deploying from GitHub Actions to your own server hands CI some kind of secret. The useful comparison is what that secret lets someone do if your CI account, or a single workflow, is ever compromised.
Approach 1: SSH from the workflow
The workflow holds a private key, connects to the server and runs the deploy. It is the simplest to set up, and with an unrestricted key it has the widest blast radius: whoever obtains the key gets a shell.
Two things make it much safer. Pin the server’s host key in a secret instead of trusting whatever answers, and restrict the key on the server so it can run exactly one command.
The workflow step
- name: Deploy over SSH
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
echo "$SSH_KEY" > ~/.ssh/deploy && chmod 600 ~/.ssh/deploy
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh -T -i ~/.ssh/deploy deploy@203.0.113.10Fill DEPLOY_KNOWN_HOSTS from a session you already trust, and compare it with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the server. Running ssh-keyscan inside the workflow trusts whatever answers on the day.
Restrict the key on the server
In the deploy user’s ~/.ssh/authorized_keys, a forced command plus restrict turns the key into a single-purpose trigger. Whatever command the client asks for, the server runs the script, and port forwarding, agent forwarding and terminals are refused.
Keep the script independent of what the client sent. The requested command is available to it as SSH_ORIGINAL_COMMAND; if you ever read it, validate it strictly.
command="/opt/app/deploy.sh",restrict ssh-ed25519 AAAAC3Nz... github-actions
With this line, a leaked key can start a deploy and nothing else. Without it, the same key is a login.
Approach 2: a self-hosted runner
Install GitHub’s runner on the server and the workflow executes there directly, with access to Docker and the local filesystem. No SSH key leaves the server, and builds can reuse the local cache.
The price is that workflow code now runs on your production machine, as whatever user the runner has. GitHub recommends self-hosted runners only for private repositories, because on a public one a pull request from a fork can try to run its own workflow on your machine. And a runner that can talk to the Docker daemon is, once again, root.
Approach 3: push to a registry, then call a webhook
CI builds the image and pushes it to a registry. Then it sends one HTTP request to a small service on your server, which pulls the new image with its own read-only credential and restarts the app. CI never holds SSH access, and the server accepts nothing but a fixed set of actions.
It is not a free pass: whoever can push to your registry decides what runs next. But a leaked webhook secret lets someone redeploy what is already there, not log in.
Build, push to GHCR, then ask the server to pull
permissions:
contents: read
packages: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/example/app:latest
- name: Ask the server to pull
env:
DEPLOY_URL: ${{ secrets.DEPLOY_URL }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
curl --fail --silent --show-error -X POST "$DEPLOY_URL" \
-H "Authorization: Bearer $DEPLOY_TOKEN"Approach 4: rsync the build output
For a static site there is nothing to restart: build in CI and copy the files into the directory the web server serves. rsync over SSH sends only what changed.
The key concern from approach 1 applies, and so does a similar fix: rsync ships a helper called rrsync which, used as the forced command, confines the key to one directory. Its install path differs between distributions. For anything with a process or a database, copying files is only half a deploy, and the restart half takes you back to approach 1.
authorized_keys: this key can only reach /var/www/site
command="rrsync /var/www/site",restrict ssh-ed25519 AAAAC3Nz... github-actions
Which secret leaks, approach by approach
- 01SSH key without restrictions: a shell on the server. With a forced command: one deploy action.
- 02Self-hosted runner: nothing needs to leak — compromised workflow code already runs on the server.
- 03Registry plus webhook: the ability to redeploy, plus whatever the registry token allows. Push access to the registry is the real prize.
- 04rsync confined by rrsync: write access to the files of one site.
A deploy URL without authentication is a remote control
Whatever receives the webhook must authenticate the request. An open deploy URL lets anyone trigger restarts on demand; one that accepts a tag or branch as a parameter lets them choose what runs.
Send the secret in a header, not in the query string — query strings end up in access logs, proxy logs and shell history. Better still, sign the request body with HMAC, the way GitHub signs its own webhooks in X-Hub-Signature-256, and compare the signature in constant time on the server.
The MihraOps version of approach 3
MihraOps implements approach 3 with an optional Agent: a container named mihraops-agent on your server. Each application gets its own trigger at /hooks/<name>, authenticated by a bearer token or by a GitHub-style HMAC signature, and a request that fails verification changes nothing.
Deploy webhooks are part of Pro. The docs page has the workflow step and the token setup.