The engine can serve a management UI itself, so a self-hosted deployment gets one without running a separate admin application. It is disabled by default.
The panel ships with sign-in and thirteen modules: projects and a per-project overview, project members, project and global API keys, project secrets, tenant-wide persons, the authentication log, a read-only view of the tenant configuration, self-service account security, the project's stages and executed migrations, its data history, and — where the actions plugin is installed — the webhook queue. See the tenant UI components for the pieces the tenant modules are assembled from.
Enabling it
CONTEMBER_PANEL_ENABLED=true
CONTEMBER_PANEL_PATH=/panel # optional, this is the default
Or in config.yaml:
server:
panel:
enabled: true
path: /panel
With the flag off, the panel path returns the same 404 Route not found as any other unknown path —
an engine with the panel disabled is indistinguishable from one that never had it.
What gets served
The panel is a single-page application bundled into the engine binary: there is no directory to mount, no extra image, and no version skew between the UI and the API it talks to. Everything under the configured path is served from that bundle — content-hashed assets are served immutable, and any other path returns the application shell so client-side routing works.
The panel's own API traffic lives directly under the same path (/panel/api), on the same origin as
the page, so there is no CORS setup and no separate host to configure.
Plugins can serve their API from that mount too, and the panel tells the UI which ones are there — a
module whose plugin is not installed simply does not appear. With the actions plugin enabled, the
panel serves /panel/api/actions/:project alongside the tenant, content and system paths.
What the panel does not do
The system modules are read-only. Stages, executed migrations and the data history are shown;
migrations are not executed from the panel — that stays with
contember migrations:execute, the same division as the read-only
tenant configuration view. The actions module is the one
place the panel writes outside the tenant API: retrying, stopping and dispatching a queued event, and
setting the variables a webhook target interpolates, are operational work with no CLI equivalent.
The panel is served from the engine's HTTP port. If that port is public, so is the panel's login
page. It is served with frame-ancestors 'none', noindex and a restrictive Content-Security-Policy,
but the usual network-level protections you apply to the API apply here too.
Enabling the panel also makes /panel/api/tenant answer unauthenticated requests with the login
role — the operations a sign-in screen needs (signIn, password reset, magic links, IdP redirects)
and nothing else. This is what every admin application already exposes by shipping
CONTEMBER_LOGIN_TOKEN to the browser, except that here no token exists at all: the caller is a
virtual in-memory identity whose roles are fixed in the engine, so the surface cannot be widened by
configuration and there is nothing to leak or rotate. CONTEMBER_LOGIN_TOKEN is not involved and does
not have to be set for the panel to work.
Anonymous calls require same-origin browser signals (Sec-Fetch-Site or a matching Origin). This
prevents another site from driving the endpoint from a visitor's browser, but it does not authenticate
non-browser clients — a direct HTTP client can send the same headers. The per-IP rate limits on sign-in
and mail dispatch apply exactly as they do on /tenant; configure them before exposing the panel.
Give this path the same protection you would give a published login token.
Who may sign in
Enabling the panel decides whether it is served at all. Who may enter it is part of the tenant configuration, not of the deployment environment:
config: {
panel: {
globalRoles: ['super_admin', 'project_admin'],
projectRoles: ['admin'],
},
}
An identity may enter when one of these holds:
- one of its global roles (
identity.roles) is listed inglobalRoles, or - it has a membership with one of
projectRolesin at least one project.
The defaults are globalRoles: ['super_admin', 'project_admin'] and projectRoles: ['admin']. An empty list closes
that dimension; with both empty no identity can reach the console.
This governs the console, not the deployment flag. The unauthenticated sign-in operations described
above stay reachable while the panel is enabled, so closing both lists is not a way to switch the
panel off — set CONTEMBER_PANEL_ENABLED=false for that.
See declarative configuration for how to apply this,
or set it with the configure mutation directly.
Being allowed into the panel grants no rights. Every action taken inside it goes through the same tenant API and the same ACL as any other client, so a user only ever sees and changes what their roles and memberships already permit. Widening these lists opens the door; it does not hand out permissions.
Building from source
When you build the engine yourself, scripts/server-build/run.sh generates the panel assets into the
bundle for you. If you invoke bun build directly, or run the engine straight from source (as
docker-compose up engine does), run this first:
bun run --filter=@contember/engine-panel build:assets
It is deliberately not part of the repo-wide pre-build, so that unrelated builds — the CLI image,
for one — do not have to build a panel they cannot use.
Without the assets the engine still starts, logs that the panel is enabled but unbuilt, and does not serve it. A released build cannot end up in that state, because the asset build fails when it produces nothing.