Setting Up a CI Pipeline for the Tailwind Build in the Hyvä Theme
AI generated
Hyvä
phtml
Hyvä Theme · Testing & CI
Setting Up a CI Pipeline for the Tailwind Build in the Hyvä Theme
Why the production build belongs in a reproducible pipeline stage, not on a developer's laptop

As long as the Tailwind production build runs manually through bin/npm run build on a single developer's machine, the result depends on locally installed Node versions, forgotten intermediate state, and whoever happened to run it that day. This article shows how to set up the build as its own reproducible CI stage, how a build cache noticeably speeds up pipeline runs, and how broken Tailwind classes can be caught automatically before deployment instead of after.

9 min read Tailwind CSS GitLab CI Build Cache

1. The manual Tailwind build as an underestimated deployment risk

In many Hyvä projects, bin/npm --prefix app/design/frontend/[Vendor]/[Theme]/web/tailwind run build still gets run by hand, on the machine of whoever last touched the frontend, before the result gets committed or deployed directly. That works fine as long as only one person touches the theme, but it quickly turns into a source of bugs once several developers work on different branches in parallel, each with a slightly different local Node or npm version installed.

The real core of the problem is that a manual build step is never reliably reproducible, because it depends on environment factors that live outside the Git repository entirely. A CI pipeline removes exactly that variable by running the build in a precisely defined, versioned environment that is identical for every commit, regardless of who wrote the code or which operating system is running locally.

2. Defining the Tailwind build as its own reproducible CI stage

The cleanest approach is a dedicated pipeline stage responsible only for the Tailwind build, sitting clearly ahead of the deployment job instead of being hidden as a side effect of a larger script. That stage checks out the repository, installs the exact dependencies pinned in package-lock.json, and runs the production build with the same flags already defined in the project's deploy sequence.

It matters that this stage runs inside a Docker image with exactly the same Node version used in the Mark Shust setup, so local development and CI produce the same result. Any drift here leads to the classic pattern where a build passes fine locally but silently produces subtly different CSS in the pipeline because of a different Tailwind engine version.


tailwind-build:
  stage: build
  image: node:20-bookworm-slim
  script:
    - cd src/app/design/frontend/Mironsoft/default/web/tailwind
    - npm ci
    - npm run build
  artifacts:
    paths:
      - src/app/design/frontend/Mironsoft/default/web/css/styles.css
    expire_in: 1 day

3. Setting up an effective build cache between pipeline runs

Without a cache, every pipeline run reinstalls every single npm dependency from scratch, even though package-lock.json usually doesn't change at all between two commits. GitLab CI lets you persist the node_modules folder behind a cache key built from the hash of package-lock.json, so a cache hit only gets invalidated when a dependency actually changed, not on every single commit.

Beyond the node_modules cache, a second cache for Tailwind's own internal build cache pays off too, since the engine uses it on repeated runs to avoid recomputing utility classes it has already processed once. In practice, combining both cache layers cuts the build time of a typical Hyvä pipeline from several minutes down to a few seconds whenever dependencies haven't changed.


tailwind-build:
  stage: build
  image: node:20-bookworm-slim
  cache:
    key:
      files:
        - src/app/design/frontend/Mironsoft/default/web/tailwind/package-lock.json
    paths:
      - src/app/design/frontend/Mironsoft/default/web/tailwind/node_modules/
      - src/app/design/frontend/Mironsoft/default/web/tailwind/.tailwind-cache/
    policy: pull-push
  script:
    - cd src/app/design/frontend/Mironsoft/default/web/tailwind
    - npm ci --prefer-offline
    - npm run build

4. Detecting broken Tailwind classes automatically before deployment

Tailwind v4 scans source code statically for class names, so it fundamentally cannot detect a dynamically composed string like class={'text-' + color + '-500'} inside a .phtml file, because the compiler has no idea what the variable's actual value will be at build time. The result isn't a build failure at all. It's a silent problem that, in the worst case, only becomes visible in production, because the matching utility class is simply missing from the generated CSS.

A dedicated CI step that greps the template code for suspicious string concatenations inside class attributes before the build runs catches these cases far earlier than a manual review ever would. On top of that, checking the generated styles.css size against an expected range helps too, since a sudden, unexplained drop in file size often means the scanner found fewer classes than in the previous build.


