Repository navigation
Development
Marmot is one TypeScript codebase (Payload CMS 3 + Next.js, pnpm) that runs as three processes. This page
gets you from a clone to a running stack and through the test, migration and build workflows. Project
conventions live in CONTRIBUTING.md and .claude/skills/marmot-conventions/SKILL.md.
- Node.js 22 and pnpm 10 (
corepack enableinstalls the pinned version). - Postgres 16 and Redis 7, either through Docker or as local binaries. MongoDB 7 optionally.
- For Playwright: a Chromium (
pnpm exec playwright install chromium, or setPLAYWRIGHT_CHROMIUM_PATH).
pnpm install
cp .env.example .env # defaults point at local Postgres + Redis
pnpm services:up # Postgres + Redis (+ Mongo with Docker)
pnpm dev # web :3000 · worker · realtime :3001Set PAYLOAD_SECRET in .env to any string of 16+ characters. pnpm services:up runs
scripts/dev-services.sh: with a Docker daemon it starts docker/docker-compose.dev.yml (Postgres, Redis,
MongoDB on their default ports); without one it starts local redis-server and a Postgres 16 cluster via
pg_ctlcluster and creates the marmot role (password marmot) with the marmot and marmot_test
databases. pnpm services:down stops them.
Install Postgres and Redis from your package manager, then either let pnpm services:up manage them or run
them yourself and point DATABASE_URL / REDIS_URL at them. For a zero-dependency database use SQLite:
DATABASE_ADAPTER=sqlite DATABASE_URL=file:./data/marmot.db pnpm dev:webSQLite has no migrations (the schema is pushed in development) and is not supported in production; Redis is still required by the worker and realtime processes.
| Command | Process | Notes |
|---|---|---|
pnpm dev:web |
Next.js dev server with Payload (UI, /admin, REST, GraphQL) |
Pushes schema changes to the dev database on the fly (Postgres/SQLite in development). |
pnpm dev:worker |
src/worker.ts through scripts/run-ts.mjs
|
BullMQ job schedulers, checks, stats, notifications. Needs Redis. |
pnpm dev:realtime |
src/realtime.ts through scripts/run-ts.mjs
|
socket.io on REALTIME_PORT. The Next dev server does not proxy /socket.io, so set NEXT_PUBLIC_REALTIME_URL=http://localhost:3001 in .env for live updates in development (the realtime server allows NEXT_PUBLIC_SERVER_URL in CORS). |
pnpm dev |
all three with concurrently
|
Coloured, prefixed logs. |
scripts/run-ts.mjs is the TypeScript loader for the long-running processes. Do not run them with
payload run or bare tsx — both exit before the schedulers are up. In production the same entrypoints are
bundled by pnpm build:server (below).
Once web is up, open http://localhost:3000: the setup wizard creates your superadmin and first
organization. /admin is the Payload admin panel (superadmins only).
| Command | What it does |
|---|---|
pnpm check |
lint + typecheck + test:int. Run before every push. |
pnpm lint / pnpm format
|
ESLint / Prettier check (pnpm format:fix writes). |
pnpm typecheck |
tsc --noEmit over app, server and tests. |
pnpm generate:types |
Regenerate src/payload-types.ts after changing a collection or global. Never edit that file by hand. |
pnpm generate:importmap |
Regenerate the admin import map after adding admin components. |
pnpm build |
Production Next.js build (also typechecks test files against DATABASE_ADAPTER). |
pnpm build:server |
esbuild bundles of worker, realtime and migrate to dist/server/*.mjs (what the Docker image runs). |
tests/int/*.int.spec.ts and src/**/*.test.ts run with pnpm test:int against a real database
(vitest.config.mts, vitest.setup.ts). They boot Payload with the collection access rules on, so they
cover what can break silently: RBAC, the engine state machine, stats rollups, provider payloads, route
handlers. Files run serially (fileParallelism: false) because they share one database.
-
The database comes from
DATABASE_URLin your environment or.env. Use a dedicated database, not the one your dev server uses: in development Vitest pushes the schema into it. -
If a run fails on boot with a schema-push error (e.g. "column contains null values") or hangs, the database has drifted. Point
DATABASE_URLat a fresh one and rerun:createdb -h localhost -U marmot marmot_test2 DATABASE_URL=postgres://marmot:marmot@localhost:5432/marmot_test2 pnpm test:int
Never answer Payload's data-loss prompt interactively in a test run.
-
Redis is not required:
vitest.setup.tssetsMARMOT_DISABLE_ENGINE_HOOKS=1so collection hooks skip the scheduler sync; engine behaviour is tested directly. -
Run one file with
pnpm test:int tests/int/members.int.spec.ts. -
On MongoDB ids are strings, so fixtures must not assume numbers: cast through
unknown(as unknown as Monitor) or create real documents.
tests/e2e/*.e2e.spec.ts cover the critical paths (setup wizard, login, monitors, members, status pages,
admin). CI runs them on Postgres and MongoDB against a production build (pnpm build, pnpm migrate,
E2E_WEB_COMMAND="pnpm start"). Locally:
pnpm exec playwright install chromium # once
pnpm test:e2e # starts `pnpm dev:web` for you00-setup.e2e.spec.ts needs a database without users and is skipped outside CI. Set
PLAYWRIGHT_CHROMIUM_PATH to use an existing Chromium binary.
Integration tests for behaviour that matters (access control, state machines, rollups, provider payloads, route handlers). E2E only for critical user paths. No snapshot tests, no tests of trivial getters.
User-facing text goes through next-intl in "without i18n routing" mode: URLs
carry no locale prefix, the language comes from the signed-in user. Everything lives in src/i18n/:
| File | Role |
|---|---|
locales.ts |
locales, defaultLocale, localeNames (each language in its own language) and the pure resolver. The only list of languages. |
messages/<locale>.json |
One catalogue per language, namespaced by area (common, auth, settings, statusPages, …). English is the reference. |
messages/index.ts |
Static map of catalogues (no computed import(), so the worker bundle can include it). |
formats.ts |
Named date and number formats (short, zoned, percent, …) and the default time zone. |
request.ts, server.ts
|
next-intl request config; locale of the current request (getRequestLocale, getStatusPageLocale). |
resolve.ts |
Locale and time zone of a request or a status page from plain Headers; usable in route handlers and tests. |
translator.ts |
getTranslator(locale) / getStaticFormatter(locale, timeZone) for code outside React (RSS, emails, notification bodies, the worker). |
admin.ts |
Payload admin languages and the marmot: translations used for collection labels (adminT('marmot:language')). |
Keys are type-checked: src/types/next-intl.d.ts derives them from en.json, so a missing or misspelt
key fails pnpm typecheck, and tests/int/i18n.int.spec.ts checks that every catalogue has the same keys.
- Add the key to
src/i18n/messages/en.jsonunder the namespace of the area (statusPages.footer.rss). Use camelCase leaves, name the key after its meaning rather than its English text, and keep whole sentences in one message (translators need the full sentence to reorder it). - Use it:
- server components and
generateMetadata:const t = await getTranslations('statusPages.footer'), thent('rss'); - client islands and shared components:
const t = useTranslations('statusPages.footer'); - outside React (route handlers, worker):
getTranslator(locale)('statusPages.footer.rss').
- server components and
- Values are ICU messages:
{name}placeholders, plurals"{count, plural, one {# check} other {# checks}}", selects"{status, select, up {…} other {…}}", and rich text"No account yet? <link>Create one</link>"rendered witht.rich('noAccount', { link: (chunks) => <Link …>{chunks}</Link> }). - Dates and numbers never go through
toLocaleString(): useuseFormatter()/getFormatter()with a named format (format.dateTime(date, 'short'),format.number(fraction, 'percent')). The request config renders in UTC, public status pages in the organization'ssettings.timezone; passing the zone explicitly is what keeps the server HTML and the client hydration identical. - Stable identifiers stay untranslated: monitor type slugs, status enum values, webhook payload keys, log
lines and the
codeof API errors. Only their display labels are messages.
User preference (users.language) → marmot-locale cookie (signed-out pages; the language picker sets
it) → Accept-Language matched by base language → defaultLocale. Public status pages use their own
language setting, or follow the visitor when it is auto. <html lang> always reflects the result.
- Copy
src/i18n/messages/en.jsonto<locale>.jsonand translate it (same keys; the catalogue format is plain nested JSON with ICU messages, which Crowdin, Weblate and Lingo.dev all handle). - Register it in
src/i18n/messages/index.tsand append the locale tolocalesandlocaleNamesinsrc/i18n/locales.ts. The language picker in Settings → Account and the status page builder appear automatically once more than one locale exists. - For the Payload admin, add the matching pack from
@payloadcms/translations/languages/<locale>and translate themarmot:keys insrc/i18n/admin.ts. - Run
pnpm check: the typecheck and the catalogue test fail on missing keys.
Postgres uses Payload migrations in src/migrations/postgres (chained in index.ts); MongoDB needs none
(indexes are created on boot). After changing a collection, global or field:
pnpm generate:types
DATABASE_URL=postgres://marmot:marmot@localhost:5432/marmot_fresh pnpm migrate:create <name> --force-accept-warningCreate the migration against a fresh database that has only the existing migrations applied, so the
diff contains exactly your change: createdb marmot_fresh, then
NODE_ENV=production DATABASE_URL=… pnpm migrate --force-accept-warning to bring it to main, then
migrate:create. Never edit generated migration files by hand. If main gains a migration while your branch
is open, delete yours, restore src/migrations/postgres/index.ts from main, and regenerate on a fresh
database.
Migrations run automatically when the web role starts in the Docker image (dist/server/migrate.mjs,
SKIP_MIGRATIONS=true opts out) and can be run by hand with pnpm migrate (development) or
pnpm migrate:prod (from the bundle). DATABASE_ADAPTER=mongodb pnpm migrate:create creates a MongoDB
migration for the rare data or index migration.
The Docker image does not ship TypeScript tooling. pnpm build:server (scripts/build-server.mjs) bundles
src/worker.ts, src/realtime.ts and src/cli/migrate.ts into dist/server/{worker,realtime,migrate}.mjs
with esbuild, keeping every package external. Consequences for server code:
- no
import.meta.url-relative paths to repository files (useprocess.cwd()); - no dynamic imports with computed specifiers; optional drivers are imported with a literal specifier;
- run
pnpm build:serveronce when you touchsrc/worker.ts,src/realtime.ts,src/server/**,src/collections/**orsrc/db/**.
pnpm start:worker, pnpm start:realtime and pnpm migrate:prod run the bundles locally.
src/payload.config.ts buildConfig; wires collections, plugins, db factory, email
src/env.ts zod-validated env (the only place process.env is read)
src/collections/ one file per collection; register in index.ts
src/globals/ instance settings
src/access/ org RBAC helpers (orgScoped, permissions map)
src/auth/sso/ single sign-on (payload-auth plugin wiring, providers, user matching)
src/server/engine/ BullMQ queues, schedulers, check worker, heartbeat state machine
src/server/monitor-types/ one file per type, self-registering
src/server/notification-providers/ one file per provider, self-registering
src/server/notifications/ dispatch, templates, notification worker
src/server/stats/ uptime calculator and rollups
src/server/realtime/ socket.io event names, emitter helpers, server auth
src/server/status-pages/ public payload, RSS, route helpers
src/app/(frontend)/ Marmot UI (App Router)
src/i18n/ locales, message catalogues, formats, request locale (next-intl)
src/app/api/ route handlers (org-scoped and public)
src/components/ UI; shadcn primitives under components/ui
src/stores/ Zustand stores fed by the socket client
tests/int, tests/e2e Vitest integration tests, Playwright E2E
docker/ Dockerfile, compose files, Caddyfile, entrypoint
Extension points (adding a monitor type or a notification provider) are described in
CONTRIBUTING.md; the data flow is in Architecture.
This wiki is published from docs/ on every push to main; edits made in the wiki are overwritten.
Found a mistake? Open a pull request with the docs(docs): … prefix.
Run it
- Getting started
- Configuration
- Deployment
- Kubernetes
- Single sign-on
- Security
- Telemetry
- Billing
- Demo mode
Use it
- Monitors
- Monitor types
- Probe locations
- Notifications
- Status pages
- Maintenance
- Organizations and members
- Integrations
- MCP server for AI agents
- Import and export
- CLI and monitors as code
- GitHub Action
Change it