When you need an API OpsAPI doesn't have, you don't fork it. A plugin is a folder of Lua files that OpsAPI loads at startup. It gets the same login, workspace scoping, RBAC, migrations and Swagger docs as the built-in modules, plus generated dashboard pages, event handlers, scheduled jobs and per-workspace settings. The same mechanism lets an external product keep its schema in its own repository and ship it in an image built on top of OpsAPI.

From folder to routes

How plugins are discovered and mounted when a worker starts/app/projects/helpdesk/project.luaapi/tickets.luaevents/billing.luajobs/close_stale.luamigrations/2026…_create.luaui/overview.htmlOPSAPI_PROJECTS_DIRProjectLoader.discoverfolders with a project.lualoadManifestcode not reserved, prefix free,sdk_version supportedregisterProjectConfig.registerFeatureloadRoutesunder /api/v2/helpdeskLoad failurerecorded in failures()/ready answers 503rollout stopsSame treatment as coreauth, workspace, RBAC,Swagger, dashboard menu
A broken plugin never fails silently: the pod reports not ready and a rolling update stops with the old pods still serving.

At the bottom of app.lua, after the core and feature routes are mounted, the loader scans the projects folder and mounts each plugin it accepts:

local ProjectLoader = require("helper.project-loader")
for _, manifest in ipairs(ProjectLoader.init(os.getenv("OPSAPI_PROJECTS_DIR") or "/app/projects")) do
    ProjectLoader.loadRoutes(app, manifest)
end

The manifest is validated before anything is registered:

  • code is lowercase letters, digits and underscores, and can't be a built-in module's code such as crm.
  • api_prefix (default /api/v2/<code>) must be under /api/ and not already used.
  • A plugin that needs a newer sdk_version than the running OpsAPI is refused.
  • A plugin can't replace a built-in route, and its before_filter only runs for its own routes.

Scaffolding with the CLI

The opsapi CLI in the image generates the pieces:

docker exec opsapi opsapi plugin:new helpdesk
docker exec opsapi opsapi make:resource helpdesk ticket title:string:required status:string
docker exec opsapi opsapi migrate
docker exec opsapi opsapi make:page helpdesk overview "Support overview"
docker exec opsapi opsapi make:listener helpdesk invoice.paid
docker exec opsapi opsapi make:job helpdesk close_stale 1h
CommandWhat you get
plugin:newThe folder and a project.lua manifest
make:resourceA migration (id, uuid, namespace_id with cascade, your fields, timestamps, tenant index), an api/ file using sdk.crud, and the RBAC module, sidebar entry and events added to the manifest
make:pageA custom dashboard page under ui/, run in a sandboxed frame
make:listenerA handler that runs when another module publishes an event, such as invoice.paid
make:jobA scheduled job that runs on an interval, once per workspace

sdk.crud registers list, show, create, update and delete, each behind its own permission (helpdesk_tickets.read, .create and so on), scoped to the caller's workspace, with whitelisted, type-checked input. List responses use the same { success, data, meta } envelope as core. Routes under /api/v2/<code>/public/ skip login; everything else needs a JWT or API key.

Events and jobs

Plugins can publish changes to their tables as events (helpdesk.ticket.created, .updated, .deleted, plus business verbs like .closed) and subscribe to events from other modules. Events are recorded in the same transaction as the change, then delivered by background workers: at least once, retried with backoff, and claimed by exactly one worker with FOR UPDATE SKIP LOCKED. Handlers should be idempotent and key on event.id. Workspace webhooks use the same outbox.

Per-workspace switch and settings

A plugin is installed for the whole deployment, but each workspace can turn it off under Workspace → Plugins (PUT /api/v2/namespace/plugins/:code, which needs namespace.update). When it's off there, its routes answer 404 with "code": "PLUGIN_DISABLED", its pages leave the sidebar, and no events or jobs run for that workspace. In the current code platform admins bypass the switch so they can operate a plugin before enabling it. default_enabled = false makes a plugin opt-in.

