Skip to content

Deployment

View as Markdown
services:
storyshelf:
image: storyshelf:latest
ports:
- "3000:3000"
volumes:
- storyshelf-data:/app/data
environment:
- SECRET=$(openssl rand -hex 32) # ≥ 32 chars
- CAPTURE_CONCURRENCY=2
- PURGE_TTL_DAYS=30
- BRANCH_TTL_DAYS=30
- BRANCH_GC_INTERVAL_MS=86400000
# Auth (see /guides/auth/): local admin or SSO example
# - AUTH_PASSWORD=a-long-admin-password # ≥ 12 chars (local admin, via invite flow)
# - OIDC_ISSUER=https://keycloak.example.com/realms/myteam
# - OIDC_CLIENT_ID=storyshelf
# - OIDC_CLIENT_SECRET=secret
volumes:
storyshelf-data:

Opt in to per-project subdomains by setting PUBLISHED_BASE_DOMAIN and adding a wildcard DNS record + TLS cert:

*.stories.example.com → your.server

Then https://<slug>.stories.example.com serves the latest published Storybook, and https://<buildId>.<slug>.stories.example.com serves a specific build.

  • Local accounts — invite-only email/password via the auth engine (AUTH_PASSWORD bootstraps the admin).
  • Social / enterprise SSO — GitHub, Google, Entra, Keycloak, Okta, Auth0, SAML (see Auth).
  • None — for VPN-protected deployments.

Deployment targets — bring your own assembly

Section titled “Deployment targets — bring your own assembly”

StoryShelf is cross-runtime: the core router (createShelfApp) uses only Web-standard APIs (Request, Response, fetch, ReadableStream, crypto, URL). There is no Node coupling in the core. This means you can assemble and deploy on any platform that runs JavaScript.

Section titled “Recommended assembly (self-hosted default)”

Use storyshelf server init to scaffold a server with your chosen adapters:

Terminal window
storyshelf server init
# ? Which database? SQLite
# ? Which storage? Local filesystem
# ? Which auth? None
# ? Which git provider? None

This generates src/index.ts, package.json and tsconfig.json with the correct imports and dependencies.

LayerPackageNotes
Database@storyshelf/db-sqlitenode:sqlite + Drizzle, WAL mode, single file
Storage@storyshelf/storage-localLocal filesystem, --data-dir
Capture queueDatabase queue (built-in)capture_jobs in your database, leased
Auth@storyshelf/authLocal accounts, social, SSO/SAML, passkeys

Swap the database layer for @storyshelf/db-postgres (Postgres via postgres.js + Drizzle, see Postgres provider recipes) or @storyshelf/db-sqlite/turso (Turso/libSQL) without changing the rest of the stack.

One npm start (or fly deploy, railway up, render.com, etc.) and you’re running.

Rendering scales on the browser side, not by adding processes: every instance runs the same testing configuration, so every instance offers the same targets. To take browsers off the app host, list remote services in the runner’s browsers (a self-hosted Playwright server, Azure Playwright Workspaces, a CDP service) and the host needs no installed browsers. sessions per service is the page cap per instance; divide a provider’s parallel-session limit among instances. See Remote browsers.

Capture drives Chromium, which dominates memory use. Each open page costs roughly 100–300 MB, so StoryShelf renders a bounded number of pages at once rather than every story together.

SettingDefaultWhat it controls
testing.concurrencyauto: 2 builds on hosts with ≥ 2 CPUs and ≥ 3 GB, otherwise 1Builds captured at the same time (each launches its own browser)
createPlaywrightCaptureRunner({ concurrency }) / STORYSHELF_CAPTURE_CONCURRENCYauto (see below)Pages rendering at once within one build
createPlaywrightCaptureRunner({ navigationTimeoutMs }) / STORYSHELF_NAVIGATION_TIMEOUT_MSauto: 30 s, 60 s under 2 CPUs, 90 s under 1 CPUPer-page navigation and action timeout

| createPlaywrightCaptureRunner({ reusePages }) / STORYSHELF_REUSE_PAGES | off | Reuse a pool of reset pages instead of opening one per capture | | createPlaywrightCaptureRunner({ recycleBrowserEvery }) / STORYSHELF_BROWSER_RECYCLE_EVERY | 150 (0 disables) | Captures between browser restarts |

The defaults are sized from the CPU and memory the process can actually use, including container limits (cgroup CPU quota and memory limit), so a 0.5 vCPU / 1 GB container on a big host is treated as 0.5 / 1 GB. Pages at once is the smaller of the CPU count (rounded up), (memory − 512 MB) / 300 MB, and 4, with a minimum of 1. In practice:

HostBuilds at oncePages at onceNavigation timeout
0.5 vCPU, 1 GB1190 s
1 vCPU, 2 GB1160 s
2 vCPU, 4 GB1230 s
4 vCPU, 8 GB2430 s

More pages than CPUs does not make capture faster: the pages compete for the same cores, each loads more slowly, and slow loads time out. The orchestrator also gives each render a time budget of 15 minutes, or 15 seconds per capture for large builds, whichever is longer (an explicit renderTimeoutMs always wins). One page at a time on a small host is slower, so a 1,000-capture build gets about 4 hours before it is cancelled as hung; without this, a build that is merely slow would be reported as timed out.

Screenshots are written to storage as each one is captured instead of being held until the whole build has rendered, so memory use no longer grows with the number of stories.

By default every capture opens a fresh page. Set reusePages: true (or STORYSHELF_REUSE_PAGES=1) to reuse a small pool of pages instead. Each page runs in its own browser context and is reset between captures (cookies, local and session storage and IndexedDB cleared, blank document), and a page that failed a capture is thrown away rather than reused. It is off by default because a story that leaks state outside those resets could affect the next one; on the bundled fixtures, screenshots were byte-identical with reuse on and off. Reuse mostly saves page startup time, which matters most on slower hosts.

Chromium grows slowly over a long run, so the browser is closed and relaunched every 150 captures. The restart happens between batches, after in-flight pages finish, so no capture is interrupted. At the start of every render the runner logs a capture sizing line with the detected cpus and memoryMb and the resulting pages, navigationTimeoutMs and recycleBrowserEvery; check it first if a host behaves differently from the table.

If your platform reserves CPU in a way the container cannot see (for example a VM with a fractional share), the detection cannot know; set the values explicitly.

Node itself is cheap by comparison, but its default heap limit can be larger than what is left once Chromium has its share. On small hosts cap it, for example NODE_OPTIONS=--max-old-space-size=384 on a 1 GB machine (the Fly demo sets this in fly.toml), so Node collects garbage earlier instead of crowding out the browser.

Storing the uploaded Storybook for the published preview handles at most 8 files at a time, so large builds (thousands of files) no longer have to fit in memory. Chromium is launched with low-memory flags (--disable-dev-shm-usage, --disable-extensions, --disable-background-networking, --mute-audio); rendering-affecting flags such as --disable-gpu are intentionally not used, because they can change pixels and cause false visual diffs.

If builds still run out of memory, lower the runner concurrency first, then add RAM.

Enterprise Terraform stacks are available per cloud — each scaffolds one app (it also runs the captures from the database queue), database, storage, auth, and optional DNS/TLS. See the dedicated pages for commands, env wiring, and stable output contracts:

For single-host Docker Compose or a bare npm start on Fly/Railway/Render/VPS, the Docker Compose page covers the compose file, subdomains, and auth. The cloud assembly page also includes minimal Turso + S3 and Postgres + S3 recipes that run on any fetch+crypto platform.