Anatomy of a Frappe bench
The first time I had to fix somebody else's ERPNext server, I ran ls in the bench directory and found five folders, none of which told me what they were for. I guessed. I guessed that apps/ was part of the same repository as everything else, ran git pull at the bench root, got "not a git repository", and concluded the install was broken. It was not broken. I was wrong about its shape.
A bench is not an application. It is a workspace that holds several independent applications, one Python environment, any number of sites, and the generated configuration to run them all. Get the shape right and deployment becomes obvious, because you will know exactly which four paths a deploy touches and which eleven it must not. Get it wrong and you will do what I did on a client's box in 2019: run one command with sudo, create a scatter of root-owned files, and spend a Saturday finding them.
This matters for deploying a Frappe app to a client server you cannot touch, because when you cannot log in and look around, your deploy script has to be right about the layout the first time.
a bench is five directories. The important one is apps/, where every subdirectory is its own independent git clone with its own remote, which is why a deploy is a git fetch inside apps/your_app and not a checkout of the bench.
apps/holds the code, one git repository per app.sites/holds the data and the per-site configuration.site_config.jsoncarries the database password in plain text and never belongs in version control.env/andconfig/are generated. Rebuild them with bench, never hand-edit the output.- Everything is owned by
frappe:frappe. Onesudo benchand you will be hunting root-owned files for hours.
The five directories
cd /home/frappe/frappe-bench
ls -1
apps
config
env
logs
sites
apps/ is not one repository, it is several
This is the insight that makes Frappe deployment simple, and almost every article about it skips straight past.
cd /home/frappe/frappe-bench/apps/your_app
git remote -v
git log --oneline -1
git describe --tags
origin https://github.com/your-org/your_app.git (fetch)
origin https://github.com/your-org/your_app.git (push)
a4f19c2 Add supplier ageing report
v1.4.0
That is a normal git clone, in a normal working tree, with a normal remote. So a deployment does not need to understand Frappe at all at this stage. It needs to fetch a tag and check it out:
cd /home/frappe/frappe-bench/apps/your_app
git fetch --tags --prune origin
git checkout --force v1.4.0
Nothing else in the bench moves. apps/frappe stays exactly where it is, on whatever version-15 commit it was on. apps/erpnext likewise. That containment is the reason a routine app deploy is low risk and a framework upgrade is a planned event with a maintenance window: they touch different repositories, and only one of them is yours.
It also means the bench root is not the thing you put under version control, and that your own customisations belong in a fourth directory alongside these rather than inside apps/erpnext. Managing Frappe and ERPNext custom code via GitHub covers creating that app and getting it onto a server.
Which apps a given site actually has installed is a separate question from which apps are present on disk, and the two do drift apart:
cat sites/apps.txt
bench --site dev.client.local list-apps
The first is what this bench knows about. The second is what that site has installed and will run patches for. If your app is on disk and in apps.txt but not in list-apps, then bench migrate will cheerfully do nothing for it and you will spend twenty minutes wondering why your new DocType has not appeared.
sites/, and the file that must never reach git
Everything that is not code lives here. Uploaded documents, generated PDFs, backups, and the per-site configuration.
cat sites/dev.client.local/site_config.json
{
"db_name": "_a1b2c3d4e5f6a7b8",
"db_password": "pLa1nT3xtP4ssw0rd",
"db_type": "mariadb",
"encryption_key": "kR2n...=",
"maintenance_mode": 0
}
That is not a mistake in the framework's design, it is how Frappe expects to find its credentials. But it has two consequences you have to build around. The file is readable by anyone who can become the frappe user, which is the point made in the argument that access is a liability rather than a convenience. And it must never enter version control, which in practice means your deploy script has to be careful with git commands that discard local state.
This is why the checkout above uses --force on the app repository only, and why you never run git clean -xdf anywhere near a bench. Inside apps/your_app that would delete compiled assets, which is recoverable. Pointed at the wrong directory it deletes uploaded customer files, which is not.
Also note maintenance_mode sitting in that file. That is the flag bench --site dev.client.local set-maintenance-mode on flips, and knowing it is a plain integer in a plain JSON file is what lets a deploy script recover from a failure that leaves the site stuck behind the maintenance page.
env/, config/ and logs/
env/ is a Python virtual environment, and every bench command that touches Python is really running env/bin/python. It is generated, so it is disposable. When your app adds a dependency to its pyproject.toml, the deploy has to install it:
cd /home/frappe/frappe-bench
bench setup requirements
Skip that step and you get the deployment failure that wastes the most time in this whole series: bench migrate fails with a ModuleNotFoundError for a package that is right there in your pyproject.toml, because the requirement was declared but never installed into the environment the site actually runs in.
config/ holds supervisor.conf, nginx.conf, the redis configs and a pids/ directory. All generated by bench setup commands from templates. If you hand-edit config/supervisor.conf, the next person to run bench setup supervisor overwrites your change without warning, usually six months later, usually at the worst time.
logs/ is where you will actually diagnose things when you have no shell on the box. bench.log, worker.log, schedule.log, plus web.error.log and the supervisor output streams. Worth naming explicitly in your handover document, because "send me the last hundred lines of logs/worker.error.log" is a request a client's IT person can act on, and "check the logs" is not.
Ownership, and the Saturday
Every file in that tree is owned by the bench user. On a standard install that is frappe.
The web processes run as frappe. The workers run as frappe. Your deploy script must run as frappe, which is why the runner is installed as a service under that account. The moment something runs as root inside this tree, it creates files root owns, and the next ordinary bench command that tries to write over them fails.
The failure never says "you used sudo". It says permission denied in sites/assets, or a build error, or a worker that will not start. Finding the damage:
find /home/frappe/frappe-bench ! -user frappe -printf '%u %p\n' | head -50
And repairing it:
sudo chown -R frappe:frappe /home/frappe/frappe-bench
The temptation at this point is chmod -R 777, which makes the error go away and creates a worse one. What to do instead when you get permission denied is the short version: fix the owner, not the mode.
That command is safe and it is also an admission. If you are running it, something in your process ran as the wrong user, and fixing the ownership without fixing the process means you will run it again next month.
Supervisor, systemd, and restarting without root
Bench generates a supervisor configuration by default. With the bench at /home/frappe/frappe-bench, the generated program groups are named after the bench directory:
sudo supervisorctl status
frappe-bench-redis:frappe-bench-redis-cache RUNNING pid 812, uptime 6 days
frappe-bench-redis:frappe-bench-redis-queue RUNNING pid 813, uptime 6 days
frappe-bench-web:frappe-bench-frappe-web RUNNING pid 940, uptime 2:14:07
frappe-bench-web:frappe-bench-node-socketio RUNNING pid 941, uptime 2:14:07
frappe-bench-workers:frappe-bench-frappe-schedule RUNNING pid 955, uptime 2:14:07
frappe-bench-workers:frappe-bench-frappe-short-worker RUNNING pid 956, uptime 2:14:07
The trailing colon in a group name restarts every program in that group, which is what a deploy wants:
sudo supervisorctl restart frappe-bench-web: frappe-bench-workers:
Note the sudo. That is the one place a Frappe deploy legitimately needs elevated rights, and it is exactly one command. Granting the frappe user unrestricted sudo to solve it is the wrong answer, and the narrow sudoers rule that solves it properly is in the article on hardening a self-hosted runner.
Bench can also generate systemd units instead, which some hosts prefer. Take the default unless you have a reason. What matters for deployment is not which supervisor you use but that your script has a non-interactive, non-root-shell way to restart the processes.
What a deploy touches, and what it must not
Which leaves the one item in the left column that is not like the others. Fetching a tag is reversible - check out the old tag and you are back. Rebuilding assets is reversible. Reinstalling Python dependencies is reversible. bench migrate is not, and pretending otherwise is how people lose an afternoon and occasionally a database. That is what bench migrate actually does, and the rollback you will wish you had.
Frequently asked questions
- Is the bench itself a git repository?
- No, and this trips up almost everyone. The bench directory is a workspace. Each folder under apps/ is its own independent git clone with its own remote and branch. Running git status at the bench root gets you nothing useful, because there is no repository there to report on.
- Why did running a bench command with sudo break everything?
- Because it created files owned by root inside a tree owned by frappe. The next command that runs as frappe cannot write over them, and the failure surfaces somewhere unrelated, often as a build error or a permission denied in sites/assets. Fix it with a recursive chown back to frappe:frappe and stop using sudo with bench.
- Should site_config.json be in version control?
- Never. It holds the site's database name and password in plain text, plus any API keys the site uses. It belongs to the server, not to the repository. If you need per-environment settings in git, keep a template with placeholder values and have the deploy leave the real file alone.
- Supervisor or systemd for a Frappe bench?
- Supervisor is what bench generates by default and what nearly every production install uses, with a single systemd unit starting supervisor itself. Bench can generate systemd units instead, but unless you have a specific reason, take the default. The one that matters is that whichever you pick, your deploy script needs a way to restart the processes without root.