Settings a workspace fills in are declared in the manifest and rendered as a form without dashboard code. Handlers read them with sdk.settings(self), event.settings or job.settings. URL settings are called with sdk.http, which refuses private and internal addresses. Deployment-wide secrets come from environment variables named PLUGIN_<CODE>_<SETTING>.

Migrations

How plugin migrations run as part of lapis migratelapis migrateHelm pre-upgrade Jobcore migrationslapis_migrationszzx_run_project_migrationsre-runs on every migratemigrateAllevery pluginPER PLUGIN, UNDER A POSTGRES ADVISORY LOCKpending filesfilename ordermigration + rowone transactionin project_migrationssync manifestRBAC, menu, jobs,events
Plugin migrations are tracked apart from core in project_migrations, keyed by plugin code and file name.

lapis migrate runs the core registry, including an entry named to sort last, zzx_run_project_migrations, which calls ProjectMigrator.migrateAll. A database trigger deletes that entry's row from lapis_migrations as soon as it's recorded, so it runs again on every deploy. For each plugin the migrator:

  • takes a Postgres advisory lock, so several pods or a Helm hook plus a manual run can't race;
  • runs pending files in filename order, each in a transaction with its tracking row, so a failure leaves nothing half-applied and stops the deploy;
  • stores a checksum and reports drift if an applied file is later edited;
  • syncs the manifest: RBAC modules, menu entries, jobs and event subscriptions.
CREATE TABLE IF NOT EXISTS project_migrations (
    ...
    checksum VARCHAR(64),
    UNIQUE(project_code, migration_name)
)

A migration file returns function(schema, db) … end, or { transaction = false, up = function(schema, db) … end } for statements such as CREATE INDEX CONCURRENTLY. Prefix tables with the plugin code and give every tenant table a namespace_id foreign key with an index that starts with it.

Consumer products: your schema, your repo

Some products use OpsAPI as their backend without living in its repository. diy-tax-return-uk is one. They don't add migrations to lapis/migrations/. Instead they ship a small image that adds their manifest and migrations to the projects folder, and run the same migrator from their own deploy hook:

How an external product ships its schema in its own imageconsumer repoproject.luamigrations/*.luaDockerfileImage FROM bwalia/opsapi:<tag>COPY into/app/projects/<code>/k8s pre-upgrade hooklapis exec project-migrator.migrate(code, path)Same database, same runnerproject_migrations(project_code, name)No change to OpsAPI's repo or itslapis/migrations folder.
From OpsAPI's side this is invisible: the runner doesn't care whether a project was baked into the image or added at deploy time.
FROM bwalia/opsapi:<tag>          # pin the version you tested against
COPY migrations/ /app/projects/<code>/migrations/
COPY project.lua /app/projects/<code>/project.lua

# Kubernetes pre-upgrade hook:
lapis exec "require('helper.project-migrator').migrate('<code>', '/app/projects/<code>')"

The product's history lives in project_migrations under its own code, so it never collides with OpsAPI's lapis_migrations, and OpsAPI upgrades don't need to know about it.

Security model

Plugins are trusted code. They run inside the OpsAPI process with full database access. Only the operator of a deployment installs them, by baking them into an image. Never let tenants upload plugins: in a shared deployment one tenant's code could read every other tenant's data. Tenant-level customisation belongs in configuration stored in the database.

Deploying

  1. Bake plugins into an image FROM bwalia/opsapi:<version> and run opsapi plugin:check in the build.
  2. Run lapis migrate before new pods take traffic, for example in a Helm pre-upgrade Job; it runs core then plugin migrations.
  3. Keep the readiness probe on /ready, so a plugin that fails to load stops the rollout.
  4. Don't mount plugins from a writable shared volume. Rebuild the image instead.

The full guide is PLUGINS.md, and projects/helpdesk is a complete example.