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.
Table of Contents
- 1. The manual Tailwind build as an underestimated deployment risk
- 2. Defining the Tailwind build as its own reproducible CI stage
- 3. Setting up an effective build cache between pipeline runs
- 4. Detecting broken Tailwind classes automatically before deployment
- 5. CSS size diffing between branches as an early quality signal
- 6. Running the build in an isolated, deterministic environment
- 7. Consistently treating Tailwind warnings as pipeline failures
- 8. Passing the built CSS to the deploy job as an artifact
- 9. A complete example pipeline for the Tailwind build and deployment
- 10. Summary
- 11. FAQ
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.