@environmentvariable/create-keel
v0.1.2
Published
Scaffold production-ready Keel full-stack monorepos
Readme
create-keel
Scaffolds the Keel three-plane webapp architecture — a production-ready monorepo with a React UI, Fastify API, and background Worker, wired together with Docker Compose, Postgres, MinIO, and Caddy.
┌─────────┐ ┌───────────┐ ┌──────────┐
│ UI │─────▶│ Caddy │─────▶│ API │
│ React │ │ reverse │ │ Fastify │
│ Vite │ │ proxy │ │ Drizzle │
└─────────┘ └───────────┘ └────┬─────┘
│
┌──────────┼──────────┐
│ │ │
┌────▼───┐ ┌────▼───┐ ┌───▼────┐
│Postgres│ │ MinIO │ │ Worker │
│ 16 │ │ S3 │ │ jobs │
└────────┘ └────────┘ └────────┘Quick start
npx @environmentvariable/create-keel my-app
cd my-app
./scripts/dev.shThis will prompt you for configuration choices, generate the project, install dependencies, and initialise a git repo. The dev script starts all services via Docker Compose.
To skip prompts and use sensible defaults:
npx @environmentvariable/create-keel my-app --defaultsPublish + run with npx
npx by package name resolves from the npm registry, so you should publish this CLI package first.
This repo is configured to publish as a public scoped package by default (publishConfig.access=public).
If you need npm auth locally, configure it with:
npm config set //registry.npmjs.org/:_authToken=<NPM_TOKEN>Manual publish checklist (fallback):
# 1) Verify build and tests, and inspect publish payload
pnpm publish:check
# 2) Publish
pnpm publish --access publicAfter publish:
npx @environmentvariable/create-keel@latest my-app --defaultsAutomated release workflow
GitHub Actions workflows:
.github/workflows/changesets.ymlruns on every push tomasterand creates/updates a release PR from pending changesets..github/workflows/publish.ymlruns on every push tomasterand publishes automatically when the package version changes.- If the version in
package.jsonis unchanged, publish exits early with a skip message. .github/workflows/publish.ymlalso supports manual dispatch with:access:restrictedorpublicdry_run:true/false
Required repository secret:
NPM_TOKEN(publish-capable npm token)
Optional repository variable:
NPM_PUBLISH_ACCESS(restrictedorpublic) to set default publish access.
Changesets-based release flow
For managed versioning/changelogs, add a changeset in each release-worthy PR:
# add a release note entry
pnpm changeset
# merge your feature PR into master
# changesets workflow opens/updates release PR automatically
# after release PR is merged, publish workflow publishes automaticallyManual commands are still available if needed:
pnpm version-packages
pnpm releaseIf you fork this project, replace @environmentvariable with your own npm scope in package.json and docs.
CLI options
| Flag | Values | Default | Description |
|---|---|---|---|
| --defaults | — | — | Skip interactive prompts, use all defaults |
| --auth <provider> | clerk supabase oidc none | clerk | Authentication provider |
| --storage <provider> | minio r2 s3 | minio | Object storage backend |
| --redis | — | false | Include a Redis service |
| --jobs <list> | comma-separated | none | Example job handlers to include (send-email, process-image, generate-pdf) |
| --styling <styling> | tailwind plain none | tailwind | UI styling approach |
Options can be combined with --defaults to override individual choices:
npx @environmentvariable/create-keel my-app --defaults --auth supabase --redisAdditional command:
npx @environmentvariable/create-keel update-plan [projectDir]— analyze drift between a generated project and the latest scaffold.npx @environmentvariable/create-keel update-bundle [projectDir]— generate an apply-ready patch bundle (files overlay + remove list + script).
Updating existing generated projects
Generated projects now include a metadata file at .keel/scaffold-state.json with the options and scaffold reference used at generation time.
Use the new update planner to compare an existing project against the latest scaffold:
# Human-readable report
npx @environmentvariable/create-keel update-plan ./my-app
# JSON report for automation
npx @environmentvariable/create-keel update-plan ./my-app --jsonGenerate an apply-ready bundle (replacement files + remove list + apply script):
# Human-readable summary
npx @environmentvariable/create-keel update-bundle ./my-app
# JSON output for automation
npx @environmentvariable/create-keel update-bundle ./my-app --jsonBundle output (default):
./my-app/.keel/update-bundles/<timestamp>-<scaffold-ref>/
├── files/ # Add/changed files to copy into project
├── remove-files.txt # Files to delete from project
├── plan.txt # Same summary as update-plan
├── manifest.json # Machine-readable metadata
├── apply.sh # Applies files/ + removals
└── APPLY.md # Step-by-step instructionsSafety behavior:
update-planreports project-only files (files present in your repo but not in scaffold baseline).update-bundlekeeps those project-only files untouched by default.- Only files tracked as scaffold-managed and removed upstream are added to
remove-files.txt.
For older projects that do not have .keel/scaffold-state.json, provide the baseline options explicitly:
npx @environmentvariable/create-keel update-plan ./legacy-app \
--project-name legacy-app \
--auth clerk \
--storage minio \
--styling tailwind \
--redis \
--jobs send-email,process-imageThe same legacy flags work for update-bundle.
Batch planning across many repos
If you manage many generated projects, you can produce update reports in a loop:
for project in /path/to/projects/*; do
[ -d "$project" ] || continue
npx @environmentvariable/create-keel update-plan "$project" --json > "$project/.keel-update-plan.json"
doneThen triage by priority (for example: projects with large changed counts first), update each project on a branch, and run that project's typecheck/tests before merging.
Batch bundle generation across many repos
for project in /path/to/projects/*; do
[ -d "$project" ] || continue
npx @environmentvariable/create-keel update-bundle "$project" --json > "$project/.keel-update-bundle.json"
doneThen apply each bundle inside that project repo:
bash "$project/.keel/update-bundles/<bundle-id>/apply.sh" "$project"What you get
The generated project is a pnpm monorepo with this structure:
my-app/
├── apps/
│ ├── api/ # Fastify REST API
│ ├── worker/ # Background job processor
│ └── ui/ # React + Vite + TanStack Router
├── packages/
│ └── shared/ # Shared types, schemas, constants
├── docker-compose.yml
├── docker-compose.dev.yml
├── Caddyfile
├── .env
└── scripts/
├── dev.sh # Start dev environment
└── migrate.sh # Run database migrationsUI plane — apps/ui
React SPA with shadcn/ui components, ready to extend:
- Tailwind CSS v4 via
@tailwindcss/vite— no PostCSS config needed - shadcn/ui — Button, Card included; add more with
pnpm dlx shadcn@latest add <component> - TanStack Router — type-safe file-based routing
- TanStack Query — server state management
- Clerk auth (configurable) —
<SignedIn>/<SignedOut>gates @/path alias configured in tsconfig + Vite
API plane — apps/api
Fastify server with batteries included:
| Feature | Details |
|---|---|
| Database | Drizzle ORM + Postgres, schema + migrations included |
| Auth | JWT verification via JWKS, dev mode bypass |
| File uploads | Presigned S3 URLs (MinIO / R2 / AWS S3) |
| Job queue | Enqueue jobs for the Worker via POST /jobs |
| Webhooks | Ingestion with deduplication via POST /webhooks/:source |
| Observability | Pino structured logging, request IDs, health check |
| Validation | Zod schemas on all inputs |
| Rate limiting | 100 req/min per user/IP |
| CORS | Enabled with credentials |
API routes:
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health | Public | DB connectivity check + uptime |
| POST | /jobs | Protected | Enqueue a background job |
| GET | /jobs/:id | Protected | Get job status |
| POST | /files/upload-url | Protected | Generate presigned upload URL |
| POST | /files/:fileId/confirm | Protected | Confirm file upload completion |
| POST | /webhooks/:source | Public | Ingest webhook events (deduped) |
Worker plane — apps/worker
Polling-based background job processor:
- Claims jobs atomically with
FOR UPDATE SKIP LOCKED— no Redis required - Configurable concurrency, poll interval, and stale job reclamation
- Retry with dead-letter after max attempts
- Graceful shutdown on SIGTERM/SIGINT
- Handler registry pattern — add new job types in
src/handlers/
Shared — packages/shared
Zod schemas, TypeScript types, error codes, and job type constants shared between API and Worker.
Infrastructure
| Service | Image | Dev ports | Purpose |
|---|---|---|---|
| Postgres 16 | postgres:16-alpine | 5432 | Primary database |
| MinIO | minio/minio | 9000 9001 | S3-compatible object storage |
| Caddy 2 | caddy:2-alpine | 80 443 | Reverse proxy (/api/* → API, /* → UI) |
Generated project commands
# Start all services in dev mode (with hot reload)
./scripts/dev.sh
# Run database migrations
./scripts/migrate.sh
# Run all tests
pnpm -r --parallel test
# Typecheck all packages
pnpm -r typecheck
# Build everything
pnpm -r build
# Run just the UI dev server
pnpm --filter ui dev
# Add a shadcn component
cd apps/ui && pnpm dlx shadcn@latest add dialogEnvironment variables
Documented in the generated .env.example:
| Variable | Default | Description |
|---|---|---|
| NODE_ENV | development | Runtime environment |
| PORT | 3001 | API server port |
| LOG_LEVEL | info | Pino log level (debug, info, warn, error) |
| DATABASE_URL | postgresql://keel:keel@localhost:5432/keel | Postgres connection string |
| DB_POOL_MIN | 2 | Minimum database pool connections |
| DB_POOL_MAX | 10 | Maximum database pool connections |
| REDIS_URL | — | Redis connection (only when --redis flag is used) |
| S3_ENDPOINT | http://localhost:9000 | S3/MinIO endpoint |
| S3_REGION | auto | S3 region |
| S3_BUCKET | keel-uploads | Upload bucket name |
| S3_ACCESS_KEY_ID | minioadmin | S3 access key |
| S3_SECRET_ACCESS_KEY | minioadmin | S3 secret key |
| S3_FORCE_PATH_STYLE | true | Required for MinIO; disable for AWS S3 |
| AUTH_PROVIDER | clerk | Auth provider (clerk, supabase, oidc, none) |
| AUTH_JWKS_URL | — | JWKS endpoint for JWT verification |
| AUTH_ISSUER | — | Expected JWT issuer claim |
| AUTH_AUDIENCE | — | Expected JWT audience claim |
| WORKER_POLL_INTERVAL_MS | 1000 | Worker polling delay when idle (ms) |
| WORKER_CONCURRENCY | 3 | Max concurrent job processing |
| WORKER_STALE_AFTER_MS | 300000 | Reclaim jobs older than this (5 min) |
| WEBHOOK_SIGNING_SECRET | — | Secret for webhook signature verification |
| ENCRYPTION_KEY | — | Application-level encryption key |
Development (contributing to create-keel itself)
pnpm install
pnpm dev my-app --defaults # scaffold a project into ./tmp/my-app
pnpm test # run all tests (unit + e2e)
pnpm typecheck # type-check the CLI source
pnpm build # build CLI with tsupCursor Cloud environment setup
If you use Cursor Cloud agents often on this repo, use the env setup prompt in:
How it works
- The
scaffold/directory is the golden copy of a generated project — a real, working monorepo. templates/root/mirrorsscaffold/and may contain.hbs(Handlebars) files for conditional rendering.- On generation,
syncAndGenerateProject()(a lock-protected wrapper) syncsscaffold/→templates/root/, then renders templates into the target project.
To add features to the generated project, edit files in scaffold/ first, then add .hbs variants in templates/root/ if the feature needs conditional logic.
Project structure
create-keel/
├── bin/create-keel.ts # CLI entry point
├── src/
│ ├── index.ts # Commander setup, orchestration
│ ├── cli.ts # Interactive prompts (@clack/prompts)
│ ├── generator.ts # Template sync + rendering
│ ├── update.ts # Drift analysis against latest scaffold
│ ├── meta.ts # Generator/scaffold metadata resolution
│ ├── actions.ts # Post-gen: install deps, init git, create .env
│ ├── types.ts # GeneratorOptions type
│ └── utils/template.ts # Handlebars rendering
├── scaffold/ # Golden copy of generated project
├── templates/root/ # Templated copy (auto-synced from scaffold)
└── test/
├── unit/ # Unit tests
└── e2e/ # End-to-end generation tests