</>CodeWithKarani

How to Manage Frappe/ERPNext Custom Code via GitHub

Karani GeoffreyKarani Geoffrey7 min read

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_app creates a git repository, commits it, and puts you on a branch called develop, not main.
  • Never edit apps/erpnext or apps/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 fixtures hook 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.

What is in git already and what is only in the database Two columns. On the left, work that is a file in the app and is therefore versioned as soon as it is saved: DocTypes you created, Python controllers and hooks, reports, client scripts written as JavaScript files, patches, and the exported fixtures directory. On the right, work that exists only as rows in one database: Custom Fields added through the interface, Property Setters, Client Scripts and Server Scripts created in the browser, Print Formats built in the editor, and Workflows and translations. A bar underneath shows the export-fixtures command as the only thing that moves the right column into the left. A file in your app, so git sees it the moment you save A row in one database, so git sees nothing DocTypes you created, as JSON and Python Custom Fields added through the interface Controllers, hooks.py, scheduled tasks Property Setters: renamed labels, hidden fields Reports, dashboards and web pages in the app Client Scripts and Server Scripts written in the browser Client scripts committed as .js files Print Formats built in the format editor Patches listed in patches.txt Workflows, roles and custom translations fixtures/*.json, once you have exported them and everything else you did by clicking One command moves the right column into the left bench --site dev.client.local export-fixtures --app your_app It exports only the doctypes you listed in the fixtures hook. Anything you never list stays on one machine, and dies with it.
The right column is not a bug in Frappe. It is the price of a system you can customise without deploying, and fixtures are how you pay it.

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

The loop from a local bench through GitHub to the server Three stages left to right. On your own machine you create the app, edit code, export fixtures and commit. A git push sends it to the repository on GitHub, which is the single source of truth and the place tags and continuous integration live. A git pull brings it down to the server bench, where bench get-app installs it the first time, and afterwards a pull followed by bench migrate, bench build and a restart applies it. A note records that the same three commands run on the server whether a human types them or a pipeline does. git push git pull Your machine local bench, apps/your_app GitHub your-org/your_app The server its own bench, its own clone once bench new-app then, every change edit code export-fixtures git commit git push what it is for The only copy that is not on one laptop The history of what changed and why Tags, releases and CI once bench get-app bench install-app then, every change git pull bench migrate bench build The right-hand column is the same whether a person types it or a pipeline does. Automating it later changes who runs it, not what runs.
Two clones of one repository, on two machines, with GitHub as the only thing both of them can reach.

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.
#Frappe#ERPNext#git#Bench#Deployment
Keep reading

Related articles