Dynamic Plugins
Dynamic plugins are the mechanism DevPortal uses to add Backstage plugins
without rebuilding the container image. A dynamic plugin is loaded at
runtime into /app/dynamic-plugins-root/ rather than compiled into the
application bundle.
Loading is step 1 of 3
This doc covers how plugins are packaged and loaded. Loading is necessary but not sufficient — a loaded plugin does nothing visible until two more steps are in place:
- Load (a preset or
dynamic-plugins.yaml) — this doc. Makes the plugin's code available. - Context (entity annotations in
catalog-info.yaml) — tells the plugin which catalog entities it should attach to. Without the correct annotation on an entity, the plugin is loaded but idle. - Backend (
app-config) — provides the credentials and endpoints the plugin queries. Without this, the plugin's tab appears but shows an error or empty state.
All three must be in place before a developer sees live data. See Composing a Portal for the full model and a worked example.
Static core vs. dynamic plugins
The image splits its plugins into two categories, mirroring Red Hat Developer Hub's approach: a thin static core handles identity and data, and the feature surface expands through the runtime plugin directory.
- Static plugins are registered directly in the backend
(
packages/backend/src/index.ts): auth, catalog, scaffolder, search, notifications, kubernetes backend, permissions, and the RBAC backend. Many integration presets only configure these static plugins (viaappConfig:) and ship an emptyplugins: []list. - Dynamic plugins are everything else — every UI tab, every optional
catalog provider, every CI integration. They are pulled at boot and installed
into
/app/dynamic-plugins-root/.
A handful of core chrome dynamic plugins ship pre-installed and always-on
(no disabled: field): veecode-homepage, veecode-global-header, the About
page and its backend, dynamic-plugins-info, and the marketplace catalog entity
provider. They are extracted into the image at build time and need no preset.
The catalog: dynamic-plugins.default.yaml
dynamic-plugins.default.yaml is the catalog (the vitrine) — the
authoritative list of every optional plugin the image knows about. Every
optional entry ships with disabled: true. No optional plugin is on by
default; the image boots to a working shell with only the pre-installed chrome
plugins visible.
Editing this file changes what is available, not what is enabled. Do not edit it to enable a plugin for one deployment — it is image-level configuration. Use one of the selection surfaces below instead.
How plugins get enabled — the three selection surfaces
A plugin is enabled if any selection surface includes it. There are three:
1. Presets (VEECODE_PRESETS) — recommended
Presets flip disabled: false for the plugins they enable. Critically, the
plugin's pluginConfig: block (mount points, dynamic routes, RBAC scopes, menu
items) stays in dynamic-plugins.default.yaml. A preset entry only carries the
package: key and disabled: false — install-dynamic-plugins.py merges
records shallow by package: key, so the default's pluginConfig: attaches
automatically. Enabling an existing plugin via a new preset is a ~3-line YAML
addition. See Presets.
2. Operator override — mounted dynamic-plugins.yaml
Mount a dynamic-plugins.yaml (read-only bind mount, or a Kubernetes ConfigMap)
with a top-level plugins: list. The entrypoint copies it to a writable shadow
and rebuilds the includes: chain on every boot, preserving your plugins:
entries. Because the operator's plugins: list is processed last, toggling
disabled: true/false here always wins over preset fragments. Apply changes
with docker compose restart (the entrypoint re-runs at boot).
3. Marketplace UI
The in-portal marketplace (/marketplace, enabled by the
recommended preset) lets end users install and uninstall plugins. The
marketplace backend writes selections to /app/data/extensions-install.yaml,
which is included in the plugin chain on the next restart and survives
container restarts as long as the /app/data volume is retained. (This is why
/app/data must be a directory volume, not a single-file bind mount.)
The boot catalog (dynamic-plugins.default.yaml) and the marketplace's
plugin-catalog-index are two independent artifacts that share content but are
maintained separately. Editing one does not sync the other. The unification of
the two is a deferred decision — see
ADR-013 in the
devportal-platform repo.
For the full precedence table when surfaces conflict, see Adding Plugins.
OCI reference shape
OCI plugin references in dynamic-plugins.default.yaml follow this pattern:
oci://${PLUGIN_REGISTRY}/<workspace>:bs_${BACKSTAGE_VERSION}!<selector>
For example:
# RBAC UI — workspace "rbac", selector "backstage-community-plugin-rbac"
- package: oci://${PLUGIN_REGISTRY}/rbac:bs_1.49.4!backstage-community-plugin-rbac
# Marketplace frontend — version tracked via BACKSTAGE_VERSION
- package: oci://${PLUGIN_REGISTRY}/marketplace:bs_${BACKSTAGE_VERSION}!devportal-marketplace-frontend-dynamic
The four parts:
${PLUGIN_REGISTRY}— defaults toquay.io/veecode; substituted byentrypoint.sh. Override it (e.g.PLUGIN_REGISTRY=registry.internal/veecode) to redirect all OCI pulls that use the${PLUGIN_REGISTRY}variable form (the large majority of bundled plugins) to an internal mirror without editing any YAML.<workspace>— the export-overlays workspace that produced the bundle (e.g.marketplace,rbac,tech-radar,sonarqube,backstage). One workspace can bundle several packages.bs_${BACKSTAGE_VERSION}— the OCI tag.${BACKSTAGE_VERSION}is substituted frombackstage.json, so a Backstage bump propagates to every reference that uses the variable form. Some entries pin a literal version (e.g.bs_1.48.4,bs_1.49.4) when the plugin has not yet been re-published under the current tag.!<selector>— the specific npm package name inside the bundle.
Pre-installed chrome plugins use a bare npm package name (no oci:// prefix)
with preInstalled: true; the install script skips the pull and only merges
their pluginConfig:.
PLUGIN_REGISTRYThe MCP plugin refs (mcp-actions-backend, mcp-integrations, mcp-chat) are
hardcoded to quay.io/veecode and do not use the ${PLUGIN_REGISTRY}
variable form. Setting PLUGIN_REGISTRY does not redirect them. Air-gapped or
mirror deployments using the mcp or mcp-chat presets must mirror those
workspaces (quay.io/veecode/backstage, quay.io/veecode/mcp-integrations,
quay.io/veecode/mcp-chat) explicitly, or override the refs in a
dynamic-plugins.yaml.
Boot sequence (what installs your plugins)
At container start, before Backstage accepts requests:
- Preset resolver validates required env vars (exit 78 on missing) and
writes a
preset-<name>-plugins.yamlfor each preset with aplugins:list. - Assemble the includes chain —
dynamic-plugins.yamlanddynamic-plugins.default.yamlare copied to writable working files and theincludes:chain is rebuilt to reference the catalog, the marketplace file, and each preset's plugin file. ${BACKSTAGE_VERSION}and${PLUGIN_REGISTRY}substitution across the working files.install-dynamic-plugins.py— for each enabled entry,skopeo copypulls the OCI bundle, the named selector is extracted into/app/dynamic-plugins-root/<selector>/, and the entry'spluginConfig:is merged into/app/dynamic-plugins-root/app-config.dynamic-plugins.yaml. Pre-installed entries skip the pull.- Backend boot — Backstage reads
app-config.dynamic-plugins.yamlto discover mount points, dynamic routes, and RBAC scopes.
Loaded plugins are surfaced at
/api/dynamic-plugins-info/loaded-plugins once the backend is up.
If a plugin bundle cannot be pulled (registry unreachable, typo'd OCI ref,
missing mirror bundle), the install script prints an INSTALL SUMMARY of failed
refs and the entrypoint exits 78 rather than booting a half-installed
portal. The same exit-78 guard fires when the same plugin is enabled with two
different OCI refs (the duplicate detector). For dev iteration only, set
DYNAMIC_PLUGINS_TOLERATE_FAILURES=true to proceed with whatever installed.
Distribution modes
Three modes are supported by design:
- Default — runtime OCI pull. The image ships with no optional plugin bytes.
At boot,
install-dynamic-plugins.pypulls each enabled plugin fromquay.io/veecode/<workspace>:<tag>. No operator config beyondVEECODE_PRESETS. Best for cloud/SaaS with outbound registry access. - Mirror — internal registry. Set
PLUGIN_REGISTRY=registry.internal/veecode(or any prefix mirroringquay.io/veecode). The entrypoint substitutes it into everyoci://${PLUGIN_REGISTRY}/...reference before the install runs — no YAML edits needed for the plugins that use the variable form. The mirror must host the same workspace/tag paths. Note that the MCP plugin refs (mcp-actions-backend,mcp-integrations,mcp-chat) are hardcoded toquay.io/veecodeand are not redirected; see the caution in the OCI reference shape section above. - Loaded variant — air-gapped image. Build a derived image that extracts the
selected plugin bundles at build time and copies them into
/app/dynamic-plugins-root/. Mark those entriespreInstalled: trueso the install script skips the pull. Pre-baked variants are the operator's responsibility; the published image stays generic.
Related
- Presets — the recommended way to enable plugins.
- Configuration Hierarchy — how
app-config.dynamic-plugins.yamlfits the config merge order. - Adding Plugins — the selection surfaces in practice and the full precedence rules.