Storybook Builds and Visual Tests on GitHub Actions

Build a static Storybook on GitHub Actions, cache node_modules/.cache and the browser binaries, run image snapshots, and size warp- runners by memory.

Last verified:

To run Storybook on GitHub Actions, build the static Storybook once with storybook build, serve storybook-static inside the job, and run the story tests against that server so every story renders in a real browser. Cache node_modules/.cache and the browser binaries under ~/.cache/ms-playwright so repeat runs skip the builder work and the browser download, then pick the runner size for the snapshot job from memory rather than from vCPU count.

A Storybook pipeline has two phases with different resource shapes: a bundler build that spends most of its time on one thread, and a snapshot sweep that runs a browser per worker. This page covers the cache directories that make repeat runs cheap, a workflow that builds the static Storybook and compares images against committed references, per-phase sizing with rates applied, and the failures that make story snapshots flaky.

Overview

Every GitHub Actions job starts on a fresh virtual machine, so a Storybook repository starts with an empty node_modules, an empty builder cache, and no browsers installed. The build phase runs storybook build, which writes a static site to storybook-static. The snapshot phase serves that directory over HTTP and points the Storybook test runner at it, so each story is visited in a browser, its play function runs, and a screenshot is compared against a committed reference image.

Five paths decide whether a repeat run is cheap, and only three of them belong in a cache:

PathWhat it holdsKey onReused by
~/.npm or the pnpm storePackage tarballs from the registryLockfile hashEvery job
node_modules/.cacheStorybook builder state, including the webpack filesystem cache and the Vite dependency pre-bundleLockfile plus .storybook/main.tsBuild job, and any job that rebuilds
~/.cache/ms-playwrightBrowser builds the test runner drivesInstalled Playwright versionSnapshot job
storybook-staticThe built static StorybookNot cachedMoved to the snapshot job with actions/upload-artifact
__image_snapshots__Committed reference imagesNot cachedRead from the checkout, diffs uploaded on failure

The builder cache key is the row people get wrong. Hashing the lockfile alone keeps a stale entry after an addon is added, a framework is swapped, or a builder option changes in .storybook/main.ts, and the resulting build either rebuilds everything anyway or serves a module graph that no longer matches the config. Adding .storybook/main.ts to the hash rolls the entry exactly when the graph changes.

The built Storybook moves between jobs as an artifact rather than as a cache entry. A cache entry is keyed content that later runs restore; storybook-static is a per-commit result that one downstream job consumes once.

Storybook work belongs on Linux x64, and the caching documentation makes that concrete: WarpBuild Cache is not supported on Windows runners, which would put both cached rows back on a cold path.

Configuration

This workflow builds the static Storybook on an 8 vCPU runner, hands it to the snapshot job as an artifact, and runs the story tests against a local server on a 16 vCPU runner. On WarpBuild runners the only edits to a stock Storybook workflow are the runs-on labels and the cache action, where WarpBuilds/cache is a drop-in replacement for actions/cache@v4.

name: storybook
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4

      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      - name: Restore the Storybook builder cache
        uses: WarpBuilds/cache@v1
        with:
          path: node_modules/.cache
          key: ${{ runner.os }}-storybook-${{ hashFiles('package-lock.json', '.storybook/main.ts') }}
          restore-keys: |
            ${{ runner.os }}-storybook-

      - run: npx storybook build --quiet

      - uses: actions/upload-artifact@v4
        with:
          name: storybook-static
          path: storybook-static
          retention-days: 3

  snapshot:
    needs: build
    runs-on: warp-ubuntu-latest-x64-16x
    steps:
      - uses: actions/checkout@v4

      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      - name: Read the Playwright version
        id: pw
        run: |
          echo "version=$(node -p "require('playwright/package.json').version")" >> "$GITHUB_OUTPUT"

      - name: Restore browsers
        id: browsers
        uses: WarpBuilds/cache@v1
        with:
          path: ~/.cache/ms-playwright
          key: ${{ runner.os }}-playwright-${{ steps.pw.outputs.version }}

      - name: Install browsers and system libraries
        if: steps.browsers.outputs.cache-hit != 'true'
        run: npx playwright install --with-deps chromium

      - name: Install system libraries only
        if: steps.browsers.outputs.cache-hit == 'true'
        run: npx playwright install-deps chromium

      - uses: actions/download-artifact@v4
        with:
          name: storybook-static
          path: storybook-static

      - name: Serve the build and run the story tests
        run: |
          npx concurrently -k -s first -n SB,TEST \
            "npx http-server storybook-static --port 6006 --silent" \
            "npx wait-on tcp:127.0.0.1:6006 && npx test-storybook --maxWorkers=6"

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: image-diffs
          path: __image_snapshots__/__diff_output__
          retention-days: 7

