Skip to content
BoringStack
Star

Monorepo layout

5 min read

BoringStack ships as a single monorepo. API, UI, docs site, and Compose infrastructure live in one tree with path-filtered CI and root-level bun run regen / bun run check for cross-app contracts. Click Use this template on the boringstack-xyz/boringstack GitHub repo to create your fork; all pieces come in one clone.

boringstack/
apps/api/ Bun + Elysia HTTP API; Drizzle schema + migrations
apps/ui/ Vite + React SPA; OpenAPI client generated from /swagger/json
apps/docs/ Astro Starlight site (boringstack.xyz)
apps/site/ Astro static marketing site on the apex domain (see Public site)
packages/ Shared code used by more than one app (optional; see below)
infra/compose/ Docker Compose runtime; services, env vars, profiles, overlays
infra/k3s/ Optional Kustomize + ArgoCD target for a Kubernetes cluster
infra/bootstrap/ Optional OpenTofu for VPS provisioning on Hetzner + Cloudflare

Each app keeps its own package.json, lockfile, and lint config. CI workflows (.github/workflows/) have path filters like apps/api/** and infra/compose/** so each app’s changes trigger only its own pipeline.

Smaller context: Open apps/ui for UI work without loading the entire API schema graph.

Independent releases: Compose publishes latest and sha-* images per app for WUD, and pins versions via env vars. The Kubernetes target ships the API, migrations, UI and site as one digest-pinned set. See Production releases.

Explicit join points: OpenAPI schema, ACL types, lint-meta RULES, and docs catalogs are regenerated from the repo root with bun run regen, so drift is caught by CI.

The infra/compose/compose/docker-compose.yml defines api-dev, api, ui-dev, and ui services with build contexts pointing back to ../../../apps/api and ../../../apps/ui. In prod, the default image: reference pulls pre-built images from GHCR published by release workflows; in dev, build: reads the local source and sets up a bind mount for hot reload.

Env files:

  • compose/.env.example: base stack and observability vars.
  • compose/api.dev.env.example: API-specific dev overrides.
  • compose/api.prod.env.example: API-specific prod secrets (JWT, MFA key, etc.).

Orchestration:

  • compose/dev.sh reads STACK= and WITH_*= to assemble the docker-compose invocation with the right overlays and profiles.

HTTP routes, auth, OAuth, password hashing, email send and provider abstraction, BullMQ jobs and queue workers, audit log writes, Drizzle schema and migrations.

Pages, components, queries, stores, routes. OpenAPI client generated from /swagger/json. i18n (en, de), shadcn/ui components, Storybook, Playwright e2e.

docker-compose YAMLs and profile overlays. Prometheus, Grafana, Loki, Promtail configs. Traefik labels, ACME, security middlewares. Backup scripts and runbooks.

boringstack.xyz Starlight site. Generated lint-meta and scripts tooling catalogs.

The public marketing site at the apex domain: static pages and a Markdown blog, built by Astro and served by nginx. It has no server code and makes no API calls. See Public site.

Hetzner provisioning, Cloudflare DNS, cloud-init.

Code used by two or more apps goes in packages/<name>/. Code used by one app stays in that app. A package holds no secrets and no app-specific routes or pages. The full convention is in apps/ui/docs/agents/shared-packages.md.

Each package has package.json with "private": true, src/index.ts as its public entry, a tsconfig.json that extends the UI config, and an eslint.config.mjs that imports the UI rule set. Its lint, format:check and typecheck scripts run the binaries in apps/ui/node_modules, so run bun install in apps/ui first. Its validate script runs all three, and lint uses --max-warnings=0.

Packages are discovered, not listed:

  • bun run check runs validate in every packages/*/ that has a package.json. A package without a validate script fails the check. A repo with no packages/ directory gets no package checks and no error.
  • scripts/ci/pre-push.sh runs the same gate when anything under packages/ changed, even if no app changed.
  • .github/workflows/apps-ui-validate.yml runs it on packages/** changes.
  • Dependabot has no entry for packages/, because it fails on a directory that does not exist. Add a bun entry for the package’s directory when you create it.

Wiring a package into apps/ui (tsconfig paths, server.fs.allow, resolve.dedupe) is a separate step, described in the same guide. Note that lint:meta does not scan packages/, so the package’s own ESLint config is its only architecture gate.

From the repo root:

Terminal window
bun run regen # ACL types → OpenAPI schema → RULES.md → docs JSON
bun run check # drift checks (api on :7330 required for OpenAPI check)
ContractProducerConsumerGenerated artifact
ACL typesapps/apiapps/uiapps/ui/src/lib/acl/acl.types.generated.ts
OpenAPIapps/api /swagger/jsonapps/uiapps/ui/src/lib/api/schema.d.ts
lint-metaeach app scripts/lint-meta/committed RULES.mdper app
Docs catalogsapi + ui scripts/READMEapps/docssrc/data/*.json

CI runs the same checks from one checkout on GitHub main.

The API exposes its OpenAPI document at /swagger/json. The UI ships bun run generate:api that reads that document and rewrites src/lib/api/schema.d.ts. When the API changes routes, the UI either fails typecheck or you run bun run regen at the repo root.

See OpenAPI client for the full flow.

infra/compose/compose/docker-compose.yml defines api-dev / api / ui-dev / ui services whose build context is apps/api and apps/ui. Env vars are documented in compose/api.dev.env.example and compose/api.prod.env.example.

In prod the default image: reference pulls a pre-built image from GHCR, published by the release workflows. See Deployment.