Hi Friend!
Good questions — this comes up a lot as more people move multi-tenant deployments onto the MFE architecture. Here’s how I’d break it down, LMS side and MFE side separately, since they use completely different mechanisms.
LMS side (Indigo per tenant)
Yes, this is doable, and you don’t need a separate deploy per tenant. The combo that works well is eox-tenant + eox-theming:
- Make sure
USE_EOX_TENANTis enabled and your themes are already available underCOMPREHENSIVE_THEME_DIRS(each theme folder can be a fork/variant of Indigo, or a lightweight theme that just overrides a few SCSS variables and images on top of Indigo). - With
eox-theminginstalled, you add aTHEME_OPTIONSblock to each tenant’s config (via the eox-tenant admin or API):
json
{
"THEME_OPTIONS": {
"theme": {
"name": "tenant-a-theme",
"parent": "indigo",
"grandparent": "base-theme"
}
}
}
eox-theming does a hierarchical template lookup (name → parent → grandparent), so you only need to override what’s actually different per tenant — logo, colors, maybe a couple of templates — and let everything else fall back to Indigo. Switching a tenant’s theme is just a config change, no rebuild/redeploy needed on the LMS side.
One gotcha to watch for: on newer Open edX releases (Teak+), _make_mako_template_dirs needs a settings argument now, so if you’re on an older eox-theming snippet from the docs, double check that against your release.
MFE side (shared build, different branding per tenant)
This is the part where people usually get stuck, and yes — PARAGON_THEME_URLS is the right mechanism, but it’s important to understand why it works and where its limits are.
Why you can’t just install two brand packages: brand packages (@edx/brand-*) and Indigo’s MFE brand are npm packages baked into the build at compile time. You physically can’t have two of them active in the same build — this has been confirmed multiple times in the forum (see the “Apply multiple different brands at the same openedx page” thread). So the “one MFE build, N tenants” requirement rules out the classic npm-alias branding approach entirely.
What actually works: CSS variables + runtime URLs. Since Paragon v23 (Teak release), Paragon and brand packages moved from SCSS variables (resolved at build time) to CSS custom properties (resolved at runtime). That means you can build one base Paragon theme, and then swap out the stylesheet that defines the variables without touching the MFE build at all. This is exactly what PARAGON_THEME_URLS is for:
json
{
"PARAGON_THEME_URLS": {
"core": {
"urls": {
"default": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/core.min.css"
}
},
"defaults": { "light": "light" },
"variants": {
"light": {
"urls": {
"default": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css",
"brandOverride": "https://your-cdn.example.com/tenant-a/light.min.css"
}
}
}
}
}
The core/variant split keeps the base Paragon CSS shared (better caching) while brandOverride layers just your tenant’s tokens on top. You compile each tenant’s token set with paragon build-scss, host the resulting dist files wherever (S3, CDN, even served statically by tutor-mfe itself), and just point brandOverride to the right file.
Making it per-tenant at runtime is the piece tutor-contrib-mfe-extensions and mfe_config_api solve together:
mfe_config_apilets each Open edX site/tenant serve its ownMFE_CONFIG(logos,PARAGON_THEME_URLS, feature flags, etc.) based on the requesting domain — this is the actual per-tenant switch.tutor-contrib-mfe-extensionsis useful here mainly for routing: it lets you serve MFEs by path under each tenant’s own LMS domain (https://tenant-a.example.com/learning) instead of a single sharedapps.subdomain, which is what makes “MFE reads config for its own domain” work cleanly in a multi-tenant setup.
So the practical flow is: request hits tenant-a.example.com → Caddy/your routing serves the shared MFE build → the MFE calls mfe_config_api on tenant-a.example.com → gets back tenant A’s PARAGON_THEME_URLS.variants.light.urls.brandOverride and logo URLs → applies them at runtime. No rebuild per tenant.
Things that will NOT work at runtime
Worth being upfront about scope, since I’ve seen people burn time here:
- Structural header/footer differences (different nav items, different layout, not just colors/logo) aren’t achievable this way —
frontend-component-header/footerare still npm-aliased at build time. If tenants need genuinely different header/footer structure (not just branding), you’re back to separate MFE builds per tenant, or a header/footer component that reads tenant config and conditionally renders — more engineering effort. - Logos and simple text (site name, some copy) usually go through
MFE_CONFIGdirectly (e.g.LOGO_URL) rather than through Paragon tokens — check whichever plugin you’re using (tutor-contrib-brandingfrom Aulasneo is a good reference implementation if you want something more turnkey than hand-rolling it). - Comprehensive theming (SCSS-based, build-time) is being phased out in favor of design tokens going forward, so it’s worth building your tenant theming pipeline around
PARAGON_THEME_URLS/tokens now rather than investing further in the old SCSS override approach.
We’ve been running something close to this exact setup in production for a while now, and the above is what we’ve observed works reliably. Happy to go deeper on any part of it if useful.