Deploying Frappe apps to a client server you're not allowed to touch
The situation, exactly as it arrives. A client has an ERPNext dev server sitting on their own network with real data on it, copied down from production so the testing means something. You have built a custom app for them. They will not give you access to that machine, and they are right not to, because the data on it is customer data and you are a vendor.
So the arrangement they have settled into is this. You push a change. You send a message. Someone on their IT team logs in, runs git pull in a directory, runs bench migrate, and tells you whether it worked. It happens when that person is free. It fails in ways neither of you can see. Nobody records what version is running. On a busy week you ship twice; on a bad week you wait four days for a one-line fix to reach a tester.
Every part of that is fixable without anybody giving you anything. This article is the complete implementation: the architecture and why, the workflow file, the deploy script, the checklist you hand their IT team, the four values you need back from them, and the security conversation to have before somebody else starts it.
install a GitHub self-hosted runner on the client's server, registered by them, running as the frappe user. It polls outward to GitHub, so nothing connects in. It runs a deploy script that lives on their server and that your repository cannot change.
- No inbound port, no VPN, no public address, no credential to their environment held by you.
- Their IT lead is the required reviewer on the GitHub environment, so no release reaches the server without a named person on their side approving it.
- Deploy is: refuse if jobs are pending, maintenance mode on, backup with files, fetch the tag, migrate, restart, maintenance mode off via a
trapthat fires even on failure. - If their security team refuses a runner outright, the pull model with a systemd timer gets you most of the way with nothing installed that listens for work.
The decision, up front
Use a self-hosted GitHub Actions runner, installed by the client, on the client's server.
Not SSH. SSH deployment for Frappe is a good design and I use it on servers I own, but on somebody else's box it requires two things you should not ask for. It requires an inbound firewall rule so a machine in Microsoft's cloud can reach port 22 on their network. And it requires a private key to their environment to exist, stored in your repository's secrets, readable by anyone with write access to that repository, and revocable only by someone with access to their server. You would be asking a client to increase their attack surface and hand you a credential, in order to save yourself a twenty-minute install.
The runner inverts the direction. It is a client, not a server: it opens an outbound HTTPS connection to GitHub and asks whether there is work. If you have not internalised that yet, what a runner actually is is the one prerequisite for the rest of this article, and it is short. The consequence is the whole reason this works: no inbound connection to their server is ever required, because their server initiates everything.
The workflow file
Complete, and the whole of it. If the on:, jobs: and runs-on: keys are not yet familiar, the workflow, job and step walkthrough covers them in fifteen minutes.
name: Deploy your_app
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch:
inputs:
tag:
description: An existing tag to redeploy
required: true
permissions:
contents: read
concurrency:
group: deploy-dev-client-local
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements-dev.txt
- run: python -m pytest -q
deploy:
needs: test
runs-on: [self-hosted, dev-client-local]
environment: production
steps:
- name: Deploy the release
run: /home/frappe/deploy.sh "$TAG"
env:
TAG: ${{ inputs.tag || github.ref_name }}
Six things in there are load bearing.
Tags only, and a strict pattern. No branches:. Merging to main must never deploy, because a push is the cheapest action an attacker has. The character-class pattern means v1.4.0 matches and v2-experiment does not.
concurrency with cancel-in-progress: false. Two deploys must never overlap on one bench, and cancelling a running one is worse than queueing it, because the thing you would be interrupting is a migration.
The test job runs on ubuntu-latest, never on the self-hosted label. Tests are the least trusted code in the repository and there is no reason for them to execute on a client's server.
needs: test. One word, and it means a failing test suite cannot reach production.
environment: production. This is where the client's approval lives. Configuration is in secrets, environments and required reviewers: add their IT lead as the sole required reviewer, turn on prevent self-review, and restrict deployment tags to v* so the environment cannot be reached from a branch.
The deploy job checks out nothing. There is no actions/checkout in it, and that is deliberate rather than an omission. The workflow's entire authority is one sentence: run the release procedure this server already defines, for this tag. It cannot bring its own code along.
The deploy script, owned by the client
This file lives at /home/frappe/deploy.sh on their server. It is owned by root and readable and executable by frappe, so the runner can run it and cannot rewrite it.
#!/usr/bin/env bash
# /home/frappe/deploy.sh
# Owned by root, executed by frappe. The repository cannot change this file.
set -euo pipefail
BENCH=/home/frappe/frappe-bench
SITE=dev.client.local
APP=your_app
TAG="${1:?usage: deploy.sh <tag>}"
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
cd "$BENCH"
log "Checking for pending background jobs"
bench --site "$SITE" ready-for-migration
log "Closing the site to users"
bench --site "$SITE" set-maintenance-mode on
cleanup() {
rc=$?
log "Lifting maintenance mode, exit status ${rc}"
bench --site "$SITE" set-maintenance-mode off \
|| log "WARNING: could not lift maintenance mode, site is still closed"
exit "${rc}"
}
trap cleanup EXIT
log "Backing up the database and the files"
bench --site "$SITE" backup --with-files
log "Fetching ${TAG}"
git -C "apps/${APP}" fetch --tags --prune origin
git -C "apps/${APP}" checkout --force "${TAG}"
log "Installing requirements"
bench setup requirements
log "Building assets"
bench build --app "${APP}"
log "Migrating"
bench --site "$SITE" migrate
log "Restarting processes"
sudo /usr/local/sbin/frappe-restart
log "Deployed ${TAG}"
The trap is the most important eleven lines on this page. Without it, a migration that fails leaves the site behind a maintenance page, and the only people who can lift it are the people you cannot call at 19:00. With it, maintenance mode comes off whether the deploy succeeded or failed, the exit status is preserved so the job still goes red in GitHub, and if even the lifting fails you get a warning in the log that says exactly what state the site is in.
Notice what the script touches: apps/your_app, the Python environment, the built assets, and the database. Four paths, and no others. It never writes site_config.json, never touches uploaded files, never goes near apps/frappe. That containment is the reason a routine deploy is low risk, and it comes straight out of the anatomy of a Frappe bench.
And notice which line is different from all the others. Everything up to bench migrate is reversible by checking out the previous tag. bench migrate is not, because each patch commits as it completes and records itself as applied, so a failure halfway leaves the earlier half permanently done. That is why the backup on the line above it is not optional, and what bench migrate actually does, and the rollback you will wish you had is the article to read before you run this in anger.
Before the first deploy
The script assumes apps/your_app already exists on that bench, which on a brand new engagement it does not. Deployment and first installation are different jobs and conflating them produces a script that has to guess which situation it is in. Keep them separate. The one-off install is done by their team, once, alongside the runner setup:
cd /home/frappe/frappe-bench
bench get-app --branch v1.3.0 https://github.com/your-org/your_app.git
bench --site dev.client.local install-app your_app
If the client's custom code is not in a repository at all yet, that is the first job rather than this one. Managing Frappe and ERPNext custom code via GitHub covers getting there.
For a private repository that clone needs credentials, which is the one moment in this whole design where a token touches their machine. Have them create a read-only deploy key on the repository and use the SSH clone URL, or use a fine-grained token scoped to read-only contents on that one repository. Either way the credential is theirs, it is read-only, it grants nothing on their server, and they can revoke it without telling you.
After that, every subsequent release goes through deploy.sh and nobody runs bench get-app again. If your deploy script ever needs to install an app, something has gone wrong upstream of it.
The checklist you hand their IT team
Send this as a document, not as a chat message. It should be followable by a competent Linux administrator who has never met you and does not know what Frappe is. All of it runs on their server, and none of it requires anything from you except the repository URL.
The runner install itself is four commands, run as frappe, with the current download URL taken from the New self-hosted runner page in the repository settings:
sudo -u frappe -i
mkdir -p ~/actions-runner && cd ~/actions-runner
curl -o actions-runner-linux-x64.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.328.0/actions-runner-linux-x64-2.328.0.tar.gz
tar xzf ./actions-runner-linux-x64.tar.gz
./config.sh \
--url https://github.com/your-org/your_app \
--token AXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \
--name dev-client-local \
--labels dev-client-local \
--unattended --replace
exit
cd /home/frappe/actions-runner
sudo ./svc.sh install frappe
sudo ./svc.sh start
sudo ./svc.sh status
The token expires within an hour and they generate it themselves, which is the detail that makes this genuinely no-access: at no point in the setup does a credential pass through your hands.
And the sudo rule, which is the only privilege the whole system holds:
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
visudo -f /etc/sudoers.d/frappe-restart
frappe ALL=(root) NOPASSWD: /usr/local/sbin/frappe-restart
A wrapper rather than supervisorctl directly, because a sudoers entry with no arguments listed permits any arguments, and supervisorctl with arbitrary arguments is root code execution. The reasoning is in the runner hardening article, along with what each layer of this does and does not stop.
The four values you need back
Ask for these in one message, and you can finish your side without another exchange.
The runner label they registered. Whatever they typed after --labels. Yours must match exactly in runs-on, and if they typed dev_client_local while you wrote dev-client-local, your job will sit queued forever with no error message worth reading.
The exact site name. Not "the dev site". The string in bench --site, which is the directory name under sites/. Throughout this series that is dev.client.local.
The bench path and the bench user, if they are not /home/frappe/frappe-bench and frappe. Plenty of installs are not, particularly ones set up by a previous vendor.
The GitHub username of the person who will approve releases. They need an account on your repository with at least write access to appear in the required reviewers list, and getting this agreed early avoids the awkward version of the conversation where you have built an approval gate that nobody at their organisation can operate.
The security conversation, before they start it
Have this conversation yourself, early, in plain words. If their security team raises it first, you are answering; if you raise it, you are advising, and the difference is worth a great deal.
Say this: the runner executes whatever the workflow file in our repository says. So push access to our repository is command execution on this server, as the frappe user. That is not a flaw we are disclosing, it is what a deployment agent is, and here is how it is contained.
Then list it. The trigger is version tags only, and tag creation is restricted to maintainers. The deploy job requires approval from your named person, and no unapproved run can execute. The runner is registered to this one repository, not to our organisation, so nothing else we build can reach this machine. It runs as frappe, never root, with exactly one sudo rule for one wrapper script that only restarts supervisor. The deploy procedure is a file on this server that our repository cannot modify. And you can stop the entire mechanism, permanently, without asking us:
cd /home/frappe/actions-runner
sudo ./svc.sh stop
That last one does more work than the other five combined. A control the client holds and knows how to use changes the character of the whole arrangement. It is also the answer to the question nobody asks out loud, which is what happens if this relationship ends badly.
Be honest about the residue too. The runner is a persistent machine, not a clean one, and GitHub is explicit that self-hosted runners can be persistently compromised by untrusted code in a workflow. Say that. Then explain that the approval gate and the tag restriction are what stand in front of it, and that the alternative designs each have a residue of their own.
If they say no to the runner
Some security policies simply forbid third-party agents on production hosts, and no amount of explanation moves them. There is a design for that: GitHub Actions is reduced to test-and-tag, and a systemd timer on their server polls the releases API every five minutes, compares the latest release against the tag currently checked out in apps/your_app, and runs the very same deploy.sh when they differ. Nothing is installed that waits for work, nothing outside the building can cause execution, and the trigger is a schedule the client owns. You give up the live deploy log, you accept up to one polling interval of delay, and you lose the approval gate because there is no job for anyone to approve. The complete unit file, timer file and polling script are in the pull model with systemd timers.
Choosing between the three
Written out plainly: if you own the box, use SSH. If the client owns the box and will host a runner, use a runner. If the client owns the box and refuses a runner, use the pull model.
What none of the three does is let you have it both ways. Each one costs something. SSH costs an open port and a key held by a third party. The runner costs an agent on their machine and the fact that push access to your repository is execution on it. The pull model costs visibility and immediacy. Pick the cost that fits the client in front of you and say out loud which one you picked.
The last thing worth saying is the thing that took me longest to learn, and it is not technical. When you arrive at the deployment conversation with this already worked out, you are not asking for permission. You are describing an arrangement in which the client keeps their data, keeps control of their server, keeps the ability to shut you off, and gets their fixes in minutes instead of days. That reads completely differently from "can I have SSH access", and the reasoning behind why is in access is a liability, not a convenience. It is the article I wish I had read before that meeting in 2019.
Frequently asked questions
- What does the client actually have to install?
- One agent, three files and one sudoers line, and it takes about twenty minutes. The GitHub runner as a service under the frappe user, a root-owned deploy script, a root-owned restart wrapper, and a single sudoers rule permitting exactly that wrapper. Nothing else changes on the machine and no firewall rule is added.
- Do I need any credential to their server?
- None. You never hold a password, a key or a VPN profile for their environment. The registration token is generated by them in the repository settings and expires within an hour, and the runner authenticates outward from their machine. If the relationship ends, they stop one service and you have nothing left that works.
- What happens if their server is offline when I tag a release?
- The job queues in GitHub and runs when the runner comes back online. That is usually what you want. It does mean a machine that has been off for two weeks will deploy a two-week-old release the moment it reconnects, so use the environment approval as the real gate rather than relying on timing.
- Can the client stop this without asking me?
- Yes, and you should tell them how on day one. sudo ./svc.sh stop in the runner directory ends it immediately, and nothing you push after that can execute. A control they hold and know how to use is worth more in a security review than any assurance you could write into a contract.