PR Deploy Agent That SSHs Straight to a VPS
Wire a GitHub Action that asks an agent to plan a deploy, then SSHs a pinned script to a Lucknow VPS so pull requests land on a real host, not a slide deck.
Netbay Engineering
Netbay Engineering
On this page
A pull request that only runs unit tests still leaves a human copying a tarball onto a box at 11 p.m. A deploy-on-PR agent closes that gap without handing the model a root shell. On every opened or updated pull request, a GitHub Actions job gathers the diff, the current systemd unit, and the health URL, then asks the model for a structured plan. The job validates that plan against an allowlist and SSHs a pinned script to a staging VPS. The model never types rm, never opens a PTY, and never chooses the hostname. You get a real process restart on Intel Xeon Platinum cores in Lucknow DC01, with High-Speed SSD under the app directory and L3/L4 DDoS filtering in front of the public NIC.
Why the agent does not own the shell
The failure mode is obvious if you have ever watched a chat model invent a flag. Give it ssh root@box and it will eventually pass --force or truncate a log. Treat the agent as a classifier that picks among named operations you already wrote. Create a Unix user named deploy-pr with no password, no sudo, and a home that can read /opt/app and talk to systemd through a narrow polkit rule or membership in a deploy group. Generate an ed25519 key that lives only in GitHub Actions secrets and in that user's authorized_keys. Set command= to /usr/local/bin/pr-deploy so even a stolen key cannot open a shell. Disable agent forwarding, port forwarding, X11, and PTY. Log every invocation with the SHA, the unit, and the exit code. That log is the audit trail when someone asks why staging restarted at 14:07.
Keep production credentials off this path entirely. Staging is a dedicated VPS, not a directory on your laptop. A small Lucknow plan is enough: one or two Xeon Platinum vCPUs, High-Speed SSD for /opt/app, and SSH locked down. The public NIC still sits behind L3/L4 DDoS filtering, which matters once you put a preview hostname on the box.
What the GitHub job actually sends
Keep the prompt complete and boring. Include the PR title, the changed file list, a diff capped at 200 KB, the current unit file copied from the box, the health path, and the last green SHA. If the diff is larger than the cap, fail the job and ping a human. Never put secrets, .env files, or private keys in the prompt. Ask the model to return JSON with keys action, unit, health, migrate, and reason. Validate with a schema: action is deploy or skip, unit is one of api.service, worker.service, or caddy.service, health starts with /health, and migrate is a boolean. If validation fails, the GitHub check is red and nothing SSHes. Skip is a valid outcome for docs-only PRs. That skip path is how you avoid restarting the API because someone fixed a typo in README.
name: pr-staging-deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
plan-and-deploy:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
env:
DEPLOY_HOST: staging.example.com
DEPLOY_USER: deploy-pr
steps:
- uses: actions/checkout@v4
- name: Collect unit and diff
run: git diff origin/main...HEAD > /tmp/pr.diff
- name: Ask agent for a plan
run: python3 tools/pr_deploy_agent.py /tmp/pr.diff
- name: SSH pinned deploy
run: |
chmod 600 "$HOME/.ssh/deploy_key"
ssh -i "$HOME/.ssh/deploy_key" -o IdentitiesOnly=yes "$DEPLOY_USER@$DEPLOY_HOST" -- "$GITHUB_SHA" api.serviceMap the deploy key from the Actions secrets context into a file before the SSH step. Do not inline the key in the prompt or in logs. GITHUB_SHA is already in the runner environment, so the script receives a real git object, not a branch name that can move.
The SSH contract on the VPS
The script is the real deploy. It takes SHA and UNIT as argv, not from stdin, so a confused model cannot inject extra flags through a pipe. It fetches that SHA in /opt/app, checks out a detached HEAD, runs a build step you already trust, reloads systemd, restarts the unit, and curls the health URL for twenty seconds. A non-zero exit paints the GitHub check red. Write one JSON line to /var/log/pr-deploy.log with timestamp, SHA, unit, and duration, then rotate that log.
# /usr/local/bin/pr-deploy (authorized_keys force-command)
set -euo pipefail
SHA=$1
UNIT=$2
case "$UNIT" in
api.service|worker.service|caddy.service) ;;
*) echo "unit not allowed"; exit 2 ;;
esac
cd /opt/app
git fetch --quiet origin
git checkout --quiet --detach "$SHA"
systemctl daemon-reload
systemctl restart "$UNIT"
i=0
while [ "$i" -lt 10 ]; do
if curl -fsS http://127.0.0.1:8080/health; then
echo "ok $SHA $UNIT"
exit 0
fi
i=$((i + 1))
sleep 2
done
exit 1authorized_keys should look like a single locked line: command="/usr/local/bin/pr-deploy",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty followed by the ed25519 key. git config --global --add safe.directory /opt/app so the deploy user can read a repo owned by another uid. If the unit uses EnvironmentFile, keep that file mode 0640 and never cat it into the agent prompt.
Staging versus production
Hard-code STAGING_HOST in the workflow. Production uses a different key and only runs after merge to main. The agent never sees production. If you want a preview URL, point a staging hostname at the VPS and let Caddy or Nginx terminate TLS. Firewall SSH to a jump box or to the ranges you actually use for CI. Require the staging check on the branch so a red deploy blocks merge. When health fails, leave the previous SHA running if you implement a symlink flip; a simple systemctl restart is still useful if the job fails closed and a human rolls back with the same script.
Takeaway
A deploy-on-PR agent is a planner with a tiny toolbelt. Pin the script, pin the user, pin the host, and let the model choose among named steps. You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and follow along — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance