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). |
pnpm build:action |
Rebuild the committed GitHub Action bundle action/dist/index.mjs (GitHub Action). |
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. -
The OTLP export test also sends its checks through a real OpenTelemetry Collector when
OTEL_COLLECTOR_URLis set (CI always does, and fails without it). Locally run the Collector withtests/fixtures/otel/collector.yaml(otelcol --config …, or theotel/opentelemetry-collectorimage with--network host) and setOTEL_COLLECTOR_URL=http://127.0.0.1:4318. -
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.
The web and worker processes send every email to a small SMTP sink (tests/e2e/mail-sink.mjs, SMTP on web
port + 3, E2E_SMTP_PORT); specs read what was sent from its HTTP API (GET <mailURL>/messages?to=…,
web port + 4, E2E_MAIL_PORT), for example the sign-in link in magic-link.e2e.spec.ts.
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 (every catalogue), appLocales (complete catalogues), defaultLocale, localeNames and the pure resolver. |
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). |
messages/scopes.ts |
PUBLIC_MESSAGE_SCOPES: the keys a status page visitor can see; the only keys fr, de and es translate (merged over English). |
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. Inside an organization (/[orgSlug]/…) the layout wraps the pages inOrgTimeZoneProvider, so client islands format in the organization's zone automatically; server components there take atimeZoneprop and pass it as an override (format.dateTime(date, 'precise', { timeZone })). Sorting by name uses the locale too (new Intl.Collator(useLocale()), seeselectMonitorList). - 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. - Schemas shared by a form and a route handler take their messages from a resolver: the form builds
them with its translator (
createMonitorFormSchema((key, values) => t(key, values))), the API keeps the English instance (monitorFormSchemainmonitor-schema.ts, a separate module so the catalogue is not bundled into the browser), so API responses stay stable. - The maintenance schema takes a message map instead of a resolver
(
createMaintenanceFormSchema(messages)): the handlers use the English defaults, the form passest(…)values built in auseMemo. Validators shared with the server take a message function the same way (validateOrganizationSlug(value, message)witherrors.slug*). - ICU syntax characters in a message are quoted with apostrophes:
'{{' monitor.name '}}'prints{{ monitor.name }},'<'token'>'prints<token>(a bare<…>is read as a rich-text tag).
pnpm lint runs marmot/no-literal-jsx-text (eslint-rules/no-literal-jsx-text.mjs) on
src/app/**/*.tsx and src/components/**/*.tsx (not components/ui, not the Payload admin). It reports
JSX text with letters, string literals rendered as children ({busy ? 'Saving…' : t('save')}) and
literal placeholder, title, alt, aria-label, label and description props. It lets through
text without letters, text inside <code>, <pre>, <kbd> and <samp>, technical values (URLs,
hostnames, paths, identifiers such as my-node, JSON and markup) and the names in its allow option
(Marmot, the Aa font specimen). An example value that still trips it gets a disable comment that says why:
// eslint-disable-next-line marmot/no-literal-jsx-text -- example value, not prose
placeholder = 'read write'It does not see strings built outside JSX (a const label = 'Save' passed down later); those are caught
in review.
Text the server sends on behalf of an organization (emails, notification bodies, expiry warnings) is
written in the organization's settings.language, falling back to en; the recipient is often not a
Marmot user. src/server/i18n.ts has the helpers:
-
getOrganizationI18n(payload, orgOrId)→{ locale, timeZone }(never throws; English/UTC when the organization is missing),serverTranslator(locale)(memoisedgetTranslator) andorganizationFormatter(i18n)for dates in the organization's time zone. - Notifications:
sendNotificationresolves the channel organization's language once and passes it to the provider asctx.locale. Providers hand it to the shared builders (statusLabel(status, locale),renderMessageTemplate(…, locale)). The[name] [status] msgshape andformatHeartbeatTimestay as they are (Kuma's wire format); only the words come fromnotifications.messages.*. - Emails:
email.*(invitation, SMTP test). HTML bodies uset.markupwith escaped values. - API errors:
throw apiError('memberNotFound', 404)(src/server/errors.ts) instead ofnew APIError('Member not found.', 404). It is still anAPIErrorwith the English message, so Payload REST, logs and tests see the same thing;withErrorsre-renders it in the request locale (userlanguage→ cookie →Accept-Language). Return-style helpers:unauthorized(request),forbidden(request),localizedError(request, key, status). The key is the stable identifier. - Route helpers with their own response shape (
jsonErrorinsrc/server/monitors/http.ts,notifications/api.ts,status-pages/http.ts,billing/http.ts) keep their shape and take the text fromerrorText(request, 'statusPageNotFound')(src/server/request-locale.ts). Theirauthenticatehelpers callrememberRequestUser, so the user'slanguagewins over the headers;payloadError(error, request),errorResponse(error, request)anderrorMessage(error, request)re-renderapiErrors thrown further down. - Collection hooks throw
ValidationErrors withuserErrorText(req, key): route handlers run the Local API as the request user, so the field message reaches the client in that user's language. Fieldvalidatefunctions do the same withuserLocale(req.user). - Notification payloads: words a provider adds (embed titles, field names, card headings) come from
providerText(locale)('serviceName')(notifications.messages.providers.*);timeLine(heartbeat, locale)rendersTime: …. The provider form (labels, descriptions, option labels) comes fromnotifications.providers.<name>;describeNotificationProviders(locale)applies it, anddescriptors-i18n.test.tschecks that the English catalogue matches every provider'sfieldMeta. - Import reports: the planners take an
ImportText(importText(locale),importExport.messages.*) andapplyImportPlanalocale; the default is English.
Stays English on purpose: zod messages of the API schemas (monitorFormSchema and friends; the forms
build translated schemas), the outbound guard's Blocked: … (MONITOR_…) diagnostics (matched by
findBlockedMessage), delivery errors and heartbeat messages (stored data), the push endpoint's Kuma
responses, badge SVG text, RSS dates (RFC 822) and machine endpoints with shared caches
(/api/status-pages/resolve-domain).
The English catalogue reproduces the previous hard-coded text byte for byte; keep it that way when
moving more strings (provider payload tests and tests/int/i18n-server.int.spec.ts check it).
Payload admin labels and descriptions use adminT('marmot:<collection>:<field>Description') with the
text in src/i18n/admin.ts (Payload's own {{var}} placeholders, not ICU). Function descriptions are
not emitted into src/payload-types.ts, so the generated types carry no field JSDoc. Select options
that are words use adminT('marmot:labels:…') (product names such as PostgreSQL stay literal), and
sidebar groups use admin.group: adminGroup('monitoring'), which builds Payload's per-language map from
the same catalogue.
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 prefix in the URL (/status/<slug>/fr), else their defaultLocale; plain URLs redirect first
visits to an offered language the visitor prefers (src/lib/status-page-locales.ts). <html lang>
always reflects the result. See docs/Status-Pages.md → Languages and time zones.
The signed-in app only offers appLocales, whose catalogues are complete. A language can first ship for
the public status pages only: add <locale>.json with exactly the keys in PUBLIC_MESSAGE_SCOPES,
register it in publicCatalogues (src/i18n/messages/index.ts), append it to locales and
localeNames, and add it to the language prefixes in the matcher of src/proxy.ts. A string added
to one of those scopes in en.json must be translated in every such catalogue (the catalogue test
checks keys and placeholders). To make a language complete:
- 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 tolocales,appLocalesandlocaleNamesinsrc/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(includinggroups,labelsand the maintenance option maps). - 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 creates
indexes on boot and only needs a migration (src/migrations/mongodb, chained the same way) to drop an index
that changed or to fix data, since Mongoose never drops an index by itself. 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.
The GitHub Action is bundled separately (pnpm build:action, scripts/build-action.mjs)
into action/dist/index.mjs, which is committed: run pnpm build:action and commit the result when you
change src/action/**, src/cli/**, the monitor validation (src/lib/validation/**) or the cli /
monitors.validation messages. The GitHub Action workflow fails when the committed bundle is stale.
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