The test runner visits stories and runs play functions on its own. Image comparison is a hook you add in .storybook/test-runner.ts:

import type { TestRunnerConfig } from "@storybook/test-runner";
import { toMatchImageSnapshot } from "jest-image-snapshot";

const config: TestRunnerConfig = {
  setup() {
    expect.extend({ toMatchImageSnapshot });
  },
  async postVisit(page, context) {
    const image = await page.screenshot({ fullPage: true });
    expect(image).toMatchImageSnapshot({
      customSnapshotIdentifier: context.id,
      customSnapshotsDir: `${process.cwd()}/__image_snapshots__`,
    });
  },
};

export default config;

Four details in those files carry the weight.

The build runs once. The snapshot job downloads storybook-static instead of rebuilding it, so the bundler work happens on the machine sized for bundling and the browsers get the machine sized for browsers. A single job that does both pays the larger rate for the whole duration.

A cache hit still needs install-deps. The cache covers browser binaries under ~/.cache/ms-playwright. It does not cover the apt packages those binaries link against, which live in system directories outside that path, so the hit branch runs playwright install-deps and skips the download only.

--maxWorkers is set explicitly. The default worker count comes from the core count, which is the wrong basis for browser-driven snapshots. Set it from the memory budget instead, as the sizing section below explains.

Diffs upload only on failure. jest-image-snapshot writes composite images to __diff_output__ when a comparison fails, and that is the artifact a reviewer needs. Uploading it unconditionally ships nothing on a green run and costs storage on every one.

Two cache behaviors from the caching documentation shape what the logs show. Entries are scoped to key, version, and branch, so seed the caches on main and let branch builds reach them through restore-keys. Entries expire 7 days after last use, so an active repository stays warm and an abandoned branch stops costing storage on its own. Cache storage is metered at $0.20 per GB-month with each write or restore operation at $0.0001.

Sizing

The Linux x64 rows from the cloud runner catalog that matter here:

Runner labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128GB150GB SSD$0.064

Build phase: 8x for most libraries. storybook build walks the module graph on one thread and then parallelizes transforms and minification across workers, so the curve flattens past 8 vCPU. Memory is what fails first on a large library: a few thousand stories with MDX docs pages can push Node past its default heap, and 32GB on 8x leaves room to raise NODE_OPTIONS=--max-old-space-size on a machine that can back it. A small component library builds fine on 4x at $0.008 per minute.

Snapshot phase: memory sets the worker count. Each test-runner worker holds a browser context, the rendered story, and every font and image that story loads, and full-page screenshots allocate a bitmap on top of that. Cores go idle waiting on rendering long before they saturate, so the useful question is how many browser contexts fit in RAM rather than how many fit on the vCPUs. Start at half the vCPU count, which is --maxWorkers=6 on a 16 vCPU runner with 64GB, and raise it while the job still passes. The failure mode when you overshoot is a worker killed mid-story, which surfaces as a page crash rather than as an out-of-memory message, so change one variable at a time.

When 32x earns its rate. A design system with several thousand stories running a full sweep on every pull request is the case where 128GB carries enough contexts to cut the wall clock. Below that, more workers on 16x is the cheaper move, and a story subset filtered by tag is cheaper still.

