</>CodeWithKarani

Deploying Frappe apps to a client server you're not allowed to touch

Karani GeoffreyKarani Geoffrey13 min read

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 trap that 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.

End to end architecture from repository to migrated site Three zones. On your side, a laptop pushes a version tag to the repository. In the GitHub zone, a test job runs on a hosted runner, then the deploy job enters a production environment where the client's IT lead must approve, after which the job enters a queue. In the client zone, the runner service running as the frappe user polls outward to that queue and receives the job, executes a root-owned deploy script, which operates on the bench and finally the site and its database. The only arrows crossing into the client zone are responses to a connection the client's own machine opened. Your side GitHub The client's premises Your laptop git tag v1.4.0 your-org/your_app private repository Job: test ubuntu-latest, hosted runner environment: production The client's IT lead approves. Nothing proceeds until they do. Job queue waits to be asked outbound poll Runner service systemd unit, runs as frappe /home/frappe/deploy.sh, root-owned frappe-bench, dev.client.local and the MariaDB database Every arrow that enters the client zone is a reply on a connection their own machine opened. No port is opened for you.
Read the two arrows between the queue and the runner in that order. The runner asks first; the job is a reply.

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.

Sequence from git tag to a migrated site A sequence diagram with three lifelines: you, GitHub, and the runner on the client server. You push a version tag. Tests pass on a GitHub-hosted runner. The deploy job enters the waiting state until the client's IT lead approves it. The runner then polls outward and is given the job on its next request. It runs the deploy script, which takes a backup, checks out the tag, migrates and restarts the processes. Logs and the exit status stream back to GitHub on the same outbound connection. You GitHub Runner on dev.client.local 1. git push origin v1.4.0 2. Tests pass on ubuntu-latest 3. Deploy job enters Waiting The client's IT lead approves it 4. Outbound poll: anything for me? 5. The approved job is handed back 6. deploy.sh runs backup with files, checkout v1.4.0, bench migrate, restart, maintenance off 7. Logs and exit status, same outbound connection
Steps 4 and 7 both start at the runner. Step 5 is a reply, which is why no firewall rule was ever needed.

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 client checklist, separated into one-time setup and recurring work Eight items in two groups. The one-time group, about twenty minutes of work, contains six items: download the runner as the frappe user, register it to the single repository with an agreed label, install it as a service running as frappe, install the root-owned deploy script, install the restart wrapper and one sudoers rule, and confirm outbound HTTPS to GitHub is permitted. The recurring group contains two items: approve each release in GitHub, and keep the runner and the operating system patched. One time, about twenty minutes 1 Download the runner agent as the frappe user, into /home/frappe/actions-runner 2 Generate the registration token in the repository settings and run config.sh with the agreed label 3 Install it as a service running as frappe: sudo ./svc.sh install frappe, then start it 4 Install /home/frappe/deploy.sh, owned root:frappe, mode 750, from the script we supply 5 Install the restart wrapper and exactly one sudoers rule permitting only that wrapper 6 Confirm outbound HTTPS to github.com is permitted. No inbound rule is required. Recurring, from then on 7 Approve each release in the GitHub environment. Nothing deploys without this. 8 Keep the runner service and the operating system patched, as with any other service
Six items once, two items forever. The second group is the part worth agreeing explicitly, because item seven is a person, not a setting.

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

Decision tree for choosing a deployment model A decision tree. The first question is who owns the server. If you own it, use SSH deployment. If the client owns it, the next question is whether they will host a runner. If yes, use a self-hosted runner with an environment approval. If no, use the pull model with a systemd timer polling for releases. Who owns the server? You do The client does Use SSH deployment Simplest, well understood, and rotation is a command you run. Will they host a runner? Yes: self-hosted runner With the client as the required reviewer. No: pull model A systemd timer polls for releases. Slower, and nothing can reach in.
Two questions, three answers. The second question is a policy question, so ask it early rather than after you have built something.

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.
#Frappe#ERPNext#GitHub Actions#CI/CD#Deployment#Bench#Security
Keep reading

Related articles