Push access is code execution: hardening a self-hosted runner
Here is the sentence that belongs at the top of this article rather than buried in a caveat near the bottom:
If you install a self-hosted runner on your client's server, then anyone who can push to your repository can run commands on that server as the bench user.
Not "could potentially, under some circumstances". That is what a runner is for. It reads a workflow file from your repository and executes it. The workflow file is code, the repository is where your team pushes code, and the machine is in your client's office. This is a property of the design, not a bug in it, and GitHub says as much: a self-hosted runner can be persistently compromised by untrusted code in a workflow.
I am not raising this to talk you out of deploying to a client server you are not allowed to touch. I still use runners and I still recommend them. I am raising it because you want to be the person who explains this to the client, not the person it is explained to.
treat push access to the deployment repository as shell access to the client's server, because that is what it is, and then spend your effort narrowing what that shell can do rather than pretending it is not a shell.
- Trigger only on protected tags. Never on
pushto a branch, never onpull_request. - Register the runner to one repository, run it as
frappe, and give it no sudo beyond one wrapper script. - The deploy script lives on the server and is owned by the client, so changing the repository cannot change what a deploy does.
- Tell the client how to stop it themselves, on day one, in writing.
The path, drawn honestly
Step 5 is worth sitting with. It is not an exploit. site_config.json holds the database password in plain text because that is how Frappe finds its own database, as covered in the anatomy of a bench. Any process running as frappe can read it. So "runs as the bench user" and "can read the entire ERP database" are the same statement.
Seven layers, and what each one is actually worth
The sudoers rule, in full
The deploy needs exactly one privileged action: restarting the bench processes. The obvious rule is wrong:
# DO NOT USE THIS
frappe ALL=(root) NOPASSWD: /usr/bin/supervisorctl
A command in sudoers with no arguments specified permits any arguments. So that line also permits sudo supervisorctl -c /tmp/mine.conf start anything, which is root code execution with extra steps. Writing the arguments inline instead is fragile, because sudoers argument matching is positional and the supervisor group names contain colons.
Use a wrapper the client owns. As root:
cat > /usr/local/sbin/frappe-restart <<'EOF'
#!/bin/sh
set -eu
exec /usr/bin/supervisorctl restart frappe-bench-web: frappe-bench-workers:
EOF
chown root:root /usr/local/sbin/frappe-restart
chmod 755 /usr/local/sbin/frappe-restart
Then exactly one rule, installed with visudo so a syntax error cannot lock everyone out:
visudo -f /etc/sudoers.d/frappe-restart
frappe ALL=(root) NOPASSWD: /usr/local/sbin/frappe-restart
Verify what you have actually granted, which is a habit worth keeping:
sudo -l -U frappe
User frappe may run the following commands on dev-client-local:
(root) NOPASSWD: /usr/local/sbin/frappe-restart
One line of output. If that listing ever grows, someone has widened the client's exposure and it should be a conversation.
The wrapper is owned by root and not writable by frappe, which matters: if the runner could edit it, the rule would be self-defeating.
The deploy script belongs to the server
The strongest structural control on this list, and the one that takes the most discipline, is that the thing a deploy runs is not in your repository.
- name: Deploy
run: /home/frappe/deploy.sh ${{ github.ref_name }}
One line. The workflow's entire authority is "run the release procedure the server already defines, for this tag". It cannot change the backup step, cannot skip maintenance mode, cannot add a step that reads site_config.json and posts it somewhere, because none of that is in the repository.
Own the script as root and make it read-only to frappe:
chown root:frappe /home/frappe/deploy.sh
chmod 750 /home/frappe/deploy.sh
Now the runner can execute it and cannot rewrite it. Changing the deploy procedure becomes a deliberate act on the client's server rather than a line in a pull request, which is slower and is the point.
Be honest about the limit, which is in the diagram above: nothing stops a modified workflow adding a second step that does whatever it likes. What the ownership buys is that the deployment is trustworthy and any deviation is visible as an extra step in the run log, rather than hidden inside a script the reviewer assumed had not changed.
The trigger block is a security control
Two rules, no exceptions.
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
No branches:, ever. A branch trigger means a push deploys, and a push is the cheapest thing an attacker can do.
And never put the self-hosted label on a job that a pull request can reach. The pull_request event runs in the context of the merge commit, which includes the pull request's own changes to workflow files. On a public repository that means a stranger's proposed workflow can execute on your client's server, gated only by an approval for first-time contributors. Keep the repository private, and keep test jobs on ubuntu-latest where they belong:
jobs:
test:
runs-on: ubuntu-latest # never self-hosted
deploy:
needs: test
runs-on: [self-hosted, dev-client-local]
environment: production
The environment and required reviewer setup is what puts the client's hand on that second job. And if you are tempted to reach for pull_request_target to get secrets into a fork's pull request, read how the pwn request works first. It is the exact shape of attack this section is warning about.
Give the client the off switch, in writing
The last control is not technical and it is the one that changes the relationship. Tell them, on day one, exactly how to stop this without asking you:
cd /home/frappe/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh status
The runner stops polling immediately. No queued job, no future job, nothing you push can execute. Removing it entirely is sudo ./svc.sh uninstall followed by ./config.sh remove.
Put that in the handover document next to the install steps. It costs you nothing, it is true, and it converts "we let a vendor run an agent on our server" into "we run an agent we can stop". Security teams respond to controls they hold. So do clients.
What none of this fixes
Two things, and I would rather say them than have you find out in a review.
The runner is a persistent machine, not a clean one. GitHub is explicit that self-hosted runners have no guarantee of running in ephemeral clean virtual machines, so anything a compromised job writes to the filesystem is still there for the next job and the one after. If you want that property back, look at just-in-time ephemeral runners, at the cost of the setup no longer being something a client's IT person does once in ten minutes.
And the runner auto-updates itself, but the machine and its floors do not move on their own. actions/checkout@v5 needs runner 2.327.1 or newer, and with Node 20 removed from Actions in September 2026, a runner installed a year ago and forgotten will start failing on jobs that worked last month. That is availability rather than security, and it will still be your phone ringing.
If the client's security team reads all of this and still says no to running an agent, that is a legitimate position and there is a design for it: the pull model, where a systemd timer on their server polls for releases and nothing external can execute anything at all. And the complete assembled implementation, with the checklist you hand their IT team, is in the pillar walkthrough.
Frequently asked questions
- Is a self-hosted runner more dangerous than an SSH deploy key?
- It is differently dangerous. The SSH key is a bearer credential that works for anyone holding it, from anywhere, silently. The runner is an execution path that only fires when your repository produces a job, and that path can be gated by branch rules, tag rules and a human approval. One risk is a file that exists; the other is a workflow you can constrain.
- Why not just give the frappe user full sudo so the deploy is simpler?
- Because it converts push access to your repository into root on the client's server. The deploy needs exactly one privileged action, restarting the processes, and that fits in one sudoers line pointing at one wrapper script. Anything wider is a convenience you are buying with the client's risk budget.
- Can a pull request from a fork really run on my runner?
- On a public repository, yes. The pull_request event runs in the context of the merge commit, so a fork's changes to the workflow file are part of what executes. GitHub gates first-time contributors behind an approval, and that gate is a person clicking a button. Keep the repository private and never put the self-hosted label on a pull_request job.
- What is the fastest way for the client to shut this down?
- sudo ./svc.sh stop in the runner directory, or systemctl stop on the actions.runner unit. The runner stops polling immediately and no queued or future job can execute. They should be told this on day one, in writing, because a control they know how to use is worth more than one they have to ask you about.