#!/usr/bin/env bash
# ci/check-dynamic-classes.sh
set -euo pipefail

MATCHES=$(grep -rEn "class=[\"'].*\$\{" src/app/design/frontend/Mironsoft/default --include="*.phtml" || true)

if [ -n "$MATCHES" ]; then
  echo "Found suspicious dynamic classes that Tailwind's scanner may miss during purge:"
  echo "$MATCHES"
  exit 1
fi

echo "No dynamic class strings found."

5. CSS size diffing between branches as an early quality signal

Beyond simply checking the file exists, comparing CSS size between the current merge request branch and main as a merge job comment pays off, because a sudden jump usually points to accidentally introduced, unpurged utility markup, while a sharp drop can point to a broken content configuration that no longer scans parts of the templates at all.

This diff doesn't replace an actual content review, but it delivers a fast, automated signal that something fundamental has shifted in the generated CSS before a reviewer has even opened the code diff. For teams that touch the Tailwind config often, this signal is frequently the first hint of a problem that would otherwise only surface during a visual review.

6. Running the build in an isolated, deterministic environment

A Docker image with an exactly pinned Node version, used solely for the build and free of any extra globally installed npm packages, prevents the classic problem where a newer, locally installed PostCSS plugin produces a different result than the version pinned in package-lock.json. This isolation is the actual core of reproducibility, not just a configuration detail of the pipeline.

Teams maintaining both the Mironsoft and the Abrams vendor variant of a theme should additionally consider a matrix configuration that runs the same build job in parallel for both theme paths, so a CSS bug in one variant surfaces immediately instead of only showing up during the manual deployment of the other variant.

7. Consistently treating Tailwind warnings as pipeline failures

The Tailwind engine usually only prints a warning to standard output for an unknown or misspelled utility class, while still letting the build finish successfully, because an unknown class is not a technical error from the compiler's point of view. In a CI pipeline this behavior should be deliberately tightened by scanning the build output for known warning patterns and explicitly failing the job with an error code on a match.

This tightening may feel inconvenient at first, since it blocks merge requests that would previously have gone through silently, but it reliably prevents a misspelled class like hoverr:bg-blue-500 from reaching production unnoticed, where it simply has no effect and would only surface through a manual visual comparison.


#!/usr/bin/env bash
set -euo pipefail

npm run build 2>&1 | tee build.log

if grep -qi "warn" build.log; then
  echo "Tailwind build produced warnings, marking the pipeline as failed."
  exit 1
fi

8. Passing the built CSS to the deploy job as an artifact

A common anti-pattern is re-running the Tailwind build in both a test stage and a deploy stage, which not only wastes time but could theoretically produce two slightly different CSS results if an external dependency changes between the two runs. Instead, the build should run exactly once, with the result passed on to subsequent stages as a GitLab CI artifact.

The setup:static-content:deploy stage from the deploy sequence then relies on exactly that already-built styles.css instead of running any npm commands itself, keeping responsibilities cleanly separated: the build job owns correct CSS, the deploy job owns nothing but its correct delivery into Magento's static folders.

9. A complete example pipeline for the Tailwind build and deployment

The following shortened pipeline shows how all the pieces above fit together in the right order: first the cached build with error detection, then an optional CSS diff comment for reviewers, and only after that the deploy job, which uses nothing but the already validated artifact instead of touching the build logic a second time.

This structure doesn't just make the pipeline faster, it makes it far more traceable, because each job owns exactly one responsibility, and when something breaks it is immediately obvious which phase, build, check, or deployment, the problem actually happened in, instead of having to dig through one monolithic stage.


stages:
  - build
  - check
  - deploy

tailwind-build:
  stage: build
  image: node:20-bookworm-slim
  cache:
    key: { files: [src/app/design/frontend/Mironsoft/default/web/tailwind/package-lock.json] }
    paths: [src/app/design/frontend/Mironsoft/default/web/tailwind/node_modules/]
  script:
    - cd src/app/design/frontend/Mironsoft/default/web/tailwind && npm ci && npm run build
  artifacts:
    paths: [src/app/design/frontend/Mironsoft/default/web/css/styles.css]

