How to Manage Frappe/ERPNext Custom Code via GitHub
Almost every Frappe project I have been asked to rescue started the same way. Someone needed a field on the Sales Order, so they added it in the UI. Someone needed a validation, so they wrote a Server Script in the browser. Someone needed a print format, so they built one in the editor. Each change took four minutes and worked immediately, which is exactly why nobody stopped to ask where any of it was being stored.
Then the questions arrive. How do we move this to the production server. What changed last Tuesday. Can we get back to how it was before. And the honest answer is that nobody knows, because the entire customisation lives in one database, on one machine, with no history and no copy.
The fix is not complicated and it is not a tool you have to install. Your customisations belong in your own Frappe app, that app is a git repository from the moment bench creates it, and GitHub is where it lives so that a second machine can have it. This article is that whole loop, updated for Frappe v15 and bench v5.
put every customisation in your own app, push that app to GitHub, and pull it onto the server. The trap is that UI customisations are database rows, not files, so they need bench export-fixtures before git can see them at all.
bench new-app your_appcreates a git repository, commits it, and puts you on a branch calleddevelop, notmain.- Never edit
apps/erpnextorapps/frappe. Reach into them with hooks and overrides from your own app instead. - Custom Fields, Property Setters, Client Scripts and Print Formats built in the UI exist only in that one database until you list them in the
fixtureshook and export them. - Version your app, never the bench. The bench is a workspace holding several independent repositories.
Here is the original screen recording that goes with this, if you would rather watch the loop than read it:
The rule: your code lives in your app
A Frappe bench holds several applications side by side. A standard ERPNext install has frappe and erpnext, both of which belong to other people, and both of which bench update will move without asking you. Anything you write into those directories is either overwritten or turned into a merge conflict in somebody else's repository, and it is invisible to your own version control either way.
So the first command is not a git command:
cd /home/frappe/frappe-bench
bench new-app your_app
bench --site dev.client.local install-app your_app
It asks for a title, description, publisher, email and licence, writes the scaffold into apps/your_app, and then does something people miss:
cd apps/your_app
git log --oneline
git branch --show-current
a1b2c3d feat: Initialize App
develop
It is already a git repository, it already has a commit, and it is already on a branch. That branch is develop, which is the single most common surprise here: a fresh app pushed with git push -u origin main fails, because there is no main. You can rename it if your team expects main, and there is no need to.
The scaffold also writes a .gitignore covering __pycache__, *.pyc, node_modules, *.egg-info and a few others, plus a .github/workflows/ directory with a CI workflow already in it. Frappe ships you a GitHub Actions pipeline whether you asked for one or not, which is worth knowing before you write your own.
Connecting it to GitHub
Create an empty private repository on GitHub, with no README and no licence file, so there is nothing to merge. Then, from inside the app:
cd /home/frappe/frappe-bench/apps/your_app
git remote add origin git@github.com:your-org/your_app.git
git push -u origin develop
That is the whole setup. Note that you are pushing from inside apps/your_app and not from the bench root, because the bench root is not a repository at all. If that distinction is fuzzy, the anatomy of a Frappe bench lays out which directories are what, and why git init at the bench root is a mistake that takes a while to reveal itself.
The half of your work that git cannot see
Here is where most people's version control quietly becomes fiction. You commit and push diligently, the repository looks healthy, and the app still does not work when you install it somewhere else. That is because a large part of Frappe customisation is not code at all. It is rows.
Fixtures are the bridge. In apps/your_app/your_app/hooks.py, declare what belongs to you:
fixtures = [
{"doctype": "Custom Field", "filters": [["dt", "in", ["Sales Order", "Item", "Customer"]]]},
{"doctype": "Property Setter", "filters": [["doc_type", "in", ["Sales Order", "Item", "Customer"]]]},
{"doctype": "Client Script", "filters": [["dt", "in", ["Sales Order", "Item"]]]},
{"doctype": "Print Format", "filters": [["module", "=", "Your App"]]},
]
Then export and commit:
cd /home/frappe/frappe-bench
bench --site dev.client.local export-fixtures --app your_app
cd apps/your_app
git status --short
git add fixtures/
git commit -m "feat: export sales order customisations as fixtures"
?? fixtures/client_script.json
?? fixtures/custom_field.json
?? fixtures/print_format.json
?? fixtures/property_setter.json
Use the filters. Listing "Custom Field" as a bare string exports every custom field on the site, including ones belonging to other apps and ones a consultant added last year for a different purpose. Your app then re-creates all of them on any site it is installed on, which is how one client's field ends up on another client's server.
Those JSON files are imported automatically when the app is installed and on every bench migrate afterwards, which is what makes the whole thing work on the second machine. Two things fixtures will not do for you: a fixture only ever adds or updates, so deleting a Custom Field on your machine does not delete it anywhere else, and a Client Script dropped into fixtures/custom_scripts/ as a raw .js file is explicitly not supported and will be skipped with a message telling you to convert it to a fixture.
The loop
On the server, the first time only:
cd /home/frappe/frappe-bench
bench get-app https://github.com/your-org/your_app.git --branch develop
bench --site dev.client.local install-app your_app
And for every change after that:
cd /home/frappe/frappe-bench/apps/your_app
git pull origin develop
cd /home/frappe/frappe-bench
bench --site dev.client.local migrate
bench build --app your_app
bench restart
bench migrate is the step that does the real work: it runs your patches, syncs the schema to match your DocType JSON, and imports those fixture files. It is also the only irreversible command in the list, so on anything holding real data, take a backup with files first. What bench migrate actually does goes through why a migration that fails halfway cannot simply be re-run.
What never goes in
The app boundary protects you from the worst mistake automatically. site_config.json holds the site's database password in plain text, and it lives under sites/, not apps/, so it is outside your repository already. Keep it that way and never copy it in "for convenience".
Beyond that, three habits. Do not commit anything under node_modules or __pycache__, which the generated .gitignore already handles unless you fight it. Do not put API keys or M-Pesa credentials in hooks.py or a settings module in the app, because your app is a repository that other people will clone; put them in the site config and read them with frappe.conf.get(). And do not commit the bench, which bears repeating because it is the single most common version-control mistake in Frappe projects.
Where this goes next
Once the loop above is running, the interesting question is who types the commands on the right-hand side. Doing it by hand is fine and it is how most teams start, but it has a specific failure mode: the person with server access becomes the bottleneck for every fix, and on a client's server that person is not you.
That is the whole reason a deployment pipeline exists. If you own the box, the direct path is SSH deployment for Frappe. If the box belongs to a client who will not give you access, the same three commands can still run on a tag you push, without you holding any credential to their environment, which is the subject of deploying Frappe apps to a client server you are not allowed to touch. Either way, none of it is possible until the custom code is in a repository, which is what this article was for.
Frequently asked questions
- Should I put the whole bench in git?
- No. The bench is a workspace, not a repository, and each folder under apps/ is already its own independent git clone with its own remote. Running git init at the bench root gives you one repository that fights with the three inside it, and sweeps sites/ into version control along with the database password. Version your own app only.
- Why is my new app on a branch called develop?
- Because bench new-app initialises the repository with develop as the initial branch and makes a first commit for you. It is not a mistake and you do not have to change it, but it does mean git push -u origin main fails on a brand new app, and that GitHub will show develop rather than main as the branch you pushed.
- I added a Custom Field in the UI. Why is it not in git?
- Because it is a row in the database, not a file. List the doctype in the fixtures hook in hooks.py, run bench --site dev.client.local export-fixtures --app your_app, and it is written to apps/your_app/fixtures/custom_field.json where git can see it. Nothing exports itself.
- Can I just edit files inside apps/erpnext to make a change?
- You can and the next bench update will either overwrite it or leave you resolving conflicts in somebody else's repository. Every change you make there is invisible to your own version control and to whoever maintains the system after you. Put it in your app and use hooks or overrides to reach into ERPNext from outside.