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

From the PROJECT_CODE variable to loaded routes and created tablesPROJECT_CODEe.g. tax_copilot,servicesunset = allProjectConfigsplit on commas, lowercaseunion of preset featurescore always addedapp.lua: load_if(feature, route)require the route file only when onoff: no route, router answers 404migrations.lua: load_if_enabled+ conditional / conditional_arrayMenu + default rolessidebar items and RBAC modulesfiltered to the enabled setPlugins add their own feature at boot with ProjectConfig.registerFeature(code, features, modules).
One variable feeds three consumers. The route loader and the migration loader must agree, which is the rule this note is about.

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
end

The 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:

CodeFeatures it enables (core always included)
allEvery feature: the default when PROJECT_CODE is unset
core_onlymenu, themes
tax_copilottax_copilot, notifications, menu, themes
businesscrm, timesheets, invoicing, accounting, kanban, notifications, menu, forms, themes
ecommerceecommerce, delivery, notifications, reviews, menu, shop, forms, themes
ecommerce_chatecommerce, delivery, chat, notifications, reviews, menu, forms, themes
collaborationchat, kanban, notifications, menu, vault, services, forms, themes
hospitalhospital, notifications, menu, forms, themes
field_servicefield_service, ecommerce, timesheets, invoicing, notifications, menu, forms, themes
propertycrm, kanban, invoicing, notifications, menu, forms, themes
cmscms, menu, forms, themes
academyacademy, menu, forms, themes
billingbilling, notifications, menu, forms, themes
servicesservices (feature-only)
formsforms (feature-only)
atsats (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

What happens when route gating and migration gating disagreeTABLES CREATEDTABLES NOT CREATEDROUTES LOADEDROUTES NOT LOADEDWorksroutes and tables500srelation … does not existUnused tablesharmless, but untidy404, no tablesmodule simply absent
The top-right cell is the outage. Gate a new module's routes and its migrations on the same feature.

Adding a module therefore touches the same feature name in several places:

  1. A migration module in migrations/, registered with conditional or conditional_array in migrations.lua.
  2. Model, queries and routes files, with the route loaded through load_if in app.lua.
  3. The RBAC modules under PROJECT_MODULES in helper/project-config.lua, so the role editor offers them.
  4. 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