TL;DR: When building a custom Paragon design tokens theme and the CI runs inside Kubernetes pods (EKS-based GitHub Actions runners, etc.), tutor local do paragon-build-tokens and the docker run -v approach will silently fail. Our team ended up pre-building theme assets in a separate pipeline and downloading them during the MFE build. Sharing what we learned in case it saves others a few hours/days.
What we were trying to do
We migrated our custom MFE brand theme from the legacy @edx/brand approach (Palm era) to Paragon design tokens (Ulmo+). The build script followed the pattern from tutor-contrib-paragon:
- Install and enable the paragon plugin
- Clone brand repo with token JSON files
- Run
tutor images build paragon-builder - Run
tutor local do paragon-build-tokensto compile tokens into CSS - Run
docker run -v ... npx paragon build-scssto compile brand SCSS - Copy compiled CSS into the MFE build context
This worked perfectly on local machines (Docker Desktop on macOS/Linux).
What broke
When the GitHub Actions CI workflow ran on Kubernetes-based runners (EKS runner group), both Docker-based steps failed silently:
tutor local do paragon-build-tokensloggedSuccessfully created bundle: coreandSuccessfully created bundle: lightbut the output was written to a tmpdir \inside\ the container. Thecompiled-themesdirectory on the pod filesystem remained empty.- The
docker run -v "$BRAND_DIR:/brand:ro"container saw an empty/branddirectory, even though the pod had the files.
Why it happens
When Docker runs inside a K8s pod using the Docker-outside-of-Docker (DooD) pattern, bind-mounts reference the K8s node’s filesystem, not the pod’s filesystem. The Docker daemon runs on the node and has no visibility into the pod’s filesystem.
A path like /tmp/tmp.9Uh0a4H0cd/paragon exists inside the runner pod, but the Docker daemon (on the node) can’t see it the mount resolves to nothing.
This affects:
tutor local do paragon-build-tokens(uses volume mounts internally)- Any direct
docker run -vcall issued from within the pod
This is not specific to any particular infrastructure setup, it’s a fundamental limitation of the DooD pattern that affects any self hosted K8s runners (GitHub Actions on EKS, GitLab runners on K8s, Jenkins agents on K8s, etc.).
Our solution: pre-build and download
We decoupled CSS compilation from the MFE build entirely:
1. Brand repo has its own CI pipeline
A GitHub Actions workflow in the brand repo compiles everything on release:
npm run build-tokens # paragon build-tokens → CSS custom properties
npm run build-scss # paragon build-scss → core.min.css
tar -czf "$VERSION.tar.gz" -C dist .
curl --upload-file "./$VERSION.tar.gz" "https://your-artifact-repo/brand-theme/$VERSION.tar.gz"
2. MFE build downloads the pre-built artifact
# just download and extract:
curl -fSL "https://your-artifact-repo/brand-theme/$THEME_VERSION.tar.gz" -o "$TARBALL"
tar -xzf "$TARBALL" -C "$TUTOR_ROOT/env/plugins/mfe/build/mfe/paragon-statics"
3. Custom YAML plugin injects the settings
Since the `tutor-contrib-paragon` plugin is no longer needed at deploy time (it also patches K8s volumes which are unnecessary when baking CSS into the image), the PARAGON_THEME_URLS settings are injected via a custom plugin:
name: my_brand
version: 1.0.0
patches:
openedx-lms-common-settings: |
MFE_CONFIG["PARAGON_THEME_URLS"] = {
"core": {
"urls": {
"brandOverride": "{{ HOST_URL }}/path/to/core.min.css",
},
},
"variants": {
"light": {
"urls": {
"brandOverride": "{{ HOST_URL }}/path/to/light.min.css",
},
},
"dark": {
"urls": {
"brandOverride": "{{ HOST_URL }}/path/to/dark.min.css",
},
}
},
}
Environment details
- Tutor 21 (Ulmo release)
tutor-contrib-paragonat commitdedee02- GitHub Actions runners on EKS (Docker-outside-of-Docker)
@openedx/paragonv23.14+
Hope this helps and saves others the debugging time.