Custom brand theme with Paragon design tokens: CI challenges on K8s runners

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:

  1. Install and enable the paragon plugin
  2. Clone brand repo with token JSON files
  3. Run tutor images build paragon-builder
  4. Run tutor local do paragon-build-tokens to compile tokens into CSS
  5. Run docker run -v ... npx paragon build-scss to compile brand SCSS
  6. 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-tokens logged Successfully created bundle: core and Successfully created bundle: light but the output was written to a tmpdir \inside\ the container. The compiled-themes directory on the pod filesystem remained empty.
  • The docker run -v "$BRAND_DIR:/brand:ro" container saw an empty /brand directory, 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 -v call 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-paragon at commit dedee02
  • GitHub Actions runners on EKS (Docker-outside-of-Docker)
  • @openedx/paragon v23.14+

Hope this helps and saves others the debugging time.