Frappe itself is an app. So is ERPNext, and so are HRMS, Helpdesk and the rest of the official catalogue. When we say “put it in a custom app”, we mean the same kind of package, only it’s yours: it sits next to frappe and erpnext in the apps folder of your bench, and nothing in it gets overwritten when those two are updated.
What’s inside the folder
bench new-app generates the skeleton. On v15 and v16 it looks roughly like this:
apps/acme_custom/
├── pyproject.toml # package metadata and Python dependencies
├── license.txt
├── README.md
└── acme_custom/
├── __init__.py # holds __version__
├── hooks.py
├── modules.txt
├── patches.txt
├── acme_custom/ # the first module, named after the app title
├── config/
├── patches/
├── public/ # served at /assets/acme_custom/
├── templates/
└── www/ # portal pages
The file that matters most is hooks.py, which tells Frappe how the app plugs into document events, scheduled jobs and other apps. modules.txt lists the app’s modules, and every DocType belongs to one of them. patches.txt lists data migrations, split into [pre_model_sync] and [post_model_sync] sections depending on whether they run before or after DocType schemas are synced. Older apps you inherit may still use setup.py and requirements.txt in place of pyproject.toml.
Getting it onto a site
Creating an app and installing it are separate steps. The app lives on the bench; installing it on a site creates its tables in that site’s database.
cd ~/frappe-bench
bench new-app acme_custom
bench --site erp.example.com install-app acme_custom
bench --site erp.example.com list-apps
# on another bench, pull it from your Git remote instead
bench get-app https://github.com/acme/acme_custom --branch main
bench --site erp.example.com install-app acme_custom
If the app depends on ERPNext, say so with required_apps = ["erpnext"] in hooks.py. Frappe then installs ERPNext on the site before your app, and refuses to uninstall ERPNext while your app still needs it. Be careful with uninstall-app. It removes the app’s data from the site, not only its code.
Why we default to a custom app
Anything created through the desk without an app lives in one site’s database. You can’t diff it, test it or review it. With an app, DocTypes are JSON files, controllers are Python, fixtures carry your Custom Fields, and bench migrate brings any site up to date. That’s the setup erpfly generates: a Frappe app delivered as a pull request, with no edits to the frappe or erpnext folders. Our page on ERPNext custom module development goes through what usually goes into one.
An app isn’t always worth it. For a one-line validation you need today, on a site where you can’t deploy code quickly, a Server Script is the practical choice. We compare the two in detail in Server Scripts vs a custom app.
Apps, modules and sites
People mix these three up. A site is one database with its own config, users and data. An app is code on the bench that you install on sites, where the same app can serve many sites. A module is a grouping inside an app, used to organise DocTypes, reports and pages. When hooks from two installed apps clash, Frappe gives priority to the app installed last on that site, which is worth remembering when two custom apps both override the same DocType class.