OpsAPI ships many modules in one image: CRM, invoicing, accounting, UK tax filing, hospital and care, e-commerce, delivery, chat, kanban, an LMS, a CMS and more. Few deployments want all of them. The PROJECT_CODE environment variable picks which ones a deployment has, at boot, for the whole process. Modules it leaves out have no routes and no tables.
How it flows
helper/project-config.lua reads the variable, splits it on commas, lowercases and trims each code, and takes the union of each preset's features. If the variable is unset it means all. An unknown code is logged as a warning and skipped. core is added even if no preset names it:
-- Always ensure CORE is included
if not feature_set[ProjectConfig.FEATURES.CORE] then
table.insert(features, 1, ProjectConfig.FEATURES.CORE)
feature_set[ProjectConfig.FEATURES.CORE] = true
endThe result is cached per worker, so changing the variable means restarting the pods. That's deliberate: the set of routes and the database schema must not drift during a process's life.
The presets
These are the codes defined in ProjectConfig.PROJECT_FEATURES today:
| Code | Features it enables (core always included) |
|---|---|
all | Every feature: the default when PROJECT_CODE is unset |
core_only | menu, themes |
tax_copilot | tax_copilot, notifications, menu, themes |
business | crm, timesheets, invoicing, accounting, kanban, notifications, menu, forms, themes |
ecommerce | ecommerce, delivery, notifications, reviews, menu, shop, forms, themes |
ecommerce_chat | ecommerce, delivery, chat, notifications, reviews, menu, forms, themes |
collaboration | chat, kanban, notifications, menu, vault, services, forms, themes |
hospital | hospital, notifications, menu, forms, themes |
field_service | field_service, ecommerce, timesheets, invoicing, notifications, menu, forms, themes |
property | crm, kanban, invoicing, notifications, menu, forms, themes |
cms | cms, menu, forms, themes |
academy | academy, menu, forms, themes |
billing | billing, notifications, menu, forms, themes |
services | services (feature-only) |
forms | forms (feature-only) |
ats | ats (feature-only) |
Combining codes
Codes combine with commas: PROJECT_CODE=ecommerce,chat or tax_copilot,services. Three codes, services, forms and ats, are feature-only: they add their module and migrations but don't seed a tenant of their own when combined with a real product code. So PROJECT_CODE=tax_copilot,ats still yields exactly one primary workspace, now with hiring enabled.
Gating routes
In app.lua, core route files go through safe_load_routes unconditionally. Every module route goes through load_if, which only requires the file when its feature is enabled. A list means any of them:
load_if("kanban", "routes.kanban-boards")
load_if({ "ecommerce", "billing" }, "routes.customers")Because the route is never registered, a request for a disabled module gets 404 from the router for everyone. No middleware runs, no role or ownership can unlock it, and nothing touches its tables.
Gating migrations
migrations.lua is one registry of every migration, built conditionally. Feature modules are loaded with load_if_enabled, and each registry entry is wrapped in conditional or conditional_array:
local crm_system_migrations = load_if_enabled(ProjectConfig.FEATURES.CRM, "migrations.crm-system") or {}
['500_crm_create_pipelines'] = conditional_array(ProjectConfig.FEATURES.CRM, crm_system_migrations, 1),The detail that matters is what a disabled entry returns. It returns nil, not a no-op function, so the key is left out of the registry entirely. The comment in the file explains why: a no-op would be run and recorded in lapis_migrations as applied, so the day you enable the feature its tables would never be created and its routes would fail with "relation does not exist". Leaving the key out keeps the migration pending until the feature is on. Skips are still counted by helper/migration-tracker.lua, which also supports a dry run.
Why both places, every time
Adding a module therefore touches the same feature name in several places:
- A migration module in
migrations/, registered withconditionalorconditional_arrayinmigrations.lua. - Model, queries and routes files, with the route loaded through
load_ifinapp.lua. - The RBAC modules under
PROJECT_MODULESinhelper/project-config.lua, so the role editor offers them. - The feature added to the presets that should include it.
Anything every request depends on belongs in core, not in a feature. The error catalogue used to be created only by the tax module; deployments without tax answered every error with a generic 500 until it moved to core.
PROJECT_CODE is the only hard boundary
Because it's process-wide, PROJECT_CODE is the only control that holds for workspace owners and platform admins too. Roles restrict members; menu settings only hide pages. If one customer must only ever reach the business modules (CRM, timesheets, invoicing, accounting, kanban), give them a deployment and database of their own with a narrow preset such as business. Only the codes in the table above are recognised; an unknown one is skipped with a warning, so check the boot log after changing it. The live demo identity service runs core_only for exactly this reason: sign-in, users, workspaces and roles, and nothing else.
Local and CI usage
./start.sh -e local -n -j core_only # core + menu + themes tables
./start.sh -e local -n -j tax_copilot # core + tax tables
docker exec -e PROJECT_CODE=ecommerce -it opsapi lapis migrate