Worked cost model

GitHub publishes per-minute list prices on the GitHub Actions minute multipliers reference. Checked on 2026-08-13, the 16-core Linux larger runner is $0.042 per minute. Take a design system repository running 900 pull-request runs a month, with a 5 minute build and a 12 minute snapshot sweep:

Line itemRateMonthly volumeMonthly cost
Build on warp-ubuntu-latest-x64-8x$0.016/min4,500 min$72.00
Snapshot on warp-ubuntu-latest-x64-16x$0.032/min10,800 min$345.60
Same snapshot minutes, GitHub-hosted 16-core$0.042/min10,800 min$453.60
Cache storage and operations$0.20 per GB-month, $0.0001 per operation1.5GB, 5,400 ops$0.84

The snapshot job is the line that moves: $345.60 against $453.60 for the same minutes, a difference of $108.00 a month before any change to how long the sweep takes.

Stated as list-price arithmetic: warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13.

Rates for every size and platform are on the pricing page.

Bottlenecks

Rebuilding Storybook in the snapshot job. A workflow that runs storybook build inside the same job as the tests pays bundler time at the snapshot job's rate and blocks the browsers until the build finishes. Split the phases and pass the output as an artifact.

Cold browser downloads. Without a restored ~/.cache/ms-playwright, the snapshot job downloads a browser build before the first story renders. Confirm the restore from steps.browsers.outputs.cache-hit in the log rather than assuming it, since a mistyped path falls through to a full download in silence.

Too many workers. A --maxWorkers value copied from a unit test config puts one browser per core on a machine that cannot hold them. The job dies partway through the sweep and the log points at whichever story was unlucky.

Small /dev/shm in container jobs. A job that runs inside a container gets a 64MB /dev/shm by default, and Chromium crashes with a closed target once a page exceeds it. Either raise it with --shm-size on the container or pass --disable-dev-shm-usage to the browser launch options.

Fonts and animations producing diffs. A story that starts a CSS transition or renders before a web font loads produces a different bitmap on every run, and every one of those is a failed comparison a human has to look at. Wait for document.fonts.ready in the postVisit hook and disable animations globally in .storybook/preview.ts before adding retries.

Observability separates a memory-bound worker pool from a sweep waiting on the local server, and the Action Debugger pauses the workflow and opens an SSH session on the runner so you can serve storybook-static and open the failing story on the machine that failed.

Proof

Every number on this page is reproducible without taking our word for it. WarpBuild rates come from the pricing page and the cloud runner catalog, the GitHub list price links to GitHub's own billing reference with the date it was checked, and the cost model is arithmetic you can repeat after substituting your own run count and sweep duration. Cost and performance statements on WarpBuild pages carry a number, a source link, and a checked-on date, and the same number appears on every surface.

Moving an existing Storybook workflow over is a one-line change per job to runs-on.

Two adjacent pages carry the parts this one compresses. The Playwright on GitHub Actions page covers sharding, blob reports, and browser caching in depth, and the guide on sizing runners for Node test suites covers the worker-count-against-memory question for suites that are not browser driven. The Node.js on GitHub Actions page covers the install phase that runs before either.

FAQ

What should a Storybook cache key hash?

Hash the lockfile plus .storybook/main.ts for node_modules/.cache, since an addon, framework, or builder change invalidates the cached module graph while a routine dependency bump does not. Key the browser cache on the installed Playwright version instead, because browser builds are pinned to that release.

Should the snapshot job rebuild Storybook or download it?

Download it. Build once with storybook build, upload storybook-static with actions/upload-artifact, and have the snapshot job download it. Rebuilding repeats the bundler work in a job whose runner size was chosen for browsers rather than for bundling.

How many test-runner workers fit on a runner?

Fewer than the vCPU count on most component libraries. Each worker holds a browser context plus the rendered story and its assets, so memory fills before the cores do. Start at half the vCPU count with --maxWorkers, then raise it while the job still passes.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.