tailwind-css-check:
  stage: check
  needs: [tailwind-build]
  script:
    - bash ci/check-dynamic-classes.sh
    - bash ci/check-css-size.sh

deploy-static-content:
  stage: deploy
  needs: [tailwind-css-check]
  script:
    - bin/magento setup:static-content:deploy de_DE en_US -t Mironsoft/default -f
  only:
    - main
Pipeline Component Job Without This Component Effect When Used Correctly
Dedicated build stage Build Tailwind reproducibly in Docker Result depends on the local machine Identical CSS for every commit
node_modules cache Reuse dependencies between runs Full npm install on every run Build time drops significantly
Grep check for dynamic classes Find string concatenations before the build Missing classes only visible in production Early, clear failure in the merge request
Treating warnings as errors Catch misspelled utility names A wrong class goes unnoticed The merge request is reliably blocked
Passing the artifact to deploy Build CSS exactly once Duplicate, potentially inconsistent build Clean separation of build and deployment

Mironsoft

Hyvä theme development and Luma migration

Still running Luma, or a Hyvä theme that just doesn't feel right?

We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.

Luma-to-Hyvä Migration

Move an existing shop to Hyvä in a structured way, without losing functionality.

Custom Theme Development

Build a custom Hyvä theme from scratch based on your design.

Performance Optimization

Improve Core Web Vitals and load times in the Hyvä frontend with purpose.

10. Summary

CI Pipeline for the Tailwind Build: Key Takeaways

Reproducibility

A pinned Docker image replaces the manual build that used to depend on the local machine.

Cache strategy

node_modules and Tailwind caches, tied to the package-lock.json hash, save minutes on every run.

Early error detection

Grep checks and CSS size diffs catch broken classes before they ever reach deployment.

Clean separation of duties

The build job and the deploy job are separate, so the CSS is produced in exactly one place.

11. FAQ: CI Pipeline for the Tailwind Build: Key Takeaways

1Why should the Tailwind build no longer run manually on a local machine?
Because the result then depends on locally installed Node and npm versions and is therefore not reproducible. A CI pipeline instead runs the build in a precisely defined environment that is identical for every commit.
2How does caching work for the Tailwind build in GitLab CI?
Through a cache key built from the hash of package-lock.json, so node_modules only gets reinstalled when a dependency has actually changed, not on every single commit.
3Why doesn't Tailwind detect dynamically composed class names?
Because Tailwind v4 statically scans source code for class names at build time and has no way of knowing a variable's actual value in something like class={'text-' + color + '-500'}, which means the matching utility class ends up missing from the generated CSS.
4How can missing dynamic classes be detected automatically?
A simple grep step in the CI pipeline scans the template code for suspicious string concatenations inside class attributes before the build runs, and fails the job with a clear error message on a match.
5What does a CSS size diff between branches actually give you?
A sudden jump often points to unpurged utility markup that slipped in, while a sharp drop can indicate a broken content configuration. Either way it's an early, automated signal, well before a reviewer even opens the code diff.
6Why should the build run inside an exactly pinned Docker image?
Because a differing, locally installed PostCSS plugin version can produce a different CSS result than the version pinned in package-lock.json. A pinned image guarantees every environment builds identically.
7How do Tailwind warnings get turned into real pipeline failures?
By writing the build output to a log and then scanning it specifically for known warning patterns. On a match, an explicit exit code fails the job instead of silently letting the warning pass.
8Why should the Tailwind build run only once per pipeline run?
Because re-running it in the deploy stage not only wastes time but could theoretically produce a slightly different CSS result. The artifact from the build job is instead passed straight to the deploy stage.
9How does a pipeline handle the dual-vendor structure of the Mironsoft and Abrams themes?
Through a matrix configuration that runs the same build job in parallel for both theme paths, so a CSS bug in one variant is visible right away instead of only surfacing during the separate deployment of the other.
10What role does setup:static-content:deploy play in this pipeline?
This stage relies solely on the already built and checked styles.css artifact and no longer runs any npm commands itself. That keeps build and deployment responsibilities cleanly separated.