Polysite — Technical Architecture
Written 2026-09-20 and kept current; last revised 2026-10-11 (Known Issues now run to #68)
Overview
Polysite is a centralized, static-first multisite CMS platform. This document describes the system as actually built and verified, not just as planned — every component below has been built, run, and tested end to end.
System at a Glance
New to this codebase? These two diagrams cover how requests flow through the system and how it's physically laid out; the rest of this document fills in the "why" behind each piece.
Connectivity
Internet
|
v
Cloudflare (DNS; TLS proxy optional)
|
v
+------------------------------------------------------+
| Single GCE VM |
| |
| caddy :80 / :443 (reverse proxy, TLS) |
| +- {$SITE_DOMAIN} -> static files, else |
| | web (/api/* proxied |
| | on to cms) |
| +- {$CMS_DOMAIN} -> cms |
| |
| web :3000 (Nuxt, server-rendered) |
| +- SSR fetches call cms directly |
| |
| cms :3001 (Payload admin + API) |
| +- reads/writes db |
| |
| db :5432 (Postgres -- dev only) |
| redis :6379 (AOF -- not yet wired) |
| backup (nightly database + media dump) |
+------------------------------------------------------+
Production: db is replaced by cloudsql-proxy,
tunneling to a managed Cloud SQL instance off the VM.
Only caddy's ports 80 and 443 are published in production.
(Local docker-compose.yml also publishes Postgres, on 127.0.0.1 only.)Structure
polysite (core repo, on GitHub)
|-- apps/
| |-- web/ Nuxt app (generic core)
| | |-- nuxt.config.ts extends: [every theme, every plugin web layer] -- ALL at once (#30)
| | |-- composables/ usePayloadPage, useSite, useSiteBranding (shared)
| | |-- pages/ index.vue + [...slug].vue -- both theme-agnostic
| | |-- layouts/default.vue the core's own plain fallback layout
| | |-- error.vue the core's own 404 / error page
| | `-- server/api/ /api/revalidate webhook handler
| `-- cms/ Payload CMS
| |-- src/collections/ Users, Roles, Sites, Pages, Media, Redirects, Privacy requests
| `-- src/plugins/ plugin discovery and content-type extension points
|-- themes/ OPTIONAL -- the core runs without any
| |-- polysite-theme-starter/ <-- the reference theme (polysite-themes repo)
| | |-- nuxt.config.ts layer config
| | `-- layouts/starter.vue THIS theme's header/footer chrome
| `-- polysite-theme-<client>/ <-- a client theme: separate git repo, own deploy key
| |-- nuxt.config.ts layer config
| `-- layouts/<client>.vue THIS theme's header/footer chrome
|-- plugins/ OPTIONAL -- separate git repo; each plugin may carry a web/ Nuxt layer
|-- backup/ deploy/ tests/ the backup tool and policy, deploy.sh, and the smoke / end-to-end / backup / deploy tests
|-- docker-compose.yml
|-- Caddyfile
`-- docs/ Guides, Architecture, Brand guide
At build time, Nuxt's `extends` merges apps/web + every themes/polysite-theme-*
into ONE app; app.vue then picks which theme's layout actually renders
per request, by name, off Sites.theme (Known Issue #30) -- not a
per-deployment choice, so no new container is needed to add a theme.Repository Structure
Independent git repositories, not one monorepo with everything in it:
polysite(core platform) —github.com/polysites/polysite. Generic, reusable across clients: the Nuxt app shell, Payload CMS, infrastructure config (Docker Compose, Caddy, GitHub Actions), and composables shared by every client theme.polysite-themes,polysite-theme-<name>(themes) — the starter theme and developer guide, plus one repo per client theme, nested atthemes/inside the core repo's working directory but each with its own independent.gitand commit history (excluded from the core repo via.gitignore). A theme carries everything specific to one client: branding, layout, and any client-only pages/components. This split keeps the core reusable while each client's customization lives in its own versioned, independently-deployable package — and per Known Issue #30, all of them get extended into the same running app at once, with a per-Site field choosing which one actually renders for a given request.polysite-plugins(plugins) — optional capabilities and content types, nested atplugins/the same way (see "Polysite Plugin System" below).
Themes and plugins are both optional: the core builds and runs with neither (Known Issue #56).
Inside the core repo:
apps/
web/ Nuxt — public multisite frontend (generic core)
cms/ Payload — editorial cockpit
themes/ optional; each theme is a separate git repo, gitignored from core
plugins/ optional; the plugins repo, gitignored from core
backup/ deploy/ tests/ the backup tool and policy, deploy.sh, and the smoke / end-to-end / backup / deploy tests
docker-compose.yml production-parity local stack
docker-compose.dev.yml fast local dev override (hot reload, schema auto-pushed)
docker-compose.prod.yml production stack (published images, Cloud SQL, real domains)
Caddyfile reverse proxy / TLS, shared by every compose file
.github/workflows/ ci.yml (checks), release.yml (tag to images), deploy.yml (manual deploy), docs.yml (docs site)
docs/ Guides, Architecture, Brand guideInfrastructure
The pilot runs on a single Google Cloud Compute Engine VM with Docker Compose and no container orchestrator: Kubernetes, Docker Swarm and Cloud Run were each evaluated and rejected for the pilot (see the product requirements' Infrastructure & Deployment section for why). That was a decision for one client's traffic, not a limit of the product: since then Kubernetes is offered as a deployment option (a Helm chart, Known Issue #71) with its limits documented, and the single VM stays the reference path. Deployment options compares them. The VM keeps a two-disk layout: a disposable OS disk and a separate data disk holding uploaded media, the Redis AOF file, and container configuration, with scheduled snapshots on the data disk only.
Docker (not Podman — an earlier decision, reversed because Docker was already installed and standardized on both the client's local machine and the server) runs everything via Docker Compose. No language runtimes live on the host — all Node.js work happens inside containers; this was an explicit correction made mid-build after Node was briefly installed locally for scaffolding verification.
DNS is managed in Cloudflare. Caddy is the reverse proxy and TLS terminator for every site: it requests and renews a Let's Encrypt certificate automatically per hostname as each one is added to its config, whether that site is a subdomain under one wildcard-friendly domain or an entirely separate root domain — so there's no dependency on wildcard DNS being available anywhere, and no manual certificate management.
Container Topology
Six services in local dev (docker-compose.yml, project name polysite, so containers are polysite-cms-1, polysite-web-1, polysite-db-1 and so on):
| Service | Role |
|---|---|
caddy | Reverse proxy, TLS, static file serving |
web | Nuxt (public site) |
cms | Payload CMS (admin + API) |
db | PostgreSQL (local dev only) |
redis | Redis, AOF-persisted (not yet wired to app logic — see Not Yet Built) |
backup | Nightly database + media backup, weekly test restore (backup, Known Issue #44) |
A seventh, restore, sits behind the restore profile and runs only on purpose (docker compose --profile restore run --rm restore latest --yes).
Production (docker-compose.prod.yml) pulls published, versioned images instead of building (see Deployment Pipeline), drops db and adds cloudsql-proxy, a secure tunnel to the managed Cloud SQL instance — the standard pattern for a non-GKE/non-Cloud-Run VM talking to Cloud SQL without exposing it publicly. In local dev cms and web also run with no-new-privileges and all Linux capabilities dropped; the images run as the unprivileged node user (Known Issue #58).
Request Routing (Caddy)
One Caddy config serves both the public site and the CMS admin, using two virtual hosts driven by env vars:
{$SITE_SCHEME}://{$SITE_DOMAIN}(defaulthttp://localhostin dev): serves static files directly from a shared volume (file_serverwithpass_thru, falling through to Nuxt for anything not a real file on disk), proxies/api/*tocms(so browser-side fetches never need to resolve a Docker-internal hostname), and proxies everything else toweb.{$CMS_SCHEME}://{$CMS_DOMAIN}(defaulthttp://admin.localhost): proxies entirely tocms.
Two reusable snippets add headers for what the CMS answers (the public site's own pages get theirs from the web app's nuxt-security): api_headers (nosniff, a referrer policy, Strict-Transport-Security) on the API and uploaded files, and admin_headers (the same plus X-Frame-Options: DENY and frame-ancestors 'none') on the admin domain. Request bodies are capped in Caddy as well as in the app: 64 KB for /api/form-intake/* (a chunked body declares no length for the app to check), 100 MB for the rest of the API and the admin. Uploaded files are served with Cache-Control: public, max-age=86400 on the public sites. Sign-in and password-reset throttling is not in Caddy (the stock image has no rate limiting) but in the CMS (auth/ipThrottle.ts), which reads the address Caddy puts last in X-Forwarded-For, so port 3001 must never be published (Known Issue #67).
The scheme is a separate variable from the domain (SITE_SCHEME/CMS_SCHEME, default http) because Caddy auto-upgrades localhost to HTTPS by default even without an explicit scheme — a real 308-redirect-to-untrusted-cert bug hit and fixed during verification. Production sets both scheme vars to https, which switches Caddy into its normal automatic-HTTPS mode.
Request & Data Flows
Loading a page (server-rendered)
Browser --GET /--> caddy --> web (SSR)
|
usePayloadPage(slug)
| (apiBaseInternal, direct)
v
cms --> db
|
<-- rendered HTML returned to browserA client-side API call (after the page has loaded)
Browser --fetch("/api/pages?where...")--> caddy
| /api/* handle block
v
cms --> dbPublishing a change (cache revalidation)
Editor clicks Publish in cms admin
|
v
Pages.ts afterChange hook fires
|
v
POST web:3000/api/revalidate (x-revalidate-secret header)
|
v
useStorage("cache").clear() inside web
|
v
Next request to any page renders fresh; the isr route rule
caches it again from thereApplication Layer: Nuxt (apps/web)
Server-rendered on every request (no build-time static prerendering — removed after it conflicted with fetching page content from a CMS that isn't reachable during the isolated Docker build step). Caching comes from a Nitro routeRules entry ("/**": { isr: true }), invalidated on demand by a /api/revalidate webhook endpoint that Payload calls on publish. Since Nuxt/Nitro has no Next.js-style ISR as a turnkey default, the webhook's actual mechanism is a full cache clear (useStorage("cache").clear()) rather than a surgical per-path purge — documented as an intentional simplification, cheap at this traffic scale.
A small set of composables carries the CMS-fetching logic, shared by every page and every client theme (apps/web/composables/):
usePayloadPage(slug)— fetches a single Payloadpagesdocument by slug, for the request's own site.useSite(),useSiteBranding(site)— the currentSiterecord and its colours, fonts, navigation and footer (Known Issues #28, #30).useSiteLanguage(),useT()— the language of the address and the fixed-word dictionaries (Known Issue #63).useFormIntake(),usePageSeoMeta()— form behaviour (Known Issue #47) and the page<head>(Known Issue #25).
It splits its API base URL by execution context: server-side (SSR) calls go directly to cms over the Docker network (apiBaseInternal), while client-side calls use a relative /api path that Caddy proxies to the CMS (public.apiBase). This split exists because http://localhost:3001 — the original single value — resolves to the web container itself during SSR, not the CMS, and port 3001 was never published to the host either way; a real build/runtime failure, not a hypothetical one.
The homepage (pages/index.vue) and the catch-all page (pages/[...slug].vue) are both in this app, theme-agnostic: the page is drawn by BlockRenderer, which picks each block's component from the site's theme (Known Issues #30, #40). The core also has a plain layouts/default.vue and error.vue (a Polysite-branded 404 and error page that follows the visitor's language) for a site with no theme.
Application Layer: Payload CMS (apps/cms)
Payload 3, which mounts as routes inside a Next.js app rather than running standalone. The (payload) route-handler files (admin/[[...segments]], api/[...slug], api/graphql, api/graphql-playground, layout.tsx, admin/importMap.js) were pulled directly from the create-payload-app npm package's bundled template rather than hand-typed, to avoid guessing boilerplate that Payload itself generates and maintains.
Collections:
| Collection | Purpose | Access control |
|---|---|---|
users | Editors/admins. role (relationship to roles, dynamic, not a hardcoded enum — Known Issue #34) + isSuperAdmin (the one hardcoded, non-role bedrock flag) + assignedSites (which sites this user can touch — the multi-tenant plugin's array field as of Known Issue #29, not hand-written) | Super-Admin-only create/update/delete; read is also Super-Admin-only, with a self-by-id exception (a user can fetch their own document) — see Known Issue #36, which fixed this from a previous, real gap where read was omitted entirely and defaulted to Payload's public () => true. unlock is Super-Admin-only; sign-in and forgot-password calls are limited per address (auth/ipThrottle.ts) and the session cookie is Secure over https (Known Issue #67); a plugin keeps per-user state in its own collection, not here |
roles | The dynamic half of RBAC (Known Issue #34): name, modules (which collections this role can touch), operations (create/read/update/delete/publish/unpublish/cloneAcrossSites, applied uniformly across every listed module), manageSiteSettings. Ships pre-seeded with 3 (Super Admin, Site Admin, Site User) | Super-Admin-only read/write, with a self-role-by-id exception (a user can resolve their own assigned role) — previously any authenticated user could read every role; see Known Issue #36 |
sites | One row per client site; domain field is what Caddy/Nuxt resolve a request to, theme selects which theme repo's layout renders it (Known Issue #30), its options auto-discovered by scanning themes/ rather than hand-maintained (Known Issue #32). enabledModules (Known Issue #34) toggles which content modules the site exposes, independent of any role's own module access. Also doubles as the multi-tenant plugin's tenants collection (Known Issue #29). Tabs: General, "Branding" (logo, favicon, tagline, show/hide Donate button, primary/secondary color, heading/body font family + Google Fonts URL, header nav items, footer columns, social links; Known Issues #28/#31), Privacy (Known Issue #48), Forms (per-site Turnstile keys; Known Issue #52) and "Advanced" (customCss/customJs, site-wide raw CSS/JS injection; Known Issue #31 — customJs is real code execution in every visitor's browser, gated only by this collection's write access, not by CSP). A plugin adds nothing to this collection: its per-site settings live in its own collection and appear on the public record as site.extensions.<key> (Known Issue #64) | Public read (name, domain and branding are not secret; the plugin-set fields are limited to each plugin's declared public ones); Super-Admin-only create/delete; update also allowed for a role with manageSiteSettings, scoped to its own assigned site(s) |
pages | Static/informational pages: site (injected by the multi-tenant plugin), title, slug, _status (native drafts, up to 25 versions, scheduled publish; Known Issue #61), content (blocks). Carries a custom POST /:id/clone endpoint (Known Issue #34) for copying a Page to a different Site; it reads the source as the caller (Known Issue #67) | Public read of published pages only; capability-gated (hasCapability) read/write for authenticated users, further scoped to assigned sites by the multi-tenant plugin; version history needs pages:read |
media | Uploaded files (images), site-scoped | Public read; capability-gated write |
forms, form-submissions | The dynamic form builder (@payloadcms/plugin-form-builder), site-scoped. Submissions are filed under their form, read-only for everyone (no create, edit or delete), exportable as CSV, and checked on the server (hidden trap field, signed token, rate limit, ALTCHA or Turnstile). Known Issues #51-#52 | Capability-gated, scoped to assigned sites |
redirects | Managed 301/302 redirects per site, to a page, a file, a form or an external URL | Capability-gated, scoped to assigned sites |
site-urls | The index of every address a site answers to (pages, redirects and any plugin's public content), built by @payloadcms/plugin-search as content is saved, so two things can't claim the same one; each live entry also carries its plain text in a hidden, per-language searchText (Known Issue #68) | Generated (no manual create). Visitors, and roles with no content permission, read only published/active entries; roles that can read pages, redirects or a routable content type read all (still per site); update and delete (the Reindex button) need manageSiteSettings (Known Issue #67) |
privacy-requests | The per-site register for finding, exporting and erasing one person's information, with a deadline tracker | Capability-gated, scoped to assigned sites |
Site-scoped access control (pages, media) is @payloadcms/plugin-multi-tenant (Known Issue #29) layered on top of the RBAC capability matrix (Known Issue #34), not hand-written Where filters — the plugin ANDs a { site: { in: <user's assigned sites> } } constraint onto each collection's own access result automatically, and Super Admins bypass it entirely via userHasAccessToAllTenants. This is the concrete implementation of "editors assigned to Site A cannot see or modify assets belonging to Site B" from the product requirements. Commitments cards are no longer their own collection — see Known Issue #26.
Collections that plugins add (posts, the settings and log collections of email, auth, search, share and the rest) are not in this table: each plugin's README lists its own, and the core only wires them in through the extension points described under Polysite Plugin System.
Database: PostgreSQL via @payloadcms/db-postgres. Payload's Postgres adapter only auto-syncs schema outside of NODE_ENV=production; production mode requires checked-in migrations, applied via npx payload migrate — wired into the Dockerfile's CMD to run before npm start on every container start (a no-op if nothing changed). This was a real bug (relation "pages" does not exist) caught during first end-to-end verification, not a preemptive guess. The core's migrations hold core tables only (one baseline, core_baseline); each plugin ships its own and the adapter runs them after the core's (Known Issue #64).
Theme Layer (polysite-theme-starter, polysite-theme-<client>, ...)
A Nuxt layer — a self-contained package of pages/components/config that another Nuxt app "extends." As of Known Issue #30, apps/web/nuxt.config.ts extends every theme repo at once, found by scanning themes/ at start (Known Issue #32; extends: LAYER_PATHS, local filesystem paths on purpose, because github: layers can't see a private repository). Which theme actually renders for a given request is runtime data (Sites.theme), not a per-deployment build choice — see Known Issue #30 for the full mechanism (multiple themes, one running app, no new container per theme). The plugins' web/ layers are found and extended the same way.
Each theme repo holds its own layouts/<theme-name>.vue (header/footer chrome) with that client's structure. All copy and imagery in them are placeholders, not any client's real content: this was a deliberate line held even after a client asserted authorization to reuse their live site's exact text/logo/photos, since verbatim reproduction of substantial copyrighted text and rehosting real photos/logos isn't something built into the platform regardless of the asserted permission — the client supplies that content directly (via the CMS, or as asset files) rather than it being scraped from their live site.
Blocks live in themes, not in the core (Known Issue #40). The core apps/web is the engine: data fetching, routing, SEO, and a plain fallback for each block. A theme supplies the designed blocks as components/<theme>/<Name>Block.vue, and BlockRenderer picks per site: the site's theme first, then its parent (set in theme.json), then the core fallback. polysite-theme-starter is the complete reference theme (all nine blocks, a layout, and a design-tokens stylesheet) meant to be copied; a client theme can declare it as its parent and override only what it changes. Its layout places the optional plugins' parts through <SiteSlot> (search box, language switcher, share buttons; Known Issue #57). Theme CSS is scoped to a class on the layout root (.theme-starter) because all themes share one build.
Because every theme depends on usePayloadPage/useSite/useSiteBranding, all core composables, Nuxt's layer auto-import system resolves them across every layer boundary with no extra wiring — each theme only needs to call them. useSite() (Known Issue #28) resolves the current Site doc the same way usePayloadPage resolves the current Page — by the request's own hostname. useSiteBranding(site) (Known Issue #30) is the shared, synchronous helper every theme's layout calls with that already-resolved site ref to compute colors/fonts/nav/footer/social-link data — deliberately not async itself, after a real bug: an earlier version awaited useSite() internally before calling useHead(), which crashed with "a composable... called outside of a plugin/setup function" the moment two themes existed to actually exercise it, because Vue only restores a component's Nuxt-instance context across an await for the setup function's own top-level await, not one buried inside a function setup merely calls.
Styling: Tailwind CSS v4 as of Known Issue #28, turned on via a one-line apps/web/assets/css/main.css (@import "tailwindcss";) referenced from nuxt.config.ts's css array — no separate @nuxtjs/tailwindcss install needed, since @nuxt/ui (already a module) pulls in tailwindcss/@tailwindcss/vite as its own dependency. Used directly as utility classes on plain HTML elements in the homepage's blocks and each theme's layouts/<theme-name>.vue; @nuxt/ui's own components aren't used. Per-site brand colors (Sites.primaryColor/secondaryColor) are wired in as CSS custom properties (--brand-primary/--brand-secondary, set inline on the layout's root element from useSite()) rather than baked into the Tailwind build, since Tailwind's utility classes are generated at build time and can't know a per-request value — every block references them via Tailwind's arbitrary-value syntax (bg-[var(--brand-primary,#f26522)], with a literal fallback for resilience). Icons throughout (including the wireframe's lucide-vue-next icon set) render through the already-installed @nuxt/icon using lucide:* names (e.g. <Icon name="lucide:menu" />) — confirmed Iconify's bundled Lucide collection covers every icon needed, so no new icon package was installed.
Tailwind and the layers. Tailwind v4 skips nested git repositories, and every theme and plugin web layer is one, so a class used only inside a layer generated no CSS (Known Issue #28). Neither a glob nor one hardcoded name held up, so nuxt.config.ts writes apps/web/assets/css/layer-sources.css (git-ignored, rewritten at every start) with an @source line per theme and plugin layer it found, and main.css imports it. A new theme or plugin layer is therefore picked up by restarting the web container, with no edit to the core.
Per-site typography works the same way as colors: Sites.headingFontFamily/bodyFontFamily (plain CSS font-family names) drive --font-heading/--font-body custom properties the same way primaryColor/secondaryColor drive --brand-primary/--brand-secondary, and Sites.googleFontsUrl (the literal <link> href Google Fonts itself generates once you pick families/weights) is injected via useHead() reactively off useSite() — no font is loaded at all until a Site's record actually sets one. h1/h2/h3/h4 get --font-heading via a small global rule in main.css; everything else inherits --font-body from .site-shell's own inline font-family (custom properties and font-family both inherit down the DOM regardless of component boundaries, so this needs no per-block wiring).
A real client's brand values live only as CMS data, never in git. Colors and font choices are Sites field data, set through the admin or API like any other content, and are never hardcoded into this repo's shared theme code, whose own defaults stay the original placeholder values (#f26522 etc.) for anyone cloning or reusing it. A licensed typeface can't be bundled without a license; a free Google Fonts substitute stands in for it. What stays fictional in the repository regardless, per the placeholder-content rule above: names, logo artwork and all copy. The Sites.logo/favicon upload fields stay empty in the repo; a real deployment uploads its own logo file there.
Docker build implication: web's build context is the repo root, not ./apps/web, so the build can see apps/web and every sibling themes/polysite-theme-* directory it extends. The Dockerfile mirrors the exact relative path structure (/repo/apps/web, /repo/themes/..., /repo/plugins/...) with a single COPY of the whole themes/ and plugins/ folders (Known Issues #32, #56), so the same extends paths resolve identically inside the image as on disk, and docker-compose.dev.yml bind-mounts the whole folders, so a new theme needs no compose or Dockerfile edit. The published images hold the core only; a site that uses themes or plugins builds its own image from source (see Deployment Pipeline). CI builds both images with empty themes/ and plugins/ folders.
Data Flow: Petition Intake (partly built)
Status 2026-10-17. Built: one signature per person (a hashed key with a database unique index) and a public, cached signature count (Known Issue #73). Not built, on purpose: the Redis queue and worker below. Submissions are written straight to Postgres by the intake endpoint; Known Issue #74 measured one CPU at about 55 signatures a second with no loss, thousands of times the PRD's target, so the queue waits until a bigger target or the need to accept signatures while the database is down. The rest of this section is the original design.
Per the product requirements, petition/form submissions are meant to flow: intake endpoint → Redis queue (immediate response to the browser) → worker draining the queue into Postgres at a controlled rate → a Redis counter serving the public live signature count without querying Postgres per page view. Redis is running with AOF persistence (appendonly yes, appendfsync everysec) in both compose files, but no application code exercises this path yet — petitions/forms are an explicitly later Rollout Plan phase, deferred behind the current static-page Prototype.
Browser submit --> intake endpoint (not yet built)
|
v
redis queue --> Redis counter
| (public live count)
v
worker (not yet built) --> dbDeployment Pipeline
A release and a deploy are two separate acts (guides: docs/guide/releasing.md, docs/guide/deploying.md). Pushing a tag deploys nothing.
ci.yml— on every push and pull request, and called by the release workflow so a release runs exactly the same gates: lint and unit tests for both apps, the migration check (npm run check:migrations),docker build --target runof both images with emptythemes/andplugins/folders, the fresh-install smoke test (tests/smoke/run.sh, then the workflow, i18n and security end-to-end checks), and a plugin end-to-end job that runs only when thePLUGINS_DEPLOY_KEYsecret lets it read the plugins repository (otherwise it reports itself skipped and passes).release.yml— on av<semver>tag: checks the tag againstVERSION, bothpackage.jsonfiles andCHANGELOG.md, runs the CI gates, builds the cms, web and backup images, scans them, pushes them to the registry by version, smoke-tests the pushed digests and drafts a GitHub release. A manual run is a dry run that pushes nothing.deploy.yml— manual only (workflow_dispatch, with a version or a rollback box, behind theproductionenvironment). SSHes into the VM (port 3333, non-default) with a key stored in GitHub Secrets, checks out the tag and runsdeploy/deploy.sh: backup and test restore, the migrations in a one-off container, one service switched at a time behind health checks, an automatic rollback if one fails. Rollback is "deploy the previous images" (--rollback), not a revert of a release.docs.yml— builds this documentation site on every change underdocs/, so a dead link fails the build; publishing to GitHub Pages is a manual run.- Dependabot (
.github/dependabot.yml) — weekly npm updates for both apps with the Payload packages in one pull request, monthly for the docs, Docker base images (Node LTS lines only) and the Actions (every action is pinned to a commit SHA with its release in a trailing comment, which Dependabot rewrites; Known Issue #69).
The published images contain the core only. Themes and plugin web layers are Nuxt layers fixed at build time and plugin admin components go through Next's import map, so an install that uses any builds its own image from source and points POLYSITE_CMS_IMAGE / POLYSITE_WEB_IMAGE at it.
Deploy key model: a dedicated SSH keypair scoped to the polysite repo only (added as a GitHub Deploy Key with write access, not tied to any personal GitHub account), rather than the assistant holding standing personal SSH/API credentials. Verified with a real push (not just a dry-run, which can pass without proving server-side write permission) before the personal key it initially supplemented was retired.
Secrets & Configuration
.env (gitignored; .env.example checked in) for the Prototype phase, loaded via Docker Compose's env_file. Deliberately no shared NODE_ENV in .env — both apps/web and apps/cms set NODE_ENV=production themselves in their Dockerfiles, and a shared NODE_ENV=development was found to override that via env_file, breaking Payload's production-mode behavior. Planned post-Prototype upgrade: Docker Compose's file-based secrets: (mounted at /run/secrets/, not exposed as an environment variable, no Swarm mode required). Every service currently gets the whole .env (the web and Caddy containers included), which is why the secrets that matter are also checked at start.
With SITE_SCHEME or CMS_SCHEME set to https, payload.config.ts calls assertProductionSafe() (utils/productionChecks.ts) and the CMS refuses to boot when PAYLOAD_SECRET (and REVALIDATE_SECRET, NUXT_REVALIDATE_SECRET, POSTGRES_PASSWORD where set) is a placeholder or under 32 characters, or CMS_DOMAIN is empty (POLYSITE_ALLOW_WEAK_SECRETS=1 switches it off); the first administrator then also needs POLYSITE_SETUP_TOKEN (setup/policy.ts, POLYSITE_ALLOW_OPEN_SETUP=1 lifts it). Every variable is described in docs/guide/configuration.md, and the operator's view of these checks is docs/guide/security.md.
Known Issues Found and Fixed During Build
Kept here as a record, since each was a real, reproduced failure rather than a hypothetical. The numbers are stable and sequential (#1 to #68): other docs and code comments refer to "Known Issue #N", so a new entry goes at the end with the next number, and an old one is corrected in place, never renumbered. Entries are history: where a later entry changed the picture, it says so, and the sections above describe the system as it is now.
Root-path routing gap — the homepage never called the CMS at all; a Payload page with slug
/had nothing rendering it, since Nuxt's catch-all route never matches the root path. Fixed by extracting a sharedusePayloadPagecomposable used by bothindex.vueand[...slug].vue.Build-time prerender vs. runtime CMS dependency —
nitro.prerender.routes: ["/"]tried to statically render the homepage during the isolated Docker build step, before any CMS is reachable; build failed with a 404. Removed.Internal vs. external API base URL mismatch —
NUXT_PUBLIC_API_BASE=http://localhost:3001resolved to thewebcontainer itself during SSR, not the CMS container, and port 3001 was never published to the host. Split intoapiBaseInternal(server, direct tocms) and a relativepublic.apiBase(client, via Caddy).Payload CLI ESM/CJS incompatibility —
payload migrate:createcrashed withERR_REQUIRE_ASYNC_MODULEon both Node 20 and Node 24, traced to@payloadcms/richtext-lexical's ESM build beingrequire()'d by Payload'stsx-based CLI loader. Fixed by adding"type": "module"toapps/cms/package.json.Production-mode schema migrations — Payload's Postgres adapter doesn't auto-sync schema in production; first real run threw
relation "pages" does not exist. Fixed by generating a checked-in migration and runningnpx payload migratein the Dockerfile'sCMD.Caddy auto-HTTPS on localhost — a bare
{$SITE_DOMAIN:localhost}site block got a 308 redirect to HTTPS with an untrusted local CA cert. Fixed by parameterizing the scheme (SITE_SCHEME/CMS_SCHEME), explicithttp://in dev.Shared
NODE_ENVoverride — see Secrets & Configuration above.TypeScript
Where-type inference —Pages.ts's access-control function returned a union of differently-shaped object literals, which TypeScript couldn't reconcile against Payload'sWheretype despite each branch being individually valid; fixed with an explicitas Wherecast per branch.Docker secrets-in-layer anti-pattern (caught before shipping) — an initial attempt to pass
PAYLOAD_SECRET/DATABASE_URLas Docker buildARG/ENV(to "fix" a CI failure that, on investigation, didn't actually exist) was reverted after Docker's ownSecretsUsedInArgOrEnvlinter flagged that this bakes secrets into the image's permanent layer history.payload migrate:create's interactive prompt can't be scripted — the create/rename-column disambiguation prompt (drizzle-kit'shanjilibrary) creates areadline.Interfacein line-buffered mode and layers raw keypress parsing on top of the same stdin; on a non-TTY stdin (any scripteddocker run -i/exec -i, piped newlines,yes '', CI) the two input paths conflict and it hangs regardless of how much input is piped in — traced tonode_modules/drizzle-kit/api.mjs, not fixed by trying harder to script it. The same prompt appears in Payload's dev-mode schema push (pushDevSchema→ drizzle-kit'spushSchema) whenever the live dev DB's schema differs enough from the code to be ambiguous. Real fix: run it from an actual terminal (docker compose exec -it cms npx payload migrate:create <name>), or avoid the ambiguity in dev by resetting the dev DB volume so there's nothing to disambiguate against.@nuxt/ui/@nuxt/iconwere silently disabled the whole time — both require Nuxt ≥4, butapps/webwas pinned to Nuxt 3.21.11; Nuxt doesn't fail the build for an incompatible module, it just logs aWARN [NUXT_B8013]and disables it, so the modules appeared installed and configured but never actually ran. Fixed by upgradingnuxtto^4.5.2in bothapps/weband thepolysite-theme-acmelayer (a layer's own Nuxt version mostly only matters for that repo's own tooling/typings — the consuming app's installednuxtis what actually runs — but kept in sync to avoid future confusion). Nuxt 4.5.2 in turn requires Node ≥22, so both Dockerfiles moved fromnode:20-alpinetonode:22-alpine(they are onnode:24-alpine, the 24 LTS line, now; Dependabot is told to stay on LTS lines).Dev-mode bind mounts shadow build-time generated directories —
docker-compose.dev.ymlbind-mounts the wholeapps/web(andapps/cms) source tree over the image at runtime for hot-reload, which also overlaysnode_modulesand Nuxt's.nuxt(generated bynuxt prepare) with whatever's on the host — nothing, since no Node runtime lives there. Fixed with named volumes mounted specifically atnode_modulesand.nuxtso those subpaths keep the container's own content instead of the (empty) host bind..nuxtalso has to be regenerated at container start (npx nuxi prepareinCMD, beforenuxt dev), not just preserved from image build time — the dev stage'snpm installruns beforenuxt.config.ts/the theme layer are bind-mounted in, so its build-timenuxt prepareproduces a.nuxtfor what looks like an empty project.Payload's admin
importMap.jsdoesn't hot-reload — it's generated once (payload generate:importmap) and read at process start; adding a plugin or collection that registers a new admin component (e.g.@payloadcms/plugin-seo's preview component) without regenerating it produces a runtimegetFromImportMap: PayloadComponent not found in importMapwarning in the admin UI, and regenerating the file alone isn't enough either — the runningnext devprocess already has the old one loaded, so the CMS container needs a restart too.Docker Desktop host port-forwarding can go stale after a sleep/restart — containers stay healthy (internal container-to-container
curlreturns instantly) but host-sideInvoke-WebRequest/curl.exe -4to the published port hangs or times out. Restarting the affected containers didn't help; it resolved itself once traffic was addressed to127.0.0.1explicitly rather thanlocalhost(which was resolving to::1first). Caveat found later:127.0.0.1only works for hitting a container's own published port directly (cms:3001,web:3000). Caddy virtual-hosts on theHostheader (localhost/admin.localhost), so a barecurl http://127.0.0.1/...gets an empty200from Caddy (no matching site block) — test through Caddy withcurl --resolve localhost:80:127.0.0.1 http://localhost/...instead, which keeps the correctHostheader while still forcing IPv4.Vite's dev-server file watcher never fires on Windows bind mounts —
docker-compose.dev.ymlbind-mountsapps/webfrom the Windows host, and Docker Desktop doesn't propagate native filesystem change events into the Linux container, so chokidar's default (inotify-based) watcher silently never triggers HMR — edits kept serving stale component output until the container was restarted, confirmed by editingTextBlock.vueand watching the response not change. Fixed withvite: { server: { watch: { usePolling: true } } }innuxt.config.ts; pure dev-server config, so it's a no-op fornuxt build/production.[...slug].vuecrashed on an unguardedlivePage.title—useLivePreview'sdataref can transiently beundefined(Nuxt 4'suseFetch/useAsyncDatadefault toundefinedinstead ofnull, a real behavior change from the Nuxt 3→4 upgrade), and the template readlivePage.title/livePage.contentwithout optional chaining onlivePageitself, throwingCannot read properties of undefined (reading 'title')client-side. Fixed withlivePage?.title/livePage?.content?.length/livePage?.content.usePayloadPage'suseFetchcall needed an explicit, slug-based key — its auto-generated cache key is derived from the request URL string alone (${base}/pages), which is identical for every slug; a client-side navigation from one page to another (e.g. homepage →/about-us) reused the first page's cached response instead of refetching, so the second page always 404'd. Reproduced for real: browsing in-app from/to/about-us404'd every time, while a fresh full page load of/about-us(curl, or a new tab) worked fine. Fixed by passingkey: \payload-page-${slug}`touseFetch`.Payload's default logout control is icon-only —
@payloadcms/ui'sLogoutcomponent renders just<LogOutIcon />; the "Log out" text only exists as anaria-label/titletooltip, invisible at a glance. Real user-reported confusion ("no logout button found") despite the control being right there in the nav. Fixed with a customapps/cms/src/components/LogoutButton.tsxwired in viaadmin.components.logout.Button, adding a visible text label next to the icon — same underlying link/href, just with the label rendered instead of hidden in the accessible name alone.Two collections had real, unrestricted access-control gaps, not just missing role distinctions —
Sitesdefinedcreate/update/deletebut neverread, which defaults to Payload's built-in() => true: every Content Editor and Campaign Manager (and any unauthenticated API caller) could list every client's Sites record regardless ofassignedSites.Mediadefined onlyread, socreate/update/deletealso defaulted wide open: any authenticated user, any role, any site, could upload or delete any site's files. Fixed by adding proper site-scoped access functions to both (Mediaalso gained asiterelationship field it never had), extracted into a sharedapps/cms/src/access/roles.ts(isAdmin,isAdminOrCampaignManager,userSiteIds) alongside the pre-existing Pages.ts/Commitments.ts pattern.Content Editor vs. Campaign Manager had no actual behavioral difference anywhere — both roles hit the exact same site-scoped access functions on every collection; the product requirements name them as distinct personas ("Campaign Manager... manages form layouts" vs. "Content Editor... drafting articles") but nothing in code reflected that. Payload has no built-in RBAC admin UI — access control is purely code-defined functions per collection/field (confirmed against Payload's own docs and its "build your own RBAC" guide, which uses this exact pattern: a
rolefield plus functions returning booleans orWherefilters). Implemented this boundary: Content Editor can create/edit Pages/Commitments/Media but can't set a Page'sstatustopublished(field-levelaccess.update/access.createon that one field — Payload silently reverts a denied field write rather than failing the whole save, verified for real: an editor'sPOSTwithstatus: "published"came back saved as"draft"); Campaign Manager can do everything Editor can, plus publish and manage the form-builder plugin'sForms/Form Submissionscollections (overridden viaformBuilderPlugin'sformOverrides/formSubmissionOverrides, since the plugin's own defaults have no restriction at all). Verified end-to-end against the project's actual 3 user accounts, not just the logic in isolation: editor sees 1 of 2 sites, editor's publish attempt reverts to draft, editor is deniedFormsentirely, campaign-manager can readFormsand successfully publish.A real ccTLD pointed at loopback gets its scripts silently blocked by the browser — tried naming local dev domains
local.polysite.ca/local.voidslate.ca(hosts file entries to127.0.0.1), which is a legitimate DNS setup (Resolve-DnsNameconfirmed it resolved correctly) but broke in the browser anyway: every_next/static/chunks/*(and Nuxt's_nuxt/*) request came backERR_BLOCKED_BY_CLIENT, reproduced independently in two separate browsers, while the exact same files loaded fine fromlocalhost:3001directly and viacurlthrough Caddy on the.cadomain. The document itself always loaded (server-side everything was correct); only client-side script/style requests were blocked. Root cause: a real-looking public TLD resolving to loopback over plain HTTP matches a phishing/malware-evasion heuristic that browser and OS-level security layers (Windows network protection, AV web-shields, some content blockers) specifically watch for, andlocalhost-pattern hostnames are the well-known, deliberate exemption from it. Fixed by switching to*.localhostsubdomains (polysite.localhost/voidslate.localhost) instead of a real ccTLD — per RFC 6761 every browser auto-resolves any*.localhostname to127.0.0.1with no hosts file entry at all, and treats it as a trustworthy local origin, avoiding the heuristic entirely rather than needing to whitelist anything.Two pages with the same slug on different sites, or a URL not scoped to the requesting domain at all —
usePayloadPage/useCommitmentsqueried byslugalone, globally, with no awareness of whichSitea request's domain belongs to; with only one Site in the dev DB this never surfaced, but it's the exact multi-tenant gap the product requirements call out. Fixed by resolving the currentSitefrom the request's own hostname (useRequestURL().hostname, isomorphic — works identically during SSR and client-side navigation) and filtering both queries by the related Site'sdomainfield via Payload's relationship dot-notation query (where[site.domain][equals]). Also added avalidatefunction onPages.slugrejecting a second page on the same site reusing a slug (a different site reusing the same slug is fine and expected). Two real things caught while building this: (1) the relationship-field query initially failed with"cannot be queried: domain"— Payload's query-path validator requires read access on the joined collection, not just the collection being queried, soSites' anonymous-read-denied fix from Known Issue #19 had to be loosened back to public read (sitename/domainaren't sensitive; the real leak that issue fixed was cross-site visibility between authenticated non-admin users, which is unaffected). (2) Caddy itself only routes the singleSITE_DOMAINfrom.env— verified a genuinely second site's isolated content resolves correctly by querying the web container directly withcurl --resolve <domain>:3000:127.0.0.1, bypassing Caddy, since testing a real second domain through Caddy needs a site block added for it first (see Not Yet Built).The homepage silently ignored its own CMS content — the admin's Home page (slug
/) points at the actual homepage, but editing it did nothing, because the theme'spages/index.vuewas a fully hardcoded template (header, hero+signup, leader spotlight, commitments, donation tiers, footer) that only ever read the page'stitlefield — itscontentblocks were never rendered at all, unlike every other page ([...slug].vue, which renderscontentviaBlockRenderer). Fixed by making the homepage genuinely block-driven: four new block types (SignupForm,LeaderSpotlight,DonationTiers,CommitmentsGrid— at the time, a marker with no data field of its own, rendering the current site's Commitments collection; converted to inline items in Known Issue #26) plus matching frontend components, andpages/index.vuesimplified to the exact sameusePayloadPage/useLivePreview/BlockRendererlogic as[...slug].vue(it only still exists as a separate file because Nuxt's[...slug].vuecatch-all doesn't match the bare root path — not because the homepage needs different rendering). A second inconsistency surfaced while fixing this and got fixed too: only the old hardcoded homepage had a header/footer at all — every other page had no navigation whatsoever. Added a site-widelayouts/default.vue(applied via<NuxtLayout>inapp.vue) so navigation is consistent on every page, not just/.Hero.imagewas a real, populated field that the frontend never rendered — updating the Hero block's image in the admin had no visible effect on the homepage. Confirmed via the API that the field really was set and populated correctly (Payload returns the fullMediadoc, includingurl, atdepth: 1) —HeroBlock.vuesimply never declared animageprop or rendered it at all, for any page, since the block was first built. Not a caching or reactivity bug; the prop didn't exist. Fixed by adding the prop and rendering it as a full-width banner image with the heading/subheading overlaid when present, falling back to the original plain layout when a Hero has no image (most existing ones don't).@payloadcms/plugin-seo'smeta.title/meta.description/meta.imagefields were real and populatable but never read by the frontend — same defect class as #24, found by an explicit audit of every block after that one turned up (every block itself turned out clean; this was found checking the adjacent SEO fields while at it).index.vue/[...slug].vueonly ever calleduseHead({ title: ... })off the plaintitlefield — the SEO plugin's admin tab (title/description/meta image, with its own character-count checks) had zero effect on the actual rendered<head>, latent since no page had used it yet. Fixed with a sharedusePageSeoMetacomposable (useSeoMeta, reactive against Live Preview) applyingmeta.title/meta.descriptionwith a fallback chain to the plaintitlefield (matching the plugin's owngenerateTitlefallback behavior in the admin), plusog:title/og:description/og:image— the last resolved to an absolute URL viauseRequestURL().origin, since Payload's uploadurlis relative and Open Graph crawlers need an absolute one. Verified for real: setmeta.title/meta.descriptionon a page via the API, confirmed<title>,og:title,name="description", andog:descriptionall update in the rendered HTML; confirmed a page with no meta set still falls back to its plain title correctly.Commitmentswas a full collection (with its own RBAC surface) for what was really just a list of cards on one block — it has a handful of text fields (heading, body, link button) per card, no reason to be a separate collection, its own admin nav entry, or its own site-scoped access functions whenFeatureGridalready does the same "heading + array of cards" shape inline on the block itself. ConvertedCommitmentsGridfrom a marker block (Known Issue #23) into one with its ownitemsarray field (heading, body,ctaLabel,ctaHrefper item), removed theCommitmentscollection, its access-control functions, itsonInitseeding, and theuseCommitmentscomposable entirely. Zero data loss: the 6 live cards were fetched via the API first and re-saved into the Home page's newcommitmentsGrid.itemsarray before the collection was dropped, verified end-to-end by a fresh browser render of the homepage afterward. Hit Known Issue #10's interactive schema-push prompt twice, in two different shapes, doing this: (a) adding the newitemsarray field alone produced drizzle-kit's create-vs-rename disambiguation prompt for the new junction table (pages_blocks_commitments_grid_items) — resolved by staging the change (add the new field whileCommitmentsstill existed, so the new table was an unambiguous pure addition, confirmed viadocker logsshowing no prompt); (b) removing theCommitmentscollection afterward produced a different prompt, a plainprompts-library data-loss confirmation (You're about to delete commitments table with 6 items... Accept warnings and push schema to database? (y/N)) that silently defaults to declining when piped/non-interactive (confirmed viapsqlthat the table survived a piped "yes"). Since the data had already been preserved via the API, this one was resolved by bypassing Payload's push tooling entirely and runningDROP TABLE IF EXISTS commitments CASCADE;directly against the dev Postgres container — a new variant of the same root prompt-can't-be-scripted problem, worth recording since it isn't the rename-ambiguity prompt Known Issue #10 originally described.The header's
Menubutton was a bare<button>with no click handler and no nav markup behind it at all — on the homepage, clicking it did nothing, anywhere on the site, since the site-wide layout added in Known Issue #23 only ever rendered<button class="btn btn-outline" type="button">Menu</button>with no@click, no toggled state, no drawer/dropdown markup to show — purely decorative from the moment it was added. Fixed inthemes/polysite-theme-acme/layouts/default.vuewith amenuOpenref toggled on click (aria-expanded/aria-haspopup/aria-controlswired to match) and a dropdown<nav>listing the same pages already linked from the footer (Leader, Our Team, About Us, Our Platform, Take Action, Volunteer, FAQ, Become a member). Verified in a real browser: clicking Menu shows the 8 nav links, clicking it again hides them.Tailwind CSS v4's zero-config automatic content detection silently skips the theme layer, because it's a nested independent git repository — added Tailwind (already available via
@nuxt/ui's own dependency, see Theme Layer above) plus a site-level branding config (Sites' new "Branding" tab: logo, favicon, colors, header nav, footer columns, social links) and a full visual restyle of the homepage's blocks. Utility classes used only inthemes/polysite-theme-acme/layouts/default.vue(w-80,ml-auto,z-40,z-30, and any arbitrarybg-[var(...)]value) generated no CSS at all — confirmed for real by fetching the compiledmain.cssand searching for their selectors directly, not just eyeballing the page. Classes that happened to also be used elsewhere in the app (flex,h-full,p-6,rounded-full,sticky, ordinaryz-10/z-20/z-50from unrelated components) appeared to work, which was pure coincidence and nearly masked the real bug. Root cause: Tailwind v4's automatic source detection walks the project respecting every.gitignoreit finds and treats a nested.gitboundary the same way it treatsnode_modules— an exclusion zone — andthemes/polysite-theme-acmeis exactly that (its own independent git history, deliberately not tracked by the core repo; see its README). Fixed with an explicit@source "../../../../themes/polysite-theme-acme";directive inapps/web/assets/css/main.css, which brings the theme layer back into Tailwind's scan regardless of the automatic heuristic. Verified by fetching the compiled CSS again and confirming all four previously-missing selectors were present, then confirming the drawer/colors rendered correctly in a real browser.Migrated from hand-rolled site-scoping to
@payloadcms/plugin-multi-tenant— pulled forward from its original place after the first deployment (see Modules & Plugins above). KeptSites' existing slug/name and thesitefield name onPages/Mediainstead of the plugin's owntenants/tenantdefaults (tenantsSlug: "sites",tenantField: { name: "site" }) — this meant the frontend (usePayloadPage,useSite, everysite.domain/sitequery) needed zero changes; only the access-control internals changed, not the public data shape.Sites.tskeeps its own hand-written access control (useTenantsCollectionAccess: false) since it already had a carefully-reasoned policy (Known Issue #19/#22);Pages.ts/Media.tshad their manual{ site: { in: userSiteIds(user) } }Wherefilters removed entirely — the plugin ANDs that same constraint on automatically viauseTenantAccess(default), and admins skip it viauserHasAccessToAllTenants: (user) => isAdmin(user).Users.assignedSiteschanged from a hand-written flathasManyrelationship to the plugin's own array field (tenantsArrayField, same outer name kept) —userSiteIds()inaccess/roles.tsupdated for the new nested row shape (user.assignedSitesis now[{ site: <id> }], not[<id>]).Hit Known Issue #10's interactive schema-push prompt twice more, both new variants: (a)
users_assigned_sites(the plugin's new array-field table) triggered the same create-vs-rename disambiguation as Known Issue #26's first prompt — resolved by inspecting an already-clean sibling array table (sites_header_nav_items) to learn Payload's exact column convention (ida barecharacter varyingprimary key with no default,_order/_parent_idinteger, one column per row field) and creatingusers_assigned_sitesdirectly via SQL to match it exactly, after backing up the 2 real user→site assignments via the API first. Restarting then cleanly resolved that ambiguity (the table already existed matching expectations) and immediately hit (b): a data-loss confirmation for droppingusers_rels(Payload's old shared relationship-storage table, now orphaned since nothing references it through that mechanism anymore) — verified viapsqlthat its 3 rows were exactly the assignments already preserved, then dropped it directly, same bypass pattern as Known Issue #26's second prompt. Verified end-to-end afterward:GET /api/pages/1still returns"site":1untouched, the content editor's "Assigned Sites" array field renders "Dev Test Site" correctly in the admin (proving the SQL-inserted row round-trips cleanly through Payload's own UI, not just the raw table), the public frontend renders with zero changes needed, and a non-admin login still sees exactly their assigned site's pages.Per-site theme selection: multiple theme repos extended into one build, chosen dynamically by
Sites.themeat runtime — not a new container per theme — a deliberate decision after weighing the trade-off: a separate frontend deployment per theme (the closest analogue to how real multi-brand Nuxt/Next sites on a headless CMS are usually run) was rejected as infeasible to keep spinning up by hand, and "just recolor one shared layout" wasn't enough either, since a genuinely new client needs a structurally different design, not just different colors on the same header/footer shape — which per-site branding data (Known Issue #28) already covers on its own. Landed on the pattern WordPress Multisite uses conceptually (every theme's files present at once, a per-site setting picks which one renders) adapted to what a compiled Nuxt app can actually do: WordPress canrequirea different theme's PHP files fresh per request because it's interpreted with no build step; Nuxt can't do that, but it can merge multiple Nuxt layers into one build and pick between their named layouts at runtime, which is the mechanism actually used here.Sitesgains athemeselect field (acme/bakery, extensible).apps/web/nuxt.config.ts'sextendslists every theme repo simultaneously, not one per deployment. Each theme names its layout file after itself (layouts/acme.vue,layouts/bakery.vue) instead of Nuxt'sdefault.vueconvention — required, since two themes both usingdefault.vuewould collide and only one would win when merged into the same build.app.vueresolves which one renders per request:<NuxtLayout :name="site.theme || 'acme'">, driven by the sameuseSite()hostname resolution every other per-site feature already uses. Adding a new theme later means adding its repo path toextends(plus a DockerfileCOPYline and a dev-compose bind mount — see the Docker build implication note above) and redeployingweb; it never means a new container or Caddy route, since one running app already contains every theme's code.Two pieces of cleanup fell out of doing this for real, not just adding a second theme: (a)
pages/index.vueused to live inside the theme repo, but its logic was already 100% theme-agnostic (identical to[...slug].vue, separate only because of Known Issue #1's routing gap) — two themes both shipping apages/index.vuewould have collided on that file path the same waydefault.vuewould have, so it moved into the core app permanently, closing that latent trap before it could bite. (b) the branding-data wiring (colors/fonts/nav/footer/social-link fallbacks) that used to live inline in the one theme's layout got extracted into a shareduseSiteBranding()composable, since every theme needs it identically and duplicating it per theme risks the copies drifting.Hit one real bug building this, not caught until a second theme actually existed to exercise the shared-composable path: the first version of
useSiteBranding()wasasync, doingconst site = await useSite()internally before callinguseHead()— worked fine for the single existing theme (the exact sameawait-then-useHead()shape it was extracted from), but produced a real 500 ("A composable that requires access to the Nuxt instance was called outside of a plugin, Nuxt hook, Nuxt middleware, or Vue setup function") oncelayouts/bakery.vuecalled it. Root cause: Vue's async-setup()support restores a component's Nuxt-instance context automatically across anawait, but only for the setup function's own top-level await — not one buried inside a separate function that setup merely calls, even when that whole call is itself awaited. Fixed by makinguseSiteBranding()synchronous, taking the already-resolvedsiteref as a parameter instead of fetching it internally; each theme's layout now doesconst site = await useSite()directly in its own<script setup>(a real top-level await, the same proven-safe patternusePayloadPagealready used in[...slug].vue) and passes the ref into the now-synchronous helper. Verified end-to-end: both themes render simultaneously from the same runningwebcontainer (voidslate.localhost→acme's sticky pill-button header,bakery.localhost→bakery's centered masthead with a utility bar and wrapping inline nav), each with its own real per-site colors, and the original theme's Menu drawer still opens/closes correctly — confirmed via computed styles and DOM queries in a real browser, not just a visual glance.More per-site theme settings (tagline, hide-the-Donate-button) plus Custom CSS/JS fields — and a real finding about this app's actual CSP, not the one its own comments assumed — WordPress-style theme management was an explicit requirement (settings + file editing), which prompted a direct comparison: WordPress's Theme File Editor writes PHP that executes on the server with no compile step, and is widely recommended to disable for exactly that reason (
DISALLOW_FILE_EDIT) — there's no safe equivalent for a compiled Nuxt app, so that half was declined in favor of two lower-risk, still-genuinely-useful pieces: more granularSitessettings (tagline,showDonateButton, both wired throughuseSiteBranding()into both themes' layouts), and rawcustomCss/customJstextareas injected site-wide fromapp.vue.customCssis low-risk (CSS can't execute server-side code).customJsis not — it genuinely executes in every visitor's browser, so it's admin-only by Sites' own existing access control and only as safe as that access control, full stop; if it were ever loosened to Content Editor/Campaign Manager, this becomes a real stored-XSS vector, not something CSP additionally catches (see why, below).Assumed initially, then checked for real rather than left as an assumption: that
nuxt-security's default strict-dynamic CSP would auto-attach its nonce touseHead()-injected inline<script>/<style>tags during SSR, the way its own docs describe for "SSR mode." Confirmed false for this specific injection path — fetched the actualContent-Security-Policyresponse header (curl -I) and inspected the rendered DOM: the injected tags carry nononceattribute at all. What's actually permitting them to run is'unsafe-inline', already present in this app's CSP (script-src 'self' https: 'unsafe-inline' 'strict-dynamic' 'nonce-...') as a pre-existing fallback, unrelated to and unchanged by this feature — not nonce-gating. Corrected the code comment that had claimed otherwise before it could mislead a future session into overstating this field's actual security posture. No CSP config change was needed either way, since'unsafe-inline'was already there — but why it works turned out to be different from the first (unverified) assumption, worth recording precisely rather than leaving the more reassuring-sounding wrong explanation in place.Verified end-to-end in a real browser:
customJsexecuting (aconsole.logmarker appeared, confirmed viaread_console_messages),customCssapplying (computed style changes),showDonateButton: falsecorrectly removing the button from the DOM (not just hiding it visually) on the site it was set on while the other site's button rendered normally (field unset, defaulttrue), and thetaglinerendering next to the wordmark on both themes' layouts.WordPress-style theme auto-discovery — the
Sites.themedropdown andnuxt.config.ts'sextendsarray both scanthemes/at boot instead of a hand-maintained list — Known Issue #30 shippedthemeas a hardcoded Payloadselectwith two literal options; a hand-maintained list, even one moved into its own file, is not auto-discovery, just tidier hardcoding. What they actually wanted, stated explicitly: scan the theme directory, find a specific marker file, list every valid theme, the way WordPress does withstyle.css's header block.The real obstacle:
cmsandwebare separate containers, andthemes/was previously mounted into neither the CMS's build context nor its runtime container at all — the CMS had zero filesystem visibility into theme files before this. Fixed by wideningcms's Docker build context from./apps/cmsto the repo root (mirroringweb's existing pattern) and mountingthemes/read-only into the dev container. A directorythemes/polysite-theme-<value>/now counts as a valid, listable theme when it has bothlayouts/<value>.vue(proves it's actually wired to render — the same naming convention Known Issue #30 already established) and atheme.jsonwith alabel(the WordPressstyle.css-header equivalent; a raw directory name isn't a presentable label on its own).apps/cms/src/collections/Sites.ts'sdiscoverThemes()andapps/web/nuxt.config.ts'sdiscoverThemePaths()run the identical scan independently — not shared code, sinceapps/cmsandapps/webare separate Next.js/Nuxt projects with no monorepo package-linking set up, and forcing a literal cross-app import risks the same class of build-boundary failure this project has hit before (Known Issue #9 and others) for a problem ~25 lines of duplicatedfs/pathcode solves at near-zero risk.A real bug caught during implementation, before it shipped: the CMS Dockerfile's
runstage originally kept its pre-existing flatWORKDIR /appstructure while thebuildstage moved to/repo/apps/cms—Sites.tsresolves the scan directory viapath.resolve(process.cwd(), "../../themes"), so therunstage's shallower path would have silently resolved to/themesinstead of/repo/themes, missed every real theme, and fallen back to the hardcoded single-theme default with no visible error. Fixed by keeping/repo/apps/cmsas therunstage'sWORKDIRtoo, matching thebuildstage exactly.Also simplified the Dockerfiles/compose files from one
COPY/bind-mount line per theme to a singleCOPY themes themes/./themes:/repo/themescovering the whole directory — a real consequence of moving to scanning: per-theme infra lines would have meant the scan finding a theme that Docker never actually shipped into the image, a worse failure mode than today's explicit per-theme lines. Confirmed nested theme repos' own.gitdirectories are excluded from the image by the existing root.dockerignore's bare.gitpattern (matches at any depth, same semantics as.gitignore) before relying on it.Verified end-to-end in a real browser after a full rebuild of both containers: the CMS admin's Theme field description itself now reads "Auto-discovered from themes/ at boot (2 found: bakery, acme)", the dropdown shows both correctly-labeled options ("ACME-style", "BAKERY-style") read live from each theme's
theme.json, and both sites' frontends still render exactly as before (real per-site colors, ACME's Menu button, BAKERY's tagline) — a pure refactor of how the lists are built, zero functional regression."acme"was hardcoded as the fallback value in three places that should have been generic — found the literal stringacmestill baked into the core apps and asked why it wasn't detachable. All three were fallbacks for the same "found nothing to discover" edge case Known Issue #32's scan already had to handle:apps/cms/src/collections/Sites.ts'sdiscoverThemes()returned[{ label: "ACME-style", value: "acme" }](twice — thethemes/unreadable case and the zero-valid-themes case) and used it again indefaultValue: THEMES[0]?.value ?? "acme";apps/web/nuxt.config.ts'sdiscoverThemePaths()returned["../../themes/polysite-theme-acme"]in the equivalent two spots;apps/web/app.vuepickedsite.theme || "acme"as the rendered layout name. None of these are hypothetical — a fresh clone withthemes/not yet populated, or a Site record with nothemeset, would silently render as one particular client's theme rather than failing loudly or falling back to something neutral.The real fix wasn't a find-and-replace to a different literal — swapping in some other placeholder string would just move the same problem, since Nuxt's
<NuxtLayout :name="...">needs an actual matching layout to exist for whatever name it's given, and no such fallback layout existed. Addedapps/web/layouts/default.vue: a genuinely functional, unbranded layout owned by the core app itself (notthemes/, not any specific client's repo) — reuses the existinguseSiteBranding()composable so per-Site colors/nav/footer/social links still apply even with no theme selected, but with no structural opinion (drawer, masthead, sticky header) any real client theme would have. Nuxt auto-registers anylayouts/default.vueas the layout literally named"default", so noextendswiring was needed for it to exist regardless of which theme repos are present. With that in place, every fallback above changed from a specific client's name to"default":Sites.ts's two array fallbacks and itsdefaultValue,nuxt.config.ts's twoextends-array fallbacks (now correctly returning[]rather than forcing a nonexistent-in-a-fresh-clone theme path — an emptyextendsis fine now thatlayouts/default.vuecovers the no-theme case on its own), andapp.vue'ssite.theme || "default".One more real, functional bug found and fixed while auditing every remaining
acmereference for this:apps/web/assets/css/main.css's Tailwind@sourcedirective (added in Known Issue #28, before a second theme existed) only pointed atthemes/polysite-theme-acmeby name —polysite-theme-bakery(added in Known Issue #30) was never actually being scanned for Tailwind utility classes, the exact same silently-missing-CSS failure mode #28 first diagnosed, just not yet hit because bakery's classes happened to overlap enough with already-scanned ones to "work" by coincidence. Fixed by widening the directive to a glob,@source "../../../../themes/polysite-theme-*";, covering every theme directory instead of one hardcoded name.Verified end-to-end in a real browser after restarting
cms/web/caddy: both existing Sites (acme,bakery) kept their explicit theme values and render unchanged (ACME's pill-button header, BAKERY's masthead, both fully styled — confirming the@sourceglob fix didn't regress either theme); the CMS admin's Theme field description still correctly reads "Auto-discovered from themes/ at boot (2 found: bakery, acme)" with noacmestring hardcoded into that output itself, only genuinely discovered.RBAC replaced with a fully dynamic system — roles are data, not a hardcoded enum — a deliberate design decision: Polysite is being built as a bigger, productized platform, and new collections will keep getting added over its life, so who can do what shouldn't require a code deploy every time. Landed on a genuine
rolescollection (apps/cms/src/collections/Roles.ts) a Super Admin creates/renames/reconfigures entirely through the admin UI. Two deliberate design choices came out of the discussion, both correcting an earlier draft: (a) general operations, not per-collection permissions — a role has one flatoperationsset (create/read/update/delete/publish/unpublish/cloneAcrossSites) applied uniformly to whichevermodules(collections) it's given, rather than a separate permission grid per collection; a future collection (Events, etc.) only ever needs adding toapps/cms/src/rbac/modules.tsand to an existing role'smoduleslist — no new permission definitions, ever. (b) the first two 4-role drafts were rejected — Content Manager and Content Creator, initially proposed with different capabilities, were merged into one Site User role after it was pointed out (with a concrete example: "content creator can also create forms, and read form submissions") that the drafted split didn't reflect a real distinction. Final set is 3 roles: Super Admin (see below), Site Admin (full content operations +manageSiteSettings: true— can edit its own assigned Site's branding/theme/domain/custom CSS-JS), Site User (identical to Site Admin minusmanageSiteSettings).One deliberately non-dynamic piece:
Users.isSuperAdmin, a plain boolean, checked before any role lookup at all. The aim was zero hardcoding anywhere, but this one exception should stay: if Super Admin access were itself governed by the same dynamicroleslookup, a corrupted or misconfigured role document could lock every admin out of the one collection that could fix it, or be used to self-escalate. Same pattern as WordPress Multisite's network Super Admin list (stored separately from per-site roles/capabilities) or AWS's root account sitting outside IAM policy evaluation entirely — not a role, an escape hatch. The "Super Admin" role document still exists (pre-seeded,isDefaultDeployed: true) for UI/assignment purposes, but it grants nothing by itself; only the flag does.A capability check (
hasCapability(user, moduleSlug, operation)inapps/cms/src/access/roles.ts) runs on every request, so it reads from an in-memory cache (apps/cms/src/rbac/permissionCache.ts) rather than queryingroleseach time — populated once at boot (onInitinpayload.config.ts) and invalidated live byRoles.ts's ownafterChange/afterDeletehooks, so a Super Admin editing a role's permissions takes effect on the very next request, no restart. Default-deny at every branch, on purpose — a missing role, an unlisted module, or an unchecked operation all resolve tofalse, the same discipline Known Issue #19's real gap (an undefined access function defaulting to Payload's own() => true) already forced onto this codebase once; verified for real by editing "Site User"'s live role to droppagesfrom its module list and confirming Pages write access was genuinely rejected, immediately, with zero restart, then restoring it.Three collateral pieces landed alongside the core system: (a) a per-Site
enabledModulesfield onSites.ts— data-only for now (Pages/Media/Forms aren't independently routable, so there's nothing yet for the frontend to gate; becomes load-bearing once a second standalone-routable content type exists), distinct from a role's own module access (a role can touch Forms; whether a given site has Forms turned on is separate). (b) Hiding@payloadcms/plugin-multi-tenant's "Filter by Tenant" selector from every non-Super-Admin role ("no traces of it" was the requirement) — the plugin's config has no per-role visibility option at all (confirmed by reading its source,dist/index.js: it pushesTenantSelectorintoadmin.components.beforeNavunconditionally at config-build time, before any request/user context exists), sopayload.config.tsruns a small plugin aftermultiTenantPluginthat swaps that onebeforeNaventry forapps/cms/src/components/TenantSelectorGate.tsx, which renders the real selector only whenisSuperAdminis true. (c) A cross-site Page clone action (POST /api/pages/:id/clone, a custom Payload endpoint onPages.ts, triggered from a "Clone to another site" item in the sameeditMenuItems3-dot-menu slot the multi-tenant plugin uses for its own "Assign Tenant" action) — available to Site User and above, server-side enforced (capability + target-site membership checked in the endpoint itself, never trusting the client), always lands as a draft on the destination site regardless of the source's status (standard content-staging safety default).Three real bugs hit and fixed while building this, all confirmed rather than assumed: (1) both new admin components (
TenantSelectorGate,CloneToSiteButton) neededpayload generate:importmapplus a full container restart before Payload's admin would render them — the exact same failure mode as Known Issues #13/#18 (getFromImportMap: PayloadComponent not found), hit again here because it's a structural limitation of Payload's admin, not something those earlier fixes could have prevented for future components. (2) The clone endpoint's first version copied a source Page'scontentblocks verbatim, including their ownids — Payload's Postgres adapter stores each block type in its own table withidas a real, globally-unique-within-that-table primary key, not scoped per parent document, so the create failed with"Value must be unique"onid. Fixed with a recursivestripIds()that drops every nestedidbefore the clone'screatecall, letting Payload assign fresh ones — same as if an editor had built the page from scratch. (3) The clone trigger's first version tried to expand an inline site-picker form directly inside theeditMenuItemspopup slot — Payload's own popup menu closes on any click inside it, so the inline expansion never got a chance to render, the same reason@payloadcms/plugin-multi-tenant's own "Assign Tenant" action is a modal, not an inline expansion, in its own source. Fixed by rendering the picker via a React portal straight intodocument.body, decoupled from the popup's lifecycle.The migration itself hit Known Issue #10's interactive schema-push prompt again (a new
rolestable, a new enum, andusers.rolechanging from aselectto arelationshipall needed confirmation) — this time resolved deliberately rather than by guessing, since real dev content (a client's actual brand colors, demo Pages) was at stake: backed up viapg_dumpfirst, then wiped the dev schema clean (DROP SCHEMA public CASCADE) by decision, letting the push run against nothing-to-disambiguate, then recreated the 3 dev accounts/2 sites/2 homepages via a new idempotent seed script (apps/cms/scripts/seedDevData.ts) rather than hand-restoring from the dump. A real bug caught writing that seed script, not the migration itself:payload run <script>awaits the script module's own dynamicimport(), and a fire-and-forgetrun().catch(...)at the bottom (no top-levelawait) let thatimport()resolve immediately — the whole process exited viapayload run's own wrapper before any of the script'sasyncbody had actually executed, silently producing zero output and zero data. Fixed by making the script's top-level body itselfawait-driven, keeping the module's own evaluation (and therefore theimport()promise) pending until the seed genuinely finished.Verified end-to-end in a real browser as all three roles (the 3 existing dev accounts remapped:
admin@dev.local→ Super Admin,campaignmanager@dev.localhost→ Site Admin,contenteditor@dev.localhost→ Site User): Super Admin sees the tenant selector and the Roles collection with full write access, Site Admin can edit its assigned Site's settings and neither sees any multi-tenant UI, Site User can create/publish/unpublish Pages but gets no Save button at all on Sites, a live role edit (removing then restoring a module) took effect instantly with no restart in either direction, and a real Page clone from Dev Test Site to BAKERY Demo Site landed correctly as a fresh-id'd draft with a disambiguated slug.Payload's default admin sidebar replaced with a real custom Nav, built from an approved mockup — the stock admin chrome (a bare
<select>for the multi-tenant switcher, a flat unstyled collections list, no branding, a disconnected avatar/logout pair) went through several rounds of design iteration against WordPress/Drupal/modern-SaaS (Slack, Notion, Linear) reference patterns on a published mockup (a Design-canvas Artifact, two interactive artboards) before being turned into realadmin.componentscode — not guessed at: every piece below was confirmed by reading Payload 3.90.2's actual installed source inside the running container first.admin.components.Nav(apps/cms/src/components/AdminNav/) fully replacesDefaultNavvia Payload's ownFallbackmechanism — which also meansbeforeNav/afterNav/logout.Buttonstop rendering entirely, since those only ever fired from insideDefaultNavitself (confirmed by reading@payloadcms/next's actual source, not assumed). Their functionality was folded directly into the new tree instead of kept alive through those now-dead config keys: the site switcher is inlined (SiteSwitcher.tsx, gated to Super Admin only — no moreTenantSelectorGate.tsxbeforeNav-swap hack, which was deleted along with thehideTenantSelectorFromNonSuperAdminsplugin function that rewrote it), and the real logout link is inlined intoAccountMenu.tsx(the exact mechanism from the now-deletedLogoutButton.tsx, ported verbatim).admin.components.actions(confirmed via@payloadcms/next'sgetRouteData.jsto render inAppHeader's top-right) carries the search popup and a create-new picker, matching where the approved mockup put them — not in the sidebar.Deliberately reused rather than rebuilt, once the real API was confirmed:
groupNavItems()(exported from@payloadcms/ui/shared) does the exact grouping and per-role filtering the mockup called for — it already respects each collection's realpermissions.read(itself derived fromhasCapability/isSuperAdmin, Known Issue #34) and anadmin.groupfield per collection, so every relevant collection (Pages,Media,Users,Roles,Sites, plusformOverrides/formSubmissionOverrideson the form-builder plugin) gotadmin.group: "Content"or"Administration"instead of a second, hand-rolled capability check that could drift from the real one. The site switcher drives Payload's actual tenant-selection state viauseTenantSelection()(a public hook the multi-tenant plugin exports from@payloadcms/plugin-multi-tenant/client) — picking a site here genuinely re-scopes list views, verified by switching and watching thePageslist actually change, not a decorative reskin of the plugin's ownTenantSelector.Genuinely new, not duplicating anything Payload ships: the Cmd-K-style search popup (confirmed via grepping the installed package for
command/kbar/etc. that no built-in command palette exists at all) — real on-type REST search against Pages/Media/Users, not placeholder UI. (A desktop icon-only collapsible rail was also built here, persisted throughusePreferences(), hover tooltips and all — then removed again by explicit decision after landing: it meant two different hamburger-shaped controls existed across viewports, which read as confusing rather than as a feature. Payload's own single mobile show/hide toggle is the only show/hide control now, at every width; see Known Issue #37 for the real bugs hit building and un-building it.)A real, non-obvious bug found and fixed along the way: Payload's own
NavProvideronly restoresnavOpenfrom a stored preference above amax-width: 1440pxmedia query (confirmed by reading@payloadcms/ui'sRootprovider source, not assumed) — meaning on any ordinary laptop viewport (≤1440px, including the 1280px/1366px range most real users are on), the stock sidebar starts closed after every navigation with nothing to reopen it except manually clicking the hamburger every time. That's genuine, pre-existing Payload behavior, not something introduced here, but it's a real usability gap worth fixing deliberately rather than faithfully inheriting. Two more real bugs surfaced specifically while fixing that: (1)@payloadcms/ui's ownuseWindowInfo()hook threwCannot read properties of undefinedwhen imported directly into a custom component — not chased further given a plainwindow.innerWidthresize listener is just as correct with no such dependency; (2) a same-tick, then a deferred-one-tick (setTimeout(…, 0)), fix to forcenavOpenopen on wide screens both racedNavProvider's own hydration effect and lost unpredictably — and even once winning that race, a third, easy-to-miss bug remained: forcing this component's own visibility open wasn't enough, because Payload's content-area wrapper (entirely outside this component, reading the same sharednavOpenfromuseNav()) still applied zero left margin whenever the shared state was false, so the sidebar was genuinely rendering at full width underneath while the page content visually overlapped it. Fixed by actually flipping the sharedsetNavOpen(true)(not just this component's own local decision) after a 150ms delay — comfortably past any ofNavProvider's own async breakpoint-matching, imperceptible during a real page load — while keeping a locally-trackedisWideScreen(plain resize listener) as the source of truth for which of two render branches (in-flow sidebar vs. a real overlay drawer with a backdrop) this component itself takes.Verified end-to-end in a real browser, all three roles: nav shows the correct grouped, capability-filtered items per role (re-confirming
groupNavItems()is wired to real permissions, not a hardcoded list — though the filtering itself had a real gap at the time, fixed later in Known Issue #36); the site switcher is visible and functional only for Super Admin, completely absent for Site Admin/Site User; the account menu's logout link actually ends the session (lands back on/admin/login, confirmed by driving the real click path, not just checking the href); search returns real matching documents; and the mobile-width drawer (a real overlay with a dimmed backdrop) renders, distinct from the desktop in-flow sidebar.Two follow-up rounds after the above shipped, both found by comparing the live admin against the mockup directly rather than trusting a clean build: (1) the force-open fix (the 150ms-delayed
setNavOpen(true)above) initially had no "already corrected" guard — reported exactly as it looked, "on click it closes, then as soon as I release it comes back open" — because the effect re-armed on everynavOpenchange, including a legitimate user-initiated close, and fired again ~150ms later. Fixed with auseRef(false)flag set inside the effect so the correction applies exactly once per mount, never fighting a click afterward. (2) the mockup-to-code port had silently swapped the mockup's actual brand accent (#7c7ce0, a fixed indigo identity color) for Payload's generic--theme-success-500token everywhere it appeared — the K logo, active-nav state, the account avatar, the role label, and the "Create new" button — plus had dropped the mockup's hover-triggered info tooltip on the site switcher's "SITE" label and its per-row hover feedback, and rendered the Content/Administration groups in the wrong order (groupNavItems()sorts group labels alphabetically, which put "Administration" first). None of these were caught by the "verified end-to-end" pass above because that pass checked behavior (permissions, logout, search) rather than a literal pixel/color comparison against the approved mockup. Fixed by hardcoding the mockup's exact accent values (not a new--theme-*token, since this is a fixed brand color, not a theme-reactive one) acrossNavClient.tsx/SiteSwitcher.tsx/AccountMenu.tsx/CreateActionClient.tsx, adding the missing tooltip and hover states, and sortinggroupNavItems()'s output against an explicitGROUP_ORDERarray rather than trusting its default alphabetical order. Re-verified live at the mockup's own 1280px frame width.UsersandRoleswere never actually wired into the dynamic RBAC matrix — a Site User could see and query both collections —Users.tsdefinedcreate/update/deletebut noreadkey at all, which Payload defaults to its own built-in() => true(the exact same defect class as Known Issue #19, on a different collection);Roles.ts'sreadwasBoolean(user)— any authenticated user, no capability check, with an inline comment claiming this was needed forhasCapability/canManageSiteSettingsto resolve a user's own role that didn't actually hold (both read fromrbac/permissionCache.ts's in-memory cache, populated viaoverrideAccess: true, never through this access-controlled path). Payload's nav-visibility logic treats any non-falseaccess.readresult as "show this in nav," so every logged-in account saw the full Administration group regardless of role.Fixed by requiring
isSuperAdminfor both, with a narrow exception letting a user read their own document/role byidonly (Payload's admin shell needs this internally; list and nav-probe calls pass noid, so the exception never reopens list/nav visibility for a non-admin). Two real bugs hit landing this, both caught only by testing against actual non-admin logins rather than reasoning about the access functions in isolation: (1) a Postgres id type mismatch —user.idarrives as a number but the access function's ownidparam as a string for the same record, so a naiveid === user.idself-check silently evaluatesfalse; fixed by comparingString(id) === String(user.id). (2)Sites' own scoped read access (any authenticated user, filtered to their assigned sites — Known Issue #19/#22's fix) looked like it should be tightened tocanManageSiteSettingsthe same way, since a Site User seeing "Sites" in nav with no write access at all looked like the same bug — but restricting it broke the entire admin root layout for every non-managing role with a hard 500 "Forbidden," confirmed by a real Site Admin/Site User login, not assumed: the multi-tenant plugin's own tenant resolution depends on every assigned user (not just ones withmanageSiteSettings) being able to read their own Sites record. Reverted that part and usedadmin.hidden: ({ user }) => !isSuperAdmin(user) && !canManageSiteSettings(user)instead — a collection's real data-access scope and its nav visibility are two different concerns, and conflating them cost a full outage to learn here. See CONTRIBUTING.md's access-control notes for the resulting pattern.Verified against all three real dev accounts: Super Admin still sees Users/Roles/Sites in full; Site Admin sees Sites only (needed for its own
manageSiteSettingssite-settings page), no Users/Roles; Site User sees neither Users, Roles, nor Sites — Administration group doesn't render for that role at all, confirmed in a real browser rather than just by reading the access functions.The mobile admin nav drawer had three separate, real bugs, one of them introduced by the fix for another — reported across several real-phone sessions, not caught by desktop-only testing (the desktop sidebar is always in-flow, never a drawer, so none of these could have surfaced there). (1) Tapping anywhere inside the open drawer closed it instead of navigating — the full-screen backdrop (
z-index: 99, closes the drawer on click) stacked visually above.navitself, since Payload's own.navsets no explicit z-index (auto) and any sibling with an explicit z-index wins the stacking order regardless of DOM order; confirmed viaelementFromPointover a real nav link returning the backdrop div, not the link underneath. (2) The first fix for that (zIndex: 100on the<aside>, unconditionally) broke every click on the entire page, not just the drawer — Payload renders.navat full viewport size even in its closed mobile state (hidden viaopacity: 0, notdisplay: noneor an off-screen transform), and it previously lost the paint/hit-test order to the header's own controls (hamburger, "Create new", search) purely by DOM order, both atz-index: auto. An unconditional100made that invisible-but-full-screen closed state win that order too, silently intercepting every click on the page — confirmed the same way,elementFromPointover the hamburger landing on the nav instead. Fixed by making the elevation conditional on the drawer actually being open (visuallyOpen ? 100 : undefined). (3) There was no way to close the drawer at all once either of the above was fixed — Payload's own "open" styling renders the drawer at full viewport width/height with no backdrop strip left to tap and the hamburger that opened it hidden underneath the drawer itself. Fixed with a plain close (X) button inside the drawer's own header, visible only on mobile, calling the samesetNavOpen(false)the backdrop/hamburger already used — not something to infer was missing from either of the first two fixes alone, since each looked complete in isolation until driven on an actual phone.A second, independent finding surfaced in the same pass: Payload renders two native nav-toggler buttons in markup, not one (
.template-default__nav-togglerand.app-header__mobile-nav-toggler, both sharing the base class.nav-toggler) — relevant here because an earlier attempt at a custom desktop collapse toggle (see Known Issue #35's note) tried to suppress Payload's own mobile toggler by its specific modifier class and only hid one of the two, leaving the other visible. Also removed Payload's own top-right account avatar (.app-header__account) from mobile specifically, since it duplicates the account/logout row already in the drawer's own footer and the two together were redundant in that much tighter layout — kept on desktop, where there's no such crowding.Setting
serverURL(for absolute password-reset links) broke every public-site image. Payload builds upload URLs fromserverURL, somedia.urlwent from/api/media/file/x.webptohttp://polysite.localhost/api/media/file/x.webp. The public site is a different origin withimg-src 'self' data:, so the browser blocked all of them (console: "violates the following Content Security Policy directive";curlof the same URL returned 200, which is why it only showed up in a real browser). Fixed at the source:Mediahas anafterReadhook (utils/relativeMediaUrls.ts, unit-tested) that strips the origin fromurl,thumbnailURLand everysizes.*.url, so consumers get a path that resolves through the site's own/api/*Caddy route (the single-origin design), the admin, and Live Preview alike. Any new upload-style collection needs the same hook. Verified: hero image loads on the public site, admin Media thumbnails still load.Pages admin: confusing titles, wrong preview links, a broken Duplicate, and no row actions. (a)
useAsTitlewas a storedadminTitlecolumn ("Home — // (Site)": the template prefixed/onto the homepage's/slug) which also made the editor heading look like a read-only label. Removed:useAsTitleis plaintitleagain and the column was dropped by hand (alter table pages drop column admin_title) before the restart to avoid the interactive schema-push prompt (Known Issue #10). Trade-off: relationship pickers (Redirects, Forms) show only the title again, so two sites' "Home" look identical there. (b) The SEO plugin's Preview showed a barehttps://...(nogenerateURL) and Live Preview always used the globalSITE_DOMAIN; both now usepageUrlFromDoc()(utils/pageUrl.ts), which looks up the page's own Site domain. (c) Duplicate returned 400 (POST /api/pages/:id/duplicate→ "The following field is invalid: Slug") because a copy kept its slug anduniquePerSiterejected it.beforeDuplicatehooks now give the copy the next free<slug>-copy[-N]on the same site (nextFreeCopySlug,/→home-copy), a "(Copy)" title, anddraftstatus. (d) Payload has no per-row list actions, so Pages has arowActionsuifield whose Cell (components/PageRowActions.tsx) is a three-dot menu: Edit, Duplicate, Clone to another site (the modal was extracted toCloneToSiteModal.tsxand is shared with the edit view), Delete. Items are hidden byuseAuthpermissions as a courtesy only; the API still enforces access. (e)frontLinkis a computeduifield (components/FrontLink.tsx: the site domain + slug as a link in the list and editor), deliberately not a stored text column: a typed value duplicatesslug+ site, goes stale on every slug change, and arequiredcolumn on existing rows made the dev schema push block on an interactive prompt that hung the CMS boot. The list loads only visible columns' fields, soforceSelect: { site, slug }is what makessitereach the cell. Verified in a real browser as the Site User: duplicate (toast + redirect), delete, clone modal, front-end links.Theme blocks: two Nuxt gotchas, and why blocks live in themes at all. Blocks used to be hard-coded in the core
BlockRenderer, so a theme could change the header and footer and nothing in the page body. They now resolve per site theme (utils/resolveBlock.ts): theme, then itsparent, then a plain core fallback. Two traps found building it. (a)import * as x from "#components"is rewritten away by Nuxt's own transform for that alias, so the page 500s with "x is not defined"; and a dynamicawait import("#components")fails on the server with "Missing #components specifier". Components are looked up by name instead, which needscomponents: { global: true }so they are in the Vue app context. (b) Givingcomponentsan object withoutdirssilently drops the default~/componentsdirectory, soBlockRendererstopped resolving ("Failed to resolve component");dirs: ["~/components"]is required alongsideglobal. Every theme is bundled into one build, so theme components are namespaced by folder (components/starter/HeroBlock.vueisStarterHeroBlock) so two themes never collide. Verified by overriding the hero in one theme only and confirming the other site was unchanged.The production migration was missing everything built after September 19th, so a production boot returned 500s. Found by building the real production images (
--target run), booting the CMS on an empty database, and calling the API: the two checked-in migrations ran, then/api/sites,/api/pagesand the admin login failed withrelation "payload_jobs" does not existand similar. Plugin tables, notifications, the roles collection, theme and branding fields and more had only ever been created in dev by schema push. Fix: since nothing had ever been deployed, the two old migrations were replaced with one baseline,production_schema, generated from the current config. With no earlier snapshot to compare against, the generator has nothing to ask about renames, so it ran non-interactively (the Known Issue #10 prompt appeared only when diffing against the old snapshot, forenum_roles_modulesvsenum_users_role). Verified on an empty database: the migration applies, roles are seeded, every collection API answers (403 for anonymous, never 500), and the first user, a site and a page with blocks can be created and read back with the same query the web app uses. From here every schema change needs its own migration on top of this one, and the old two (initial,commitments) no longer exist. (Superseded by #64: before launch this baseline was regenerated ascore_baseline, which holds core tables only, and each plugin ships its own migrations.)docker compose up --buildand the production compose file built the dev server, not the production image. Both Dockerfiles end with thedevstage, so abuild:block with notargetuses it. The "production-parity" stack therefore rannext devandnuxt devand never ranpayload migrate, anddocker-compose.prod.ymlwould have deployed dev servers (its CMS build also still pointed at./apps/cms, which cannot seethemes/orplugins/, so it could not have built at all). Found while running a clean install for the first-run page: the CMS log said "Next.js (Turbopack)" and no migration ran. Fix:target: runon the web and CMS builds in both compose files, and the prod CMS build now uses the repo-root context.docker-compose.dev.ymlstill setstarget: devitself. Re-verified with a clean project (-p, its own volumes and ports): the migration applies,next startand the built Nuxt server run, and the setup page works end to end.First-run setup page. An empty database used to show Payload's bare create-first-user form, which could not work (
roleis required, and a Super Admin is meant to hold the Super Admin role).admin.components.views.createFirstUsernow renderscomponents/Setup: administrator email and password, plus the first site (name, domain, theme, optional starter home page).POST /api/setup(setup/) validates, then creates the user (isSuperAdminand the seeded Super Admin role), the site and the page in one transaction, and refuses with 409 as soon as any user exists. Payload only routes to this view while no user exists. OptionalPOLYSITE_SETUP_TOKENis required by both the page and, through abeforeValidateguard on Users, Payload's ownfirst-registerendpoint, so the token can't be bypassed. Not covered: two simultaneous submissions from different processes (a single-process flag handles one VM). Verified on a clean production-mode stack: validation errors, a successful setup landing on the dashboard, the public site rendering the starter home page, setup and first-register both refused afterwards, and the token cases (missing and wrong give 403, right gives 200).No backups, and uploaded media lived inside the container. Two gaps found together. (a) Nothing backed up the database or the uploads. (b)
Media.upload.staticDiris the relative pathmedia, i.e./repo/apps/cms/mediain the production image, and neither compose file mounted a volume there, so every redeploy or container recreation would have deleted every upload (dev hid this behind its bind mount). Fixes: amedia-datanamed volume on the CMS, and abackupservice (backup, seedocs/guide/backups.md): nightlypg_dump(custom format) plus a media tarball in one archive with a manifest (checksums, row counts), optional AES-256 encryption, retention (7 daily / 4 weekly Sundays / 6 monthly firsts, newest never pruned), optional off-site copy through rclone, an optional monitoring ping, a Docker health state that goes unhealthy after 36 hours without a success, and a weekly job that restores the latest backup into a scratch database and compares row counts. Restore isdocker compose --profile restore run --rm restore latest --yes; it checks checksums first, saves the current database topre-restore-*.dump, and restores in one transaction. Traps found by testing: a Postgres 17pg_dumpwritesSET transaction_timeout, which a 16 server rejects, so the client image takesPG_MAJOR(default 16) and must match the server;apps/cms/mediawas being baked into the CMS image through the build context (a new named volume is pre-filled from the image on first use), so it is now in.dockerignore, along withbackupsandinternal. Verified end to end on a clean production-mode stack: upload a file, back up (the archive contains it), recreate the CMS container (upload still served), wipe the pages, sites, media rows and the file, restore from the compose profile, and the content, the file and the public site all return. Also covered: encrypted and wrong-passphrase archives, a flipped byte being refused by the checksum, restore refusing without--yes, the off-site copy, the cron schedule inside the container, and the retention policy (tests/backup/prune.test.sh). Not covered: restoring into Cloud SQL itself (the commands are the same, but run it once against a staging instance before relying on it).Sites.themewas aselect, which Payload stores as a Postgres enum, so every new theme folder would have needed a database migration. That also made the generated baseline migration bake in the theme names of whichever machine generated it, and default new sites to the alphabetically first one. The field is now plain text with a server-sidevalidateagainst the discovered theme list, a default ofstarterwhen it is installed, and aThemeSelectadmin component for the menu (clientPropscarries the options). Adding a theme is now "drop in the folder and restart", as documented. Verified: an unknown theme is rejected with a clear message, known themes save, the edit page shows the dropdown with the current theme, the dev column was converted in place (ALTER COLUMN ... TYPE varchar,DROP TYPE enum_sites_theme) before restarting so schema push had nothing to disambiguate, and the baseline migration was regenerated without prompts.A fresh clone could not build the CMS image, and CI could not build either image. Found by building the cleaned tree the way a stranger would (clone, add the themes and plugins repositories,
docker compose up --build). (a)payload-types.tsis generated and git-ignored, andnext buildtype-checks against it; without it the build failed withProperty 'name' is missing in type 'JsonObject & TypeWithID'. The earlier builds only passed because the generated file happened to exist in the working tree. The Dockerfile now runspayload generate:typesbeforenext build. (b) Both image builds copythemes/andplugins/, which are separate repositories and git-ignored here, so a bare clone failed atCOPY themes themes. The quick start now clones them, and CI fetches them before building; CI also built the wrong things (the web job used the Dockerfile's last stage, the dev server, and the CMS job used a build context that could not see those folders), so both jobs now use--target runwith the repository root as context. Verified on a clean copy: the stack builds from.env.examplealone, migrates, shows the setup page, and a site created through setup renders with the starter theme.Forms rendered but could not submit, and the submission endpoint was wide open. The sign-up block never sent anything and the form-embed block was a placeholder (R-05). Behind that sat a worse problem: the form-builder plugin's
form-submissionscreate route was public by design and stores whatever it is sent (its own source has a TODO for validating against the form), forms and submissions had nosite, so any site's editors could read every site's submitted personal data, and any visitor could post to any form. Fix: forms and submissions are now multi-tenant collections (asiteeach), the public create route is closed, and visitors useGET/POST /api/form-intake/:id(apps/cms/src/forms/). The GET serves a sanitised field list (never the email settings) and a token; the POST requires the form to belong to the host the request came in on, drops unknown fields, validates required/email/number/date/select and length server-side, and takessitefrom the form, never from the visitor. Spam protection is three cheap layers: an off-screen honeypot (answered with a fake success), an HMAC-signed token that must be 1.5 seconds to 6 hours old, and an in-memory rate limit per address (8 a minute, 40 an hour, right for one process). On the front enduseFormIntakeandPolysiteFormFieldshold all behaviour and unstyled markup withpoly-*class hooks, and each theme'sFormEmbedBlockandSignupFormBlockonly style it. The signup block gained aformrelationship and shows nothing until one is chosen. Forms load in the browser after the page, so editing a form needs no republish. Verified end to end against the dev stack: 16 API checks (wrong host, wrong form, no/instant/forged token, honeypot, invalid data, direct create refused, rate limit, one row stored with the right site and only defined fields), site isolation for an editor of another site, and in the browser a sign-up box and a contact form (select, textarea, checkbox, static text) with field errors, a confirmation message and a redirect. Also learned: the plugin'sradioanddatefield types are not enabled in this install (its field list is opt-in), so saving a form with one silently drops it. Not built: CAPTCHA, duplicate detection, consent records (R-10).Privacy and consent framework (R-10). Form submissions hold personal information, and there was no way to say why it was collected, record what a person agreed to, keep it for a limited time, or answer "what do you have about me / delete it". Built, per site, on the form-builder collections: forms gained a
privacygroup (purpose text, a required-consent checkbox with its label, andretentionDays), sites a Privacy tab (policy URL, validated so onlyhttps:///http://or a/pathcan become a link, and a contact email), submissions a read-onlyconsentrecord (given, the exact statement shown, timestamp) and an indexedexpiresAt. The intake endpoint takes the statement from the form and site at submit time, refuses a missing required tick (errors.consent), and stamps the expiry; email-type values are now stored lower-cased so a person can be found by their address.startRetentionPurge(onInit, in-process, 2 minutes after boot then every 6 hours) deletes expired submissions. Aprivacy-requestscollection (per site, its own RBAC module that the default Site User role does not get) is the register: type, received, due (30 days by default), status. Its edit page has Find, Download a copy and Delete buttons (components/PrivacyActions) backed byPOST /api/privacy/requests/:id/find|export|erase, which check the module capability, load the request with the user's own access (so site scoping applies) and search only that site's submissions by email. Erasure marks the request completed, clears the email and keeps an HMAC-SHA256 of it keyed withPAYLOAD_SECRET;scripts/reapplyErasures.tsre-deletes anyone on that list after a restore from an older backup. Traps: custom endpoints cannot live under a collection's own URL (/api/privacy-requests/1/findreturned "Route not found", so the actions live under/api/privacy/requests/);payload runscripts need a top-levelawait, because a fire-and-forgetmain()exits before it runs (a second script made the same mistake). Verified end to end: validation and consent refusal, the stored consent record and 30-day expiry, request create/find/export/erase including the completed state, a role without the module and an anonymous caller refused, another site's admin unable to see or work a request, the purge deleting only the expired row, and re-apply removing a restored erased person while leaving a bystander; in the browser, the purpose text, policy link and consent box blocked a submission until ticked, and the admin buttons found a record. Not built: cookie or tracking consent and analytics (R-08), self-hosted fonts, lookup by anything but email, breach-register tooling, and any automated sending of the export to the person. It is a technical framework, not legal advice.No record of what email did, so a failed password reset was invisible (R-02, re-scoped). The first idea was to make Polysite responsible for deliverability (sign mail with its own DKIM key, check SPF/DKIM/DMARC records). That was built and then deliberately removed: authenticating a domain and managing its sending reputation is the mail provider's job, and Polysite's job ends at a successful hand-off. What was missing was visibility into that hand-off. Payload's own callers (forgot-password) swallow send errors, so the only signal of a bad SMTP setting was a console line. Fix, in the email plugin: an
email-logscollection (Super Admin read; created only by the plugin) records every message with recipient, subject, attempts, the provider's reply and message ID or the error with its code and SMTP reply code; the "send a test email" button and every Email Settings change (field names and old/new non-secret values, never secrets) are entries too, with the acting user. Bodies are never stored because a reset email contains a live link. Because recipients and subjects are personal data, entries expire after a setting (default 90 days) via an in-process purge. Every message also gets a generated plain-text part. A logging failure can never stop a send. Verified end to end against a local SMTP catcher: a test email and a real forgot-password email appear as sent with the server's reply; a closed SMTP port produced a failed entry after 3 attempts withESOCKET; a password change shows as "SMTP password changed" with no value anywhere in the log; an editor, an anonymous caller and even an admin writing by hand are refused; and the purge removed exactly the aged rows. Traps: Payload updates a global withPOST /api/globals/<slug>(a PATCH is a 404);git checkouton Windows rewrote the plugin file with CRLF line endings, which broke a find-and-replace patch until the line endings were normalised. Sent means accepted by the provider, not delivered; bounces and spam placement are in the provider's dashboard.The submissions list showed
<No Form>when opened with a filter on the form. The "View submissions" link on a form's screen opensform-submissions?where[form][equals]=ID. With that filter in the URL, Payload's relationship cell skipped the shared lookup it normally uses to turn the stored form id into a title, so the Form column read<No Form>although the data was right (the API returned the form with its title at depth 1). Unfiltered, the same column was fine. Fix: theformfield's Cell is a small component (apps/cms/src/components/FormCell.tsx, set throughformSubmissionOverrides) that reads the title from the row and otherwise fetches it itself.Form submissions moved under their form, and became read-only. They were a second sidebar and dashboard entry beside Forms, editable and deletable by role. Now: the collection uses
admin.group: false(gone from the sidebar and dashboard, pages still routable), the Forms list has a Submissions column and each form a summary with View submissions and Export CSV (FormSubmissionsCount,FormSubmissionsSummary,FormSubmissionsHeader),create,updateanddeleteaccess are() => falsefor everyone including Super Admin, and a form with submissions can't be deleted (abeforeDeleteguard). Intake, retention, privacy erasure and the restore script useoverrideAccess: true, so they are unaffected. Gotcha found on the way:admin.hidden: truealso removes the collection's routes, so the links from each form 404ed;group: falseis the setting that hides navigation only. Export isGET /api/form-export/:id(apps/cms/src/forms/export.ts): streamed CSV, one column per form field, formula-guarded, read through the caller's own access, logged.Bot checks and hidden fields on forms. A form's
protection.captchaisnone,altchaorturnstile. ALTCHA is built in (forms/altcha.ts, ALTCHA's challenge format: the server signs a puzzle bound to one form with an expiry, the browser finds the number that hashes to it (utils/altchaSolver.ts, solved in the background as the form loads), and each solved puzzle is accepted once; the used list is in memory, fine for one process). Turnstile is verified server-side against Cloudflare (forms/captcha.ts): no answer from Cloudflare isunavailable(503), never a pass, and the visitor's address is not sent. Turnstile keys are per site (Sites -> Forms): the secret is AES-256-GCM encrypted (forms/secrets.ts, labelpolysite-core-forms), bullets on read, never returned to anonymous callers (field read access), and "bullets in, keep the stored key" on save. A Turnstile form with no keys is withheld (503, logged). Hidden fields are a custom form-builder block (forms/hiddenField.ts, via the plugin'sfieldsoption); the browser fills them (utils/hiddenFields.ts: page, a URL parameter, referrer), the server trims and stores them as untrusted text, and afixedvalue is supplied by the server and the browser's copy ignored. Gotchas hit: Payload runs a field'svalidateafter itsbeforeChangehook, so a secret that is encrypted inbeforeChangeis validated asenc:v1:...(the format check lives inbeforeValidate, andvalidateaccepts the stored form); and auseAsTitlefield can't be virtual. Cloudflare's published test keys (1x00000000000000000000AA, secret1x0000000000000000000000000000000AAalways pass; secret2x0000000000000000000000000000000AAalways fails) let the whole path be tested without an account.Posts and articles (R-14).
postsandpost-categoriescollections (per site through the multi-tenant plugin, one RBAC modulepostsfor both, public read of published posts only). The per-site News setting (sites.news: label, section address, per-page) sets the address/<section>/<slug>;enabledModulespostsfinally gives that toggle something to gate, becausepages/[...slug].vueonly matches the news routes when it is on (utils/postsRoute.ts: list,/page/N,/category/x,/category/x/page/N, one post; "page" and "category" are reserved slugs) and falls through to the page lookup otherwise, so redirects from an old/news/<slug>still work. The body is Lexical JSON, drawn byRichText.vuefrom a tree built inutils/lexical.ts(a fixed tag and attribute whitelist, link schemes checked, Vue escapes text), never as raw HTML. Posts join the URL index (site-urls, docTypepost, path<section>/<slug>) and the SEO plugin;/feed.xmlis a Nitro route built per host (ISR is off for it). Gotchas hit:@payloadcms/live-previewkeeps ONE module-level cache of the last merged data, so twouseLivePreviewcalls on one page (one for pages, one for posts) leave the second with{}and a blank preview, hence a single call per page; Live Preview needsadmin.livePreview.collectionslisting the collections (it was silently off for pages too); and after adding a module, existing roles and sites don't have it (roles are only seeded, never rewritten), so a Super Admin grants it. Not built: scheduled publishing, redirects on slug change, sitemap entries.Posts moved out of the core into a content-type plugin (
plugins/posts). The core gained the extension points any content type needs, so the next one (events, documents) is a plugin, not a change to nine places. CMS: a plugin's manifest says"kind": "content-type"and it is applied before the core plugins;apply(config, kit)hands it the helpers it can't import (hasCapability,normalizeSlug,uniquePerSite,recordException, built inplugins/contentTypes.ts); each collection declares itself in Payload's owncustom.polysite(module, androutablewithdocType,urlPath,previewUrl) and the core reads that from the built config, wrapping the multi-tenant, SEO and search plugins infromConfigso their options come from the collections that exist (no separate registry, in the spirit of a Drupal entity type declaring its own handlers).modulesPluginfeeds plugin modules into Roles and Sites;seedRolesreads them frompayload.config; Live Preview's collection list is set after the content plugins run, and its URL builder readscollectionConfig.customat request time. Admin components in a plugin can import@payloadcms/uibecausenext.config.mjsaliases it for Turbopack (without the alias:Module not found). Web:plugins/<name>/webwith anuxt.config.tsis a Nuxt layer, discovered bydiscoverPluginLayerPaths()like themes, mounted in the dev container and copied into the web image; the layer's Nuxt plugin registers a route with the core's$contentRoutesregistry (utils/contentRoutes.ts,plugins/contentRoutes.ts); the catch-all page asks each route in turn (resolveContent), skips modules a site has off, lets a route that finds nothing fall through to the page lookup and redirects, and draws the result with the route's component. Gotchas: the page must calluseLivePreviewonly in its own branch, because the package keeps one cache per page and the plugin's component makes its own call (see #53); composables called inside a route'sloadafter anawaitgo throughnuxtApp.runWithContext; and in development, removing a plugin's collections makes Payload's schema push wait on an interactive data-loss prompt, so drop its tables first (a leftover column inpayload_locked_documents_relsfrom a throwaway test collection did exactly that). Verified by removing the folder: the CMS lists no Posts module, the web app serves every other page, and the news addresses become ordinary 404s.Removing a plugin must not delete what its users created. Three real ways it could have: (1) with the plugin gone, any site or role that still ticked its module (
posts) failed to save ("The following field is invalid: Modules"), because the options no longer listed it; the module selects on Roles and Sites now accept unknown values, so they are kept untouched. (2)payload migrate:createrun while a plugin was missing wroteDROP TABLE posts,DROP COLUMN news_*andDROP TYPEfor everything it had added; it now refuses underPOLYSITE_PLUGINS_OFF, andapps/cms/scripts/checkMigrations.mjs(npm run check:migrations, in the commit hook and CI) fails any migration whoseupdrops or deletes unless it carries apolysite:allow-destructivecomment. (3) Local dev's schema push would offer to drop the tables on boot:POLYSITE_DB_PUSH=falseturns it off. Production never drops anything on boot (migrations only). Verified on a copy of the dev database with the posts plugin off: 12 posts and the sites'/roles'poststicks intact, sites and roles save.POLYSITE_PLUGINS_OFFdeactivates a plugin without touching its folder. The posts plugin has a "reset news settings" button; there is deliberately no "delete content" button.A fresh install of the core alone (no
themes/, noplugins/) had six problems. Found by building from a clean checkout and running first-run setup. (1) Neither image would build:COPY themes themes/COPY plugins pluginsfail when the git-ignored folder is missing, and the CMS plugin install loop (for d in plugins/*/; do [ -f ... ] && npm install ...; done) exits 1 when it matches nothing. The copies are nowCOPY .dockerignore themes*/ themes/(a wildcard next to a file that always exists, so no match is not an error) and the loop usesif; the CI build jobs create empty folders instead of cloning private repositories. (2) A new site showed the old demo menu (Leader, Our Team, Take Action...), footer columns, fake social links and a Donate button, none of which exist as pages;useSiteBrandingnow returns only what the site set, setup creates sites with the Donate button off, and the demo content moved intoscripts/seedDevData.ts. (3) Every site's tab title ended "| Polysite" because nuxt.config.ts has one build-time site name;app.vuenow sets the title template andog:site_nameper site. (4) A blank required field with a customvalidate(Pages slug, Redirects from-path and target) skipped Payload's built-in required check, reached the database's NOT NULL and returned a 500 "Something went wrong";uniquePerSiteand the redirect target validator now return a normal 400 message. (5) The 404 page was Nuxt's default with the tab title "... | Nuxt";apps/web/error.vueis a small neutral replacement. (6) README and getting-started told everyone to clone themes and plugins as if required; they are now optional steps.The core still carried plugin code and gaps found after the fresh-install pass. (1) The sitemap listed only each site's home page (the Nuxt SEO module found just the one routable page, because everything else is a catch-all), so published pages and plugin content were missing.
server/api/__sitemap__/urls.tsnow feeds it from the CMS'ssite-urlsindex (published pages and every plugin's public addresses, per site), andsitemap.sourcespoints at it. (2) The analytics plugin's browser code (tag builders, consent notice, event adapters) lived inapps/web; it moved intoplugins/analytics/web, and the core gained two generic hooks, slots and site events (utils/siteExtensions.ts), instead of knowing about analytics.resolveComponentdoesn't work inside a Nuxt plugin (Vue warns and the component never renders); a plugin must import its components directly. (3) The core's fallback blocks (what a site shows when no theme supplies a block) and the fallback form were unstyled; they now have a neutral, readable style (.poly-form-defaultsinassets/css/main.css). (4)tests/smoke/run.shbuilds a fresh stack from the tracked files (no themes, no plugins) and drives it withtests/smoke/smoke.mjs: first-run setup, sign-in, publishing a page, the public site, a form submission, a redirect, the admin screens. CI runs it as thesmokejob. Notes from writing it: authenticated API calls need theAuthorization: JWT <token>header (cookie sessions are rejected without a browserOrigin), and a form submission must wait a few seconds after its token is issued. (5) A sitemap entry can outlive its document: a page removed without going through the CMS (a database-level delete) leaves itssite-urlsrow behind with no document, so the sitemap advertised an address that returned 404. A normal delete does remove the row (checked); the sitemap route now also skips any index row whose document is gone. (6) The smoke job's only CI failure was cleanup: the backup container writes root-owned files into the working folder, so the run no longer starts it.The production images ran as root and had no health checks. The CMS and web images now run as the unprivileged
nodeuser and carry OCI labels (title, version, revision, source) and aHEALTHCHECK. Both apps answer/api/health: the CMS checks the database (200, or 503 when it does not answer), the web app only reports that it is up, because the public site keeps serving static pages when the CMS is down. The response carries onlystatusandversion(POLYSITE_VERSION, set with the build argument of the same name). Thesmokejob now also fails if either container runs as root or does not become healthy. Upgrade note: a Docker volume created by an older image is owned by root, and a non-root process cannot write to it. On an existing install, hand the volumes to uid 1000 once before starting the new images:docker run --rm -v <project>_media-data:/m -v <project>_static-content:/s alpine chown -R 1000:1000 /m /s. New installs need nothing, because Docker copies the ownership of the image's own folders into an empty volume.Two things that hang a boot or a sign-in, found building the auth plugin. (1) A write from inside a sign-in hook to the user's own row waits forever: Payload holds a transaction that has already touched that row (it records the session first), and a write on another database connection queues behind it. Code in a login hook must pass
req(join the transaction) for anything on the user row, and anything that has to survive a refused sign-in (a count of wrong codes, since the refusal rolls the transaction back) belongs in a table of its own, written withoutreq. (2) In development, Payload's schema push asks for confirmation before dropping a column, and a container has no keyboard, so boot waits (about five minutes) and the first request after it appears to hang. After removing a field in dev, drop the column by hand or setPOLYSITE_DB_PUSH=false. Related:beforeLoginruns after the password was checked and after a session was created, so a refusal there leaves an unused session record that expires with the rest; password reset signs the person in without runningbeforeLoginat all, which is why the auth plugin removes that session for accounts with two-factor on.A database write that names one array field silently empties the row's other array fields. Found while building single sign-on:
payload.db.updateOnerewrites every array table of a row from the data it is given, so writing onlysessionsremoved the user'sassignedSites(their site access) and the linked sign-in identities, and writing the identities removed the sessions. Payload avoids this because its own session write passes the whole user document. Anything that touches an array on a document through the adapter must do the same: read the full document (with hidden fields) and write it back with the one change (saveWithArraysin the auth plugin;createSessionin the core). Plain values (flags, counters, text, JSON) are safe to write alone. The auth plugin's end-to-end checks now assert thatassignedSitessurvive sign-in, reset and single sign-on. Related: the fresh-install smoke test now waits for both containers' Docker health checks before it starts, because the server answers a moment before the database is migrated and a test that started in that gap failed once for no real reason.Pages and posts have native drafts, version history, scheduled publishing and a review marker (the content workflow; design in the internal notes, editor guide in
docs/guide/publishing.md). Things that are not obvious from the code: (1)statusbecame Payload's_status; the oldstatuscolumn is kept as a hidden legacy field so nothing is dropped and the migration can copy it (UPDATE ... SET _status = 'published' WHERE status = 'published', in the migration itself). (2) Payload's admin list for a collection with drafts is built from the versions table, so a document that existed before drafts has no version row and silently vanishes from the list while still being live;workflow/backfill.tssaves each such document once at start-up to give it its first version. (3) A draft save is told apart from a publish only by?draft=trueon the request (req.query.draft);beforeChangeseesdata._status = draftfor both a draft save and an unpublish.workflow/rules.tsholds the decisions as pure functions (what a save needs, whether to refresh the site, which address needs a redirect) with unit tests, andworkflow/hooks.tsthe hooks and the publish-aware update access. (4) Publish permission is enforced in two places: the collection's update access (Payload's admin hides "Publish changes" and "Schedule publish" from people who fail it) and abeforeChangehook (the only place unpublish is checked). A scheduled publish runs as the person who scheduled it, so the hook checks them again at run time. (5) The redirect for a changed slug is made from the old LIVE slug, recorded inreq.contextbefore the write; the previous draft may already carry the new slug. (6) The search/URL index follows the live version (beforeSyncreadsdraft: false;syncDrafts: true, deleteDrafts: false), otherwise a draft saved over a live page would rewrite or delete its entry. (7) Payload's own job scheduler only starts when an admin page renders, sojobs/runner.tsruns due jobs every 30 seconds from the server itself; a scheduled publish queued from a separate process was published by the running server within 20 seconds with no admin page open. (8) Plugins use the same rules throughkit.workflow(publishRules,publishAware,reviewFields, ...). The content workflow end-to-end check istests/e2e/workflow.e2e.mjs.The core stores text in a per-language layout (translation-ready), with English as the only language a fresh install offers. Design and research in the internal multilingual notes. Things that are not obvious from the code: (1) Why the core, not the plugin: a translatable field's value lives in a
<table>_localestable instead of a column, and the checked-in migration fixes that layout for every install, plugin or not; a plugin that made fields translatable would leave a core-only install reading columns that no longer exist. (2)i18n/catalog.tsdeclares about 48 language codes once; the database's_localesenum holds them all, so offering a language later needs no migration.i18n/localize.tsmarks prose (text, textarea, richText) translatable and leaves addresses, links, identifiers, virtual and hidden fields shared; containers are never marked, so block order and structure are shared between languages. (3) The generated migration must not be used as it is (historic: before launch the core's migrations were regenerated as one core-only baseline, Issue #64, so a fresh install never runs this rewrite; it matters for anyone upgrading a database that predates it): it creates the locale tables and then DROPS the old columns, deleting all existing text.scripts/keepOldColumnsMigration.mjsrewrites it: it copies every old column into the English row first (oneINSERT ... SELECTper locale table), then turns eachDROP COLUMNintoDROP NOT NULLso the old data stays, and makesdownrefuse to run. It was verified on a restored copy of the dev data: 47 locale tables, no row-count mismatches, versions included. (4) Scripting the generator's create-or-rename prompt (Issue #10): in a container,apk add util-linux, then feed carriage returns through a pseudo-terminal,(sleep 30; while true; do printf "\r"; sleep 3; done) | script -qec "npx payload migrate:create <name>" /dev/null. Plain newlines did not work. (5) Dev schema push stalls on the legacy columns (Issue #59): apply the migration SQL to the dev database and drop those columns there by hand, once. Take the drop list from theuphalf only; a first attempt also picked up thedownhalf and dropped two new columns, which dev push re-created harmlessly. (6)localizationis on withfallback: trueand a fallback chain per language (pt-BRtoptto English); without a?locale=the API answers in English exactly as before,?locale=frfalls back to English where nothing is translated,fallback-locale=noneturns that off, andlocale=allreturns every language side by side. (7) The admin hides the language picker and the "English" suffix on field labels until the i18n plugin marks the page multilingual (custom.scss). (8) The SEO plugin's search-result title and description are translatable (its fields are overridden), the search index syncs English only unless the plugin widensskipSync, and the form builder's own labels and messages are already translatable in its code, so forms move into the locale tables too. The checks aretests/e2e/i18n.e2e.mjs(translate, fall back,locale=all, drafts and versions across languages) and the unchanged workflow and smoke checks.The i18n plugin: several languages on a site, and what it took to get the public side right. Design and research in the internal multilingual notes; editor guide in
docs/guide/languages.md, plugin notes inplugins/i18n/README.md. Things that are not obvious: (1) Where the pieces live. The core web app is language-aware by itself (utils/language.ts: a language is the first path segment, only for a language the site offers, never for its default;useSiteLanguage(),useLocalePath(),useT()), because pages and links are the core's; the plugin supplies the settings (its ownsite-languagescollection, read by the core as the contract keysite.extensions.languages; Issue #64), the admin picker and list column, the switcher,hreflang/noindexand the dictionaries. A site with nothing set answers "English, no prefix" and no link or query changes. (2)useSite()reads the site in English first (that also says which languages it offers) and only reads it again in another language when the address asks for one or the site's own default isn't English, so a one-language site still makes one request. Composables must be called before the firstawait(Nuxt loses the request afterwards); the language state is captured at the top. (3)<html lang>cannot be set from the page. The SEO modules write it from the site configuration with a priority that nouseHeadoption reliably beats (a numeric sweep either lost to "en" or dropped the attributes altogether), soapp.vuerecords the language on the request andserver/plugins/htmlLanguage.tsrewriteslanganddirat the very end of rendering;updateSiteConfigalso gets the language soog:localeand the canonical address agree. (4) Required translatable fields. Payload validates a required localized field in whichever language is being saved, so the first French save of a page failed until every required block field had a French value.relaxRequired(i18n/localize.ts) removesrequiredand re-adds it as a validation that applies only in the site's default language (isDefaultLanguageHere, which reads the site'sextensions.languagesthroughi18n/registry.ts); the columns become nullable (migrationi18n_plugin). Plugins use the same throughkit.i18n.localizeNamed. (5) Which languages a page has is kept on the URL index (site-urls.languages, filled inbeforeSync), and the sitemap, the switcher andhreflangread it; the search plugin writes index entries only for languages a site offers (skipSyncthrough the same registry), otherwise every page would get an entry per catalog language. (6) The admin language picker follows the site an editor is working in (the multi-tenant plugin'spayload-tenantcookie) throughfilterAvailableLocales;LanguageMarkerhides the picker unless the person can write in more than one language. (7) The dev server marks every pagenoindex, nofollowthrough the SEO module, so an end-to-end check for this plugin's ownnoindex, followhas to match that exact tag. (8)fetch()in a script cannot set theHostheader the proxy uses to pick the site; the end-to-end check usesnode:http. The check istests/e2e/multilang.e2e.mjs(routing, fallback,hreflang, switcher,noindex, sitemap, news, required fields).Plugins own their own tables; the core's schema is the same with or without any plugin. The rule and the author's guide are in
plugins/README.md("The isolation rule", "Per-site settings", "Database changes"). How it works, and what bit us: (1) Per-site settings are a collection in the plugin declared withcustom.polysite.siteSettings = { key, publicFields }(plugins/siteSettings.ts); the multi-tenant plugin runs it inisGlobalmode (one row per site, the admin opens the current site's row), a SitesafterReadhook attaches the public fields assite.extensions.<key>(a missing row means no key: the plugin uses defaults; the read is wrapped so a broken row cannot take the site down), and the row'safterChange/afterDeleterefresh the public site's cache. Manifest"kind": "site-settings"(or an array withcontent-type) makes the plugin apply BEFORE the multi-tenant plugin, content-type plugins first so the others see their collections. (2) Migrations. The core'sapps/cms/src/migrationsis one core-only baseline. Each plugin hasmigrations/*.js(plain ESM,up({ db, sql })), loaded byplugins/pluginMigrations.tsand passed to the adapter'sprodMigrationsafter the core's, recorded as<slug>__<file>; Payload runsprodMigrationswhen the production server connects, andpayload migrate(the image's CMD) still reads only the core folder. Dev still schema-pushes. (3)scripts/pluginMigrations.shgenerates a plugin's migration as the difference between "the core" and "the core plus this one plugin" (POLYSITE_PLUGINS_ONLY, with a scratchPOLYSITE_MIGRATION_DIR;migrate:createrefuses a partial plugin set outside a scratch run), so it is independent of the other plugins.check(innpm run check:plugin-isolationand intests/e2e/run-with-plugins.sh) fails when the core or a plugin changed without a migration, when a plugin migration alters a table or type it did not create, drops anything inup, or when a plugin's own tables differ depending on which other plugins are installed (comparePluginSnapshots.mjs). The only core objects a plugin may touch are Payload's per-collection bookkeeping columns (payload_locked_documents_rels,payload_preferences_rels,site_urls_rels) and the job queue's task-slug enums. A plugin's snapshot embeds the core's, so after any core migration runrebase <slug>for each plugin that passedcheckbefore. (4) Aselectis a Postgres enum, so it must not list things that depend on what is installed. Four fields did:sites.enabledModules,roles.modules,site-urls.docType(core), and the search plugin's searchable types and the notifications plugin's watched collection (the latter broke the whole seed: a query casting an unlisted collection name to the enum aborted the transaction). They are plain text now, withcomponents/ModuleSelect,SingleSelectandThemeSelectsupplying the menu. (5) A fielddefaultValuebecomes a SQL default, sosites.theme's default (the first installed theme) made the core's table differ between a machine with themes and CI without; it is filled in by a hook instead. (6)migrate:create --force-accept-warningalso disables--skip-empty(a blank migration gets written), so the check does not pass it. (7) Per-user state (two-factor secret, SSO links, API keys) lives in the auth plugin's own collections; API keys are a custom auth strategy, and Payload reads anyBearerheader as a JWT, so amehook is needed for/api/users/me. (8) A settings collection needs a storedtitlefield foruseAsTitle(a virtual field is rejected and breaks the import-map build). (9)tests/e2e/run-with-plugins.shtakesE2E_PLUGINS=a,bandE2E_PROJECT=nameso several runs can happen side by side; its checks skip plugins that are not installed. (10) The sitemap page is cached by the web app for ten minutes, so an end-to-end check that follows other checks reads the list it is built from (/api/__sitemap__/urls).The webhooks plugin: signed messages to other systems, and what makes them safe to send. Plugin notes and recipes in
plugins/webhooks/README.md. Things that are not obvious: (1) Events describe the live site. Saving a draft or an autosave sends nothing, publishing sendspublishedand notupdatedas well, and the message is built field by field from a fixed list (ids, titles, links; for a form submission only the form id and the submission id, never an answer), never copied from the document, with a unit test pinning that down, so a field added to a collection later cannot leak into a webhook by accident. (2) Delivery is a Payload job per message with exponential-backoff retries (six attempts by default, 30 seconds doubling), an event id that stays the same on every retry and replay (a receiver de-duplicates on it) and a new timestamp per attempt, signed as HMAC-SHA256 over<timestamp>.<raw body>so the timestamp cannot be changed; a4xxother than 408/429, a redirect or a refused address is not retried because it would answer the same way. After ten failures in a row the endpoint switches itself off and the admin shows a notice, so a dead receiver does not pile up work. (3) The destination is an administrator-supplied URL the server fetches, so it is an SSRF surface. Saving checks the scheme (https only in production), refuses credentials in the URL and names that resolve inside the network; sending checks every address the name resolves to inside the connection's own DNS lookup (a name cannot change its answer between the check and the connection), refuses loopback, private, link-local, reserved and embedded-IPv6 ranges, never follows a redirect and never reads the reply. The URL and the signing secret are AES-256-GCM encrypted (same scheme as the email and notifications plugins), masked on every read, re-read for each attempt so they never sit in the job queue, and only a Super Admin can touch webhooks or deliveries. (4) Own tables only (webhook_endpoints,webhook_endpoints_events,webhook_deliveries): the only core objects its migration touches are the per-collection bookkeeping column and the job queue's task-slug enum, the exceptions in Known Issue #64. (5) Code that writes many documents (an importer) passescontext: { skipWebhooks: true }. (6) Third-party receivers are not equal: Zapier's Catch Hook cannot read headers and so cannot verify the signature (its URL is the credential), Slack's incoming-webhook URL rejects the payload (use the notifications plugin for people), and GitHub needs a bearer-token call Polysite does not make, hence the small relay inexamples/. Verified by unit tests (signing and replay, address filtering, the no-personal-data rule, retry and switch-off decisions, the hooks against a stand-in for Payload, the relay), the three signature snippets run in Node, Python and PHP against a fixed vector, and an end-to-end script (test/e2e/webhooks.e2e.mjs). Not verified: the third-party products named in the recipes were not called, and the end-to-end script is not yet part oftests/e2e/run-with-plugins.sh. Not built: custom headers, payload templates, events for other collections, and email on automatic switch-off.The WordPress import plugin: a safe, repeatable import of an export file into one site. Plugin notes in
plugins/wordpress-import/README.md. Things that are not obvious: (1) The input is untrusted text. The WXR file is read by a streaming SAX parser (no whole-file DOM, so a 512 MB export is fine), and the body HTML is turned into Lexical nodes by an allow-list sanitiser (src/html.js): only the listed tags produce anything; scripts, styles, forms, object/embed/svg and event handlers are dropped,javascript:anddata:links are rewritten away, an iframe or player becomes a link, a table becomes one line per row, and shortcodes and page-builder layouts are removed and counted. Nothing is dropped silently: the dry run counts what will be left out and the log lists it. (2) Image download is the SSRF surface. The host comes from the export, other hosts must be listed per import, and every connection's address is checked at connect time (private, loopback, link-local and cloud-metadata ranges refused even if a name resolves to one); redirects are followed by hand, at most three, each hop checked; the type is decided by the file's first bytes (JPEG, PNG, GIF, WebP, AVIF, PDF), never by the server's word, and SVG is refused; a size limit applies while downloading. (3) Re-running updates instead of duplicating. The plugin keeps its own map of which WordPress item became which document (per site and source address), so a second run updates in place, downloads no image twice and adds no redirect twice. A page or post edited in Polysite since the last import (a saved draft counts) is left alone and listed unless Overwrite is ticked, and an item deleted in Polysite is not brought back. Old addresses become 301 redirects only where the new address differs, never over a page that exists and never over a redirect someone else made. (4) Cautious defaults: everything arrives as a draft; WordPress drafts, pending, scheduled, private and trashed items are skipped unless ticked; password-protected items are never imported because they would become public. (5) It runs as a background job with progress on the import screen and can be started again if the server restarts. The upload is stored privately under the media volume, served by no route, and deleted with the import. (6) Who: Super Admins, and roles withmanageSiteSettingsfor the sites they are assigned to; the plugin isglobaland its manifest says"kind": "content-type". Posts need the posts plugin; without it pages still import and the log says the posts were skipped. Its tables arewordpress_imports,wordpress_import_mapandwordpress_import_log; removing the plugin deletes nothing, and the content it created is ordinary content. Verified by unit tests (the reader, the sanitiser, paths and redirects, the downloader, the importer against an in-memory store) and an end-to-end script (test/e2e/wordpress-import.e2e.mjs, which needsWORDPRESS_IMPORT_ALLOW_PRIVATE_HOSTS=1to fetch from its local server). Not verified: a real WordPress export from a large live site, and the script is not yet part oftests/e2e/run-with-plugins.sh. Not imported: comments, users, custom fields, other post types, sub-menus, category hierarchy and the old site's front-page setting.Security review before beta (R-22): what was found, fixed and left open. The full table (28 findings with evidence, the fix and its commit) is in
internal/SECURITY_REVIEW.md; this is the shape of it. The method was reading every collection's access control, endpoint, hook and server-side fetch, then probing a throwaway stack (core plus eight plugins, demo data) as anonymous, Site User, Site Admin and Super Admin over REST, GraphQL and Caddy, plusnpm auditper package. "Fixed" means committed with a test, and the regression check istests/e2e/security.e2e.mjs, run by the smoke test and the plugin end-to-end run. Found, 2 high: page clone read the source page with the server's access, so a Site Admin could copy another site's unpublished draft into their own site (now read as the caller); andnextandsharpwere inside the ranges of advisories (an RCE innext/og, an SSRF in image optimisation; nownext16.3.8 andsharp0.35.5, neither reachable as far as seen). Medium, fixed: the URL index listed drafts to anonymous visitors and any signed-in editor could edit its rows;readVersionsdefaulted to any signed-in user; the session cookie was notSecure; the CMS sent no security headers; placeholder secrets from.env.examplewere accepted on a public server; uploads and chunked request bodies were unbounded; Postgres was published on all interfaces with a placeholder password; a deleted document's title reached subscribers of another site (notifications); nodemailer had 13 advisories; the CMS image was on Node 20, past end of life (the images are on Node 24 LTS now); backup archives were world-readable. Low, fixed: any signed-in user could unlock any account; internal review notes were public; the notifications SSRF filter missed reserved ranges; sign-in had no per-address limit. Closed since the review: the first-run takeover (POLYSITE_SETUP_TOKENis now required over https,setup/policy.ts) and the per-address sign-in and password-reset limits (auth/ipThrottle.ts,POLYSITE_LOGIN_RATE_LIMIT,POLYSITE_RESET_RATE_LIMIT), the limits live in the app because Caddy's stock image has no rate limiting. Accepted by design: a role withmanageSiteSettingscan run script on every visitor of its site (custom JavaScript, a custom tracking tool, unvalidated link addresses), the form limiter trusts the firstX-Forwarded-Forentry and so depends on Caddy overwriting it and on port 3001 staying unpublished, and API keys and single sign-on skip the second factor. Still open: backup encryption is not mandatory for an off-site copy; SMTP uses opportunistic STARTTLS;docker-compose.prod.ymllacks theno-new-privilegesandcap_dropthat the local file has; the CI/CD items (apermissions:block, the deploy ref passed through the environment and validated, third-party actions pinned to commit SHAs) were being rewritten by the release work at the time and are not re-checked here; whether raw script should be its own permission is a product decision. Not reviewed: the webhooks and WordPress import plugins, the CLI, and the release and deploy tooling, which were added during the review, and anything that needs a browser or a real certificate (Live Preview with the new admin headers, HSTS and Let's Encrypt, a CDN in front, the Cloud SQL proxy path, the off-site backup path). Traps worth keeping: a custom endpoint that passesoverrideAccess: trueskips the multi-tenant scoping as well, so read as the caller; filtering a query on a field the reader cannot read is refused by Payload (checked, so there is no blind oracle on secrets); Caddy'srequest_bodylimit is what bounds a chunked body, the app only sees a declared length; and the Caddyfile was normalised from CRLF to LF in the process, which makes its diff look larger than the change.Visitor search finds words in the body text, not only titles and addresses (a launch blocker for the beta). Guide
docs/guide/search.md, plugin notes inplugins/search/README.md("How it matches"). Things that are not obvious: (1) The text lives in the core's index, filled by the core.site-urlshas asearchTexttextarea,localized: true(so it is a column ofsite_urls_locales, one value per language like the title) andhidden: true(never listed in the admin or returned by the API unless a Local API caller passesshowHiddenFields; it can still be queried).beforeSyncfills it throughsearch/searchText.ts, which callssearch/extractText.ts: a generic walk over the collection's own field list (text, textarea, rich text; into blocks, arrays, groups, tabs and rows; Lexical flattened to words with one space between blocks, links keep their text, uploads and relationships are dropped, a block placed inside rich text contributes its prose-looking values). Never in: the title and slug (indexed separately), fields named like addresses or identifiers (NOT_PROSE, shared withi18n/localize.ts),hiddenorvirtualfields, any field with its ownaccess.read(the review note), relations, uploads, selects, numbers, code and JSON. A collection opts out withcustom.polysite.searchText = falseor leaves fields out with{ exclude: [...] }; a content-type plugin's rich text and blocks need no code. Capped at 50,000 characters (MAX_SEARCH_TEXT, cut at a word); whitespace collapsed, control characters removed. (2) Only what is live, only when published.beforeSyncalready follows the live version of a document with drafts; the text is stored only when that version's status ispublished, so a never-published page, a draft over a live page, an unpublished page and a redirect have""and unpublished words never reach the index (the public API shows published entries only anyway; the plugin re-checks site and status when it reads bodies). (3) A latent bug fixed on the way: the live re-read inbeforeSyncpassed nolocale, so it used the request's language; during a Reindex (every language of a document in turn on one request) every language row got the request language's title. Payload does not tellbeforeSyncthe language being synced, soskipSync, which is called with it just before, remembers it onreq.context.searchSyncLocale, and the live read now passes it. (4) Schema change on top of the baseline. The pilot may already run the previous baseline, so this is a new additive core migration (20261011_154509_search_text: oneALTER TABLE site_urls_locales ADD COLUMN search_text varchar), not a regenerated baseline;npm run check:migrationspasses (nothing destructive) and every plugin snapshot was rebased (rebase <slug>for all ten, thencheckgreen for the core and each plugin).pluginMigrations.sh coreused to overwritemigrations/index.tswith a file listing only the new migration (the scratch run knows only that one); it now rebuilds the list from the folder. (5) Existing rows get their text without manual work. At startbackfillSearchText(background, never blocks boot or fails it) looks per language for index rows that have a title but nosearchText, reads the live document, and writes the text (or""); every row gets a string, so each is looked at once. The admin's Reindex does the same for everything (verified in the end-to-end check, including that French rows keep their French titles). (6) Matching and cost: plainILIKE, no index, measured. Each word is one boundlike(PostgresILIKE '%word%'; Payload splits on spaces, the plugin sends one word at a time and strips% _ \) on title OR address ORsearchText, ANDed over the words; no tsquery or SQL is built from what is typed. A scratch Postgres 16 with 6,000 entries of about 7 KB (2,000 for the searched site): about 90 ms for one word, linear in the site's total text (through the real endpoint, 4,000 entries of about 3 KB answered in about 110 ms). A trigram GIN index brings a body-only predicate to 2 ms but costs as much disk as the text (50 MB for 48 MB) and does not help the real query, whose OR spanssite_urls.pathandsite_urls_locales, so the planner cannot use it (70 ms with it); atsvectorindex would need raw SQL from the plugin and would not segment Chinese or Japanese. So: no index, the rate limits bound the load, no stemming or accent folding, the same behaviour in every script. Revisit with a generated tsvector column if a site passes tens of thousands of long pages. (7) Ranking and snippets are the plugin's (query.js): exact title 1000, title starts with the search 800, every word starts a title word 600, every word in the title 400; otherwise per word 40 or 30 in the title, 15 in the address, 4 only in the body (so more words in better places rank higher), then index priority, then title. The snippet is the item's own summary when it shows a searched word, else the 220-character window of the body with the most different words, cut at word edges; the bodies of the results on the shown page are read in one extra query. The plugin detects the column (flattenedFields) and on a core without it searches titles and addresses as before. The results page already marked and escaped words in titles and now does the same for snippets (text nodes, never HTML). (8) Tests:extractText.test.tsandsearchText.test.ts(core), the plugin'ssearch.test.js(query building, ranking, snippets, operators, feature detection) andtest/e2e/search.e2e.mjs(a word only in a body, draft body, unpublished body, edited-in-draft body, other site's body, post bodies, escaped snippet, operators, a body past the cap, a French body, Reindex).Release and deploy design, and the backup, pinning and image-scan rules around it (SEC-10, SEC-14, SEC-16). Guides
docs/guide/releasing.md,deploying.md,backups.md. Things that are not obvious: (1) A tag releases; it never deploys. PushingvX.Y.Zrunsrelease.yml:prepare(check-version.sh: tag =VERSION= bothpackage.jsonand lock files = a dated, non-emptyCHANGELOG.mdsection, and the tagged commit is onmainorrelease/*), thengates(it callsci.ymlas a reusable workflow, so a release runs exactly what every push runs), then per image (cms, web, backup) build amd64 into the local daemon, Trivy HIGH/CRITICAL before anything is pushed, build and push amd64+arm64 asvX.Y.Zonly (SBOM, provenance, attestation by digest), thensmoke-publishedpulls the three images by digest and runs the fresh-install smoke against them (.github/scripts/smoke-images.sh), thenpromotemovesvX.Yandlatest(stable releases only;imagetools createby digest so attestations stay attached), then a draft GitHub release (changelog section, the deployment bundle,IMAGES.txtwith every digest, SBOMs,SHA256SUMS). A manual run of the workflow is a dry run that pushes nothing. If the published-digest smoke fails the images stay under their exact tag only and nothing moves: fix forward with the next patch, never delete the tag. (2) Deploy is a separate manual act.deploy.ymlisworkflow_dispatchonly, behind theproductionenvironment, validates the version input against a version pattern and hands it over throughenv:(never interpolated into the remote script), SSHes to the VM and runsdeploy/deploy.sh <version>(or--rollback). The script refuses a tag whose image label disagrees, takes a backup with the running backup service and test-restores it (verify.sh), runs the migration guard andnpx payload migratein a one-off container of the new image before any service changes, then switches cms, web and backup one at a time behind health gates (and/api/healthmust report the new version), rolling back by itself on failure (exit 3; 2 if the rollback failed too; 1 = refused before any change). State is in.deploy/(audit.log,current.env,previous.env), and every later compose command must carry--env-file .env --env-file .deploy/current.envso it uses the same digests;POLYSITE_VERSIONandPOLYSITE_*_IMAGEexported in the shell are ignored. (3) Migrations are additive only; that is why rollback is images only. The guard (check:migrationsplus a scan of plugin.jsmigrations) refuses DROP, DELETE or TRUNCATE unless the file sayspolysite:allow-destructiveand the operator passes--allow-destructive; older code ignores columns it does not know, so--rollback(and the automatic one) puts back images and the database keeps every migration. Undoing a migration itself means restoring the pre-deploy backup named in the audit log, which loses later edits. A pre-release/hotfix flow and the plugin-compatibility range (requires.polysite) are in the releasing guide. (4) SEC-10: an off-site copy is encrypted or it does not happen.lib.shoffsite_block_reason(same rules aspolysite doctor: 16+ characters, not a placeholder, at least 10 distinct characters) is checked bybackup.shafter the local archive is written: withBACKUP_RCLONE_REMOTEset and no acceptableBACKUP_ENCRYPTION_PASSPHRASEthe local archive is kept (owner-only,umask 077;restore.sh's safety dump too), nothing is sent (not even the checksum), the run exits non-zero withOff-site copy REFUSED, the monitoring ping gets/fail,OFFSITE_STATUSsaysrefused,healthcheck.shreports the container unhealthy,entrypoint.shlogs it at start andverify.shfails with the reason. Defence in depth: only a*.encname is ever passed to rclone. Consequence for deploys: the pre-deploy backup fails, sodeploy.shstops (--skip-backupis the explicit way round). Tested for real bytests/backup/run.sh(a throwaway Postgres and the backup image in their own Docker network, local rclone path as the remote; CI jobbackup-tests): plain local archive is mode 600, refusal without/with weak/placeholder passphrases, the encrypted archive reaches the remote and is OpenSSL-salted with no plaintext,verify/restoreopen it with the passphrase and refuse a missing or wrong one, a tampered archive is caught. (5) SEC-16 pinning. Everyuses:isowner/repo@<full sha> # vX.Y.Z, resolved from the release tag within the major already in use (annotated tags dereferenced throughgit/tags/<sha>); majors are left to Dependabot, one pull request each, because itsgithub-actionsecosystem reads the version comment and rewrites SHA and comment together (minor and patch updates are grouped). The Trivy scanner is a container image pinned by tag and digest inrelease.yml(TRIVY_IMAGE), which Dependabot does not see, so it is moved by hand.actionlint(containerrhysd/actionlint) is clean. (6) SEC-14 / the release image scan. Scanning the three images built from the tree with the release build args found 67 (cms), 9 (web) and 35 (backup) HIGH/CRITICAL findings, nearly all fixable. Fixes:source-map-js1.2.2 in both lock files; the CMS lock pinsundici7.29.1 (Payload pins 7.29.0 exactly) andesbuild0.28.2 through npmoverrides(the older esbuild binaries fromdrizzle-kitand@esbuild-kitwere built with Go versions whose standard library has ~30 CVEs; 0.28.2 already ran undertsxandvite,drizzle-kit/api,tsxandpayload migratestill work); the CMS run stage doesnpm install -g npm@11(npm's own bundledtar,ip-address; staying on the 11 line,npm@12is a major); the web run stage deletes npm altogether (nothing there runs it); the backup image runsapk upgrade(musl, zlib, libuuid, libxml2) and deletesgosu, a Go binary of the Postgres entrypoint that this image replaces. After that web and backup scan clean and the CMS had 5 left. Three of them (brace-expansion5.0.9, CVE-2026-102276 and -102278, andundici6.28.0, CVE-2026-19534, bundled inside npm) have fixed patch releases that no npm release ships yet (npm 11.21 and 12.2 both bundle the vulnerable ones), so the CMS run stage overlays npm's own copies withbrace-expansion5.0.11 andundici6.28.1 (a block inapps/cms/Dockerfileto delete once an npm 11 release bundles them). 2 are left, with no fixed version anywhere:braces3.0.3 (CVE-2026-93687, via sass > chokidar and fast-glob > micromatch, build tooling) andhttp-cache-semantics4.2.0 inside npm (CVE-2026-93748; 4.3.0 exists but its diff does not touch the max-stale code). Neither is on a request path (npm only starts the app and runs migrations). The workflow scans without--ignore-unfixed, so these block a release until npm ships, or.trivyignoresays why (an owner decision, with an expiry date); re-check with a scan of the built images before every tag, since new advisories appear daily. Note the lock files are committed with CRLF line endings: regenerate with npm, then restore CRLF, or the diff is the whole file.A Cloudflare Turnstile check on the admin sign-in and forgot-password pages (auth plugin). Guide
docs/guide/security.md, plugin notes inplugins/auth/README.md. Things that are not obvious: (1) Settings and keys. AsignInProtectiongroup on the plugin'sauth-settingsglobal (on/off, site key, secret key, "applies to sign-in" and "password reset", both on); the secret is encrypted at rest (the plugin's AES-GCM helper), masked on every read (context.revealCaptchaSecretgets the real value for the check,rawCaptchaSecretthe stored one for the save) and the mask means keep. BothAUTH_TURNSTILE_SITE_KEYandAUTH_TURNSTILE_SECRET_KEYswitch the check on and win over the saved keys (one alone does nothing), so a fresh install can be protected before anyone can sign in;AUTH_CAPTCHA_OFF=1beats everything (the recovery switch for unreachable Cloudflare or wrong keys). (2) Server. AbeforeOperationhook on the users collection forloginandforgotPassword, appended after the core's per-address throttle (plugins are applied last): it reads the token from the headerx-polysite-captcha, posts secret, token and the address (lastX-Forwarded-Forentry, elseX-Real-IP) tositeverifywith a 5 s timeout, and fails closed ("Please complete the check." for a missing, spent or refused token; "The check could not be completed, try again." for no answer or Cloudflare refusing our secret, with a log line that names the recovery switch and never a token or key). "No forwarded address means internal" is only true for Local API calls without a request: the Next.js server addsx-forwarded-foritself to every HTTP request, so a script that signs in on the CMS port directly is "from outside" too (the e2e stack stays unaffected only because the check is off there). The plugin's ownpayload.logincalls (setting up two-factor from the sign-in page) passcontext.polysiteSkip2FAand carry no address; the 2FA endpoints, API keys and single sign-on are not covered. An unreadable settings global leaves only the environment in charge (the same fallback as the two-factor policy). (3) Page.admin/CaptchaGate.jsis a Payload provider (admin.components.providers), because the forgot-password page has no component slot (beforeLogin/afterLoginexist only on the login page). It watches the DOM, puts a holder aboveform .form-submit(or above the Resend actions once a reset email was sent, which submits the same form again) and renders the widget there withsize: flexible, followinghtml[data-theme];GET /api/auth-captcha/configreturns onlyenabled, the site key and the two switches. Three traps: the fetch wrapper that adds the header must be installed when the module loads, because the sign-in code box wrapswindow.fetchin an effect and, on unmount, puts back whatever it found, which silently removed a wrapper installed later; tokens are single use, so the widget resets after every request, right or wrong; and Payload's forgot-password form treats any JSON answer as "email sent", so the wrapper throws the server's message for a refused reset instead (it also fixes a 429 showing as success). Timers, notrequestAnimationFrame, drive the DOM watcher: a tab that never painted does not run animation frames. The admin sends no CSP that blocks the script (Caddy's admin headers carry onlyframe-ancestors 'none'). (4) Migration. The plugin's second migration (auth_captcha) adds five columns to its ownauth_settingstable;pluginMigrations.sh checkused to refuse any ALTER of a table the same file did not create, so it now counts tables created by any of the plugin's own migrations (still nothing of the core's, and nothing dropped). (5) Tests.test/captcha.test.js(result handling with a mocked fetch, the timeout, closed failure, header and address parsing, env precedence and the off switch, secret masking, the public config) andtest/e2e/captcha.e2e.mjswith Cloudflare's published test keys in three phases (settings;env, the CMS started with both keys;off, started withAUTH_CAPTCHA_OFF=1), which skips with a message whenchallenges.cloudflare.comcannot be reached. Verified in a browser on the dev stack with the always-pass keys: widget above Login, sign-in, a wrong password resets the widget, the forgot-password page (reached by in-app navigation and by a direct load), Resend, a refused reset showing the real message, light and dark, and the "script blocked" message.Deployment options: proxies, Kubernetes, VM, bare metal, and the template repositories (R-01 follow-up). Guides
docs/guide/deploy/*; artifacts (proxy,kubernetes,templates/*) in the separate polysite-deploy repository, whose README lists every artifact and test command. The "no orchestrator" decision was for the pilot; Kubernetes is now an option, with limits. Things that are not obvious: (1) Reverse proxies other than Caddy are tested, not just written.proxy/test/run.sh (polysite-deploy) <nginx|apache|traefik|...-tls|traefik-labels|all>layers each proxy's compose file over the realdocker-compose.ymlin a throwaway project and runs the whole first-run smoke test through it, thenchecks.mjs: API and admin headers,/redirect on the admin domain, an upload whose name has a space plus itsCache-Control: public, max-age=86400, the 64 KB and 100 MB caps for sized and chunked bodies (and that the refusal comes from the proxy, not the app: the app marks its answersX-Powered-By), forgedX-Forwarded-Foragainst the sign-in limit, unknown hosts; the TLS modes (self-signed) check the http redirect, HSTS,X-Forwarded-Proto, TLS 1.0/1.1 refused and HTTP/2. The web app serves its own/_nuxtassets, so other proxies need no shared static volume (Caddy'sfile_serveron the shared volume is an optimisation). Apache'sLimitRequestBodyis not applied to a proxied request (measured: a 5 KB body passed a 1,000-byte limit, and the 413s that looked like Apache's were the app's), soops/proxy/apachechecks the declaredContent-Lengthwithmod_rewriteexpressions and refuses chunked request bodies with 411; nginx counts chunked bodies itself and Traefik'sbufferingmiddleware does too. nginx cannot remove itsServerheader (only the version). Traefik's redirect on the admin root needed the admin headers added in front of it (Caddy sends them on the redirect too). (2) The Helm chart (kubernetes/helm/polysite) runs the migrations as a Helm hook Job from the NEW image, preceded by the same destructive-migration guarddeploy.shruns; because hooks run before the release's own resources exist, the Job reads its own copies of the ConfigMap and chart-made Secret (<release>-migrate, kept after upgrades), and withpostgresql.enabledthe hooks arepost-installso the demo database exists. The cms container then startsnext startdirectly (not the image'spayload migrate && npm start); Payload'sprodMigrationsstill runs at every boot, which is a no-op after the Job. Nothing is generated by a template (a regeneratedPAYLOAD_SECRETwould orphan stored credentials): secrets come fromexistingSecretor explicit values. An edge nginx (the same routing, limits and headers) sits behind the Ingress so the install does not depend on ingress-controller annotations; it takes the visitor address fromX-Forwarded-Foronly fromedge.trustedProxies, because the sign-in limit reads the LAST entry and would otherwise see the ingress for everybody. The cms usesRecreatewhile the media claim is ReadWriteOnce. Pods are non-root with read-only root file systems (writable: media,.next/cache,/tmp), verified on k3s.kubernetes/test/lint.sh(no cluster; CI jobdeploy-static) andrun.sh(throwaway k3s in Docker: install, hook Job, smoke test and proxy checks through Traefik's Ingress and the edge, upgrade,helm rollback, backup CronJob, NetworkPolicy) cover it. (3) What is NOT safe with more than one replica (the chart refusesreplicaCount > 1and autoscaling withoutallowMultiReplica): uploads on one local disk (no S3 storage adapter exists; two pods need ReadWriteMany); the web app's route cache and rate limits are per process and the revalidate call reaches one pod (measured, one scenario: with two web pods a publish was visible on both within 2 seconds, so the short cache lifetime hides it; not examined further);rbac/permissionCache.tsis refreshed only by the process that handled the role change, so a revoked permission lingers on other replicas;auth/ipThrottle.ts,forms/spam.tsandforms/altcha.ts(captcha replay) are per-process maps;jobs/runner.ts, the retention purge and plugin timers run in every replica with Payload's job lock as the only guard and no single-runner guarantee; two replicas booting on an empty database race onseedDefaultRoles(unique name: one restarts). Single-replica "high availability" means restart and reschedule plus rolling image updates, not active-active; the real fix is in the application (shared invalidation, an object-storage adapter, an out-of-process limiter and job lock). (4) Plugins and themes need images built from source: "FROM the published image and COPY plugins" cannot work (thenext buildand the Nuxt build have compiled the plugin list, import map and layers in), sokubernetes/derived-image/build.shruns the core's Dockerfiles with the plugins in the context. (5) VM and bare metal.templates/vm-cloud-init/cloud-init.yamlis generated from the Caddy template andfiles/(build.sh, stale files failtest.sh), checked with cloud-init's own schema validator, shellcheck and a clean Ubuntu 24.04 (the Docker key fingerprint the bootstrap demands, Docker's apt repository,init.sh); never run as root on a real VM; the AWS Terraform skeleton passesterraform validateand was never applied.templates/bare-metalhas two hardened systemd units (loopback listeners,ProtectSystem=strict);test.shbuilds both apps on Node 24 withscripts/build.shand runs them with the units' exact commands against PostgreSQL behind nginx, passing the smoke test, but systemd itself was not run. Managed platforms (Cloud Run, App Runner, Fly.io, Render, Railway) are documented only and mostly "not yet": they need a writable disk, a long-running process for the 30-second job timer and exactly one instance of each app. (6) Packages in the core, examples in template repositories. Releases publish the images and (release.yml, jobpublish-chart, after the published-digest smoke test, and the draft release waits for it) the chart tooci://ghcr.io/<owner>/charts/polysite;templates/<name>are self-contained directories (README quickstart,.env.example, pinned versions, Dependabot and Renovate configs, a validate-only workflow, a licence note) thatops/release/publish-templates.shcopies intopolysites/deploy-<name>(dry run by default; nothing is cloned or pushed without--apply, and nothing leaves the machine without--push);templates/test.sh static|runchecks them (the proxy copies must equal the tested ones). Compose image tags in templates are literal so Dependabot's compose support can bump them; Renovate regex managers cover the chart pin and custom registries (neither bot was run). (7) Found while testing: Live Preview's framing origin is fixed when the web image is BUILT.apps/web/nuxt.config.tsreadsCMS_SCHEMEandCMS_DOMAINat build time intosecurity.headers.contentSecurityPolicy["frame-ancestors"], and the built image carrieshttp://admin.localhostin its runtime config; settingCMS_DOMAINwhen the container starts does not change the header (measured on k3s: withCMS_DOMAIN=runtime-admin.localhosta page still sentframe-ancestors 'self' http://admin.localhost). With the published images on a real domain the admin cannot frame the site, so Live Preview is blocked, on every deployment path that uses them (the proxy tests and the chart test use the default domain, which is why they pass). Not fixed here, and the Compose files' comment that these values are "read at runtime" holds forcmsServerUrlbut not for the CSP. Fix options for the owner: build args inapps/web/Dockerfile, a Nitro plugin that setsframe-ancestorsfrom runtime config, or overriding the header in the proxy. Also found:helm install --waitnever finishes while the unused backup volume is Pending on a WaitForFirstConsumer storage class (k3slocal-path): install without--wait, or usebackups.persistence.existingClaim.The blocks-pack plugin: eleven extra page-builder blocks without changing a core table, and the block-type registry it needed. Plugin notes and the field contract in
plugins/blocks-pack/README.md. Things that are not obvious: (1) A plugin adds block types by rewriting thecontentblocks field of thepagescollection (a config transform,src/index.js); it adds no collection and no field to any other collection, and a block already present is not added twice. Plugins are applied after the core, so the core'slocalizeNamedpass over Pages has already run: the plugin's own block fields are made translatable withkit.i18n.localizeNamed(prose only; links, the phone number, the gallery's column count and pictures stay shared). (2) Storage stays in new tables. Each block type createspages_blocks_<slug>(a hyphen becomes an underscore:hours-contactispages_blocks_hours_contact), a_localestable, a table per nested list (pages_blocks_pricing_plans_features) and the_pages_v_blocks_*twins for versions: 96CREATE TABLEstatements and one new enum (the gallery's columns), and noALTERof a core table. A singleuploadfield inside a block is a plainimage_idcolumn of the block's own table, not a row inpages_rels, so no relationship column is added to a core table either;scripts/pluginMigrations.sh check blocks-packpasses and the migration applies on an empty production-mode database (tests/e2e/run-with-plugins.sh). The gallery'scolumnsselect holds the strings"2","3","4"(a Postgres enum cannot hold numbers). (3) The renderer had a closed list of blocks (BLOCK_COMPONENT_NAMESand the fallbacks inBlockRenderer.vue), so a plugin could not add one. The core now has a registry like the slots and content routes (utils/blockTypes.ts, Nuxt pluginblock-types,$blockTypes.register({ slug, name, component })); the plugin's layer registers each block and its plain component, a theme overrides one by shippingcomponents/<theme>/<Name>Block.vueexactly as for core blocks, a parent theme's is inherited, and a plugin cannot take over a core slug. Block types are now looked up withObject.hasOwn, because page data is not trusted and ablockTypeofconstructorused to resolve to a function from the object prototype. (4) The page data is untrusted, so the components are written defensively. Text is drawn with interpolation, neverv-html; a link is followed only if it is http, https, mailto, tel or relative (web/utils/blocks.js, which also refuses control characters hidden inside a scheme such asjava<tab>script:,//hostand/\host); a picture address must be http, https or relative; a row that is not an object is skipped; a button needs both words and a safe address or it is left out. (5) Look. One stylesheet in thecomponentscascade layer (so it beats Tailwind's reset but loses to any unlayered theme rule and to utilities) with every colour taken from--poly-accent, then--color-primary, then--brand-primary; text is never drawn in the accent itself but in the accent mixed half with black, which is what keeps links and buttons above AA contrast whatever colour a site picks. (6) Headings follow the page's outline: a block's title is anh2, the headings below it one level down, orh2again when the block has no title (an end-to-end check fails on any skipped level). (7) Two things that cost time: apre-commithook lints the whole CMS app, so another person's unfinished file in the working tree blocks an unrelated commit, and Vue's server rendering wraps every drawn list in<!--[-->comments, which broke the first end-to-end markup checks until the comments were removed before matching. Verified: unit tests (config change, field contract, link and time helpers, the registry), the plugin check, andplugins/blocks-pack/test/e2e/blocks.e2e.mjs(every block with hostile text and addresses, heading order, list semantics, alt text, sanitised links, pages with missing data, a page without any plugin block), run as part oftests/e2e/run-with-plugins.sh. Not verified: a screen reader, browsers other than Chromium, right-to-left languages, and the translated fields in more than one language (the i18n plugin was not part of the run).Petition forms: one signature per person, and a public count (core,
apps/cms/src/forms/petition.ts). Both are per-form settings (grouppetitiononforms), off by default, so a contact form is unchanged. (1) De-duplication by email: the key isHMAC-SHA256(PAYLOAD_SECRET, "dedupe.<formId>.<normalised email>"), stored inform-submissions.dedupe_keywith a unique index on (form, dedupe_key). The index, not a lookup, is what stops two racing requests both being stored; the endpoint creates the row and treats a unique violation as "already signed". Payload's Postgres adapter does not pass the raw23505through: it throws its ownValidationErrorwhose message names the columns (form_id, dedupe_key), soisDuplicateErrorrecognises that shape as well as the raw one (the first attempt missed it and answered 500; a live test caught it and it is now a unit test). A duplicate gets the same success response as a first signature, so the form cannot be used to find out who has signed. Only the first email field is used; the hash is not the address, is scoped to the form, and goes with the submission on retention or privacy erasure. RotatingPAYLOAD_SECRETchanges every key (old rows keep their old key, so a repeat signer after a rotation is stored again). The default is off because silently dropping a second contact-form message from the same address would be a bug. (2) Public count:GET /api/form-intake/:id/count, 404 unlesspetition.showCount;count = stored submissions + countOffset; cached 10 s in the process (TtlCache) and sent withCache-Control: public, max-age=10; rate-limited to 120 a minute per address. A retention or privacy deletion lowers it. The site shows it withPolysiteSignatureCount.vue(class namespoly-signatures*, no look of its own), driven byuseFormIntake().signatures, refreshed every 30 s and after a signature; all 19 theme form blocks carry the one line that renders it. (3) No Redis queue: measured in Known Issue #74, one CPU absorbs about 55 signatures a second, far above the PRD's 1,000 to 10,000 a day, so the queue is not built. Core migrationpetition_dedupe(five columns, one enum, the index); every plugin's snapshot was re-based onto it (scripts/pluginMigrations.sh rebase). Verified: 15 unit tests, a live API test (a repeat with a retyped address, a simultaneous double submit, the count and its cache header), and a real browser submission (count 13 to 14). Not verified: behaviour under load.Public form intake capacity, measured (2026-10-17,
tests/load/run.sh). A fresh production build in its own Docker project with the CMS limited to 1 CPU and 1 GB and PostgreSQL to 1 CPU and 512 MB (a small server's share), a petition form with de-duplication on, and a load generator in a third container on the same machine, talking to the CMS directly (Caddy replacesX-Forwarded-For, which would put every request on one address and trip the per-address limit; the test spoofs distinct addresses). Each stage runs 15 s (10 s for reads). Results: new signatures about 55 to 57 a second at every concurrency level (10, 25, 50, 100), all HTTP 200, latency rising with queueing only (median 0.17 s at 10 concurrent, 0.46 s at 25, 0.89 s at 50, 1.8 s at 100; 99th percentile 2.7 s at 100); repeat signatures (the de-duplication path) 88 a second; the public count 152 a second and the form definition 159 a second, both uncached at the CMS (the count carriesCache-Control: public, max-age=10, but Caddy does not cache by default, so a CDN or a Caddy cache is what makes the header useful beyond a few thousand people watching at once). Accuracy: 3,457 accepted signatures, 3,457 rows stored, public count 3,457; no loss, no duplicates, no errors, no 429. Peak use: CMS 102% of its one CPU and 267 MiB; database 89% of its one CPU and 94 MiB, so the CPU is the limit, both containers' share of it, not memory. What it means: the PRD's 1,000 to 10,000 signatures a day is 0.01 to 0.12 a second on average, and even 10,000 in three minutes is 55 a second; one CPU absorbs that, so the Redis queue and worker (F5, R-21) are not needed for the stated target and are not built. They would be worth building for (a) sustained bursts well above about 50 a second per CPU, (b) accepting signatures while the database is down, (c) a larger machine's headroom being cheaper than the queue's complexity, which is the usual outcome. Each signature costs roughly 18 ms of CMS CPU (Payload's create path: access, hooks, validation, the insert), so the cheaper fix at higher load is a bigger CPU share before a queue. Limits of the measurement: the load generator shares the machine; a Windows Docker VM is not a cloud VM; the tiny free-tier VMs inPolysite-gcpandPolysite-awshave a fraction of this CPU, so repeat it on the real server before promising a number to a client. Repeat it withsh tests/load/run.sh(takes about ten minutes;STAGE_SECONDS=30for longer stages).The API reference, and the setup checklist (the
onboardingplugin). (1)docs/guide/api.mdis hand-written from the real collections and endpoints and every request in it was run against the dev stack (2026-10-18): signed-out reads see only live pages;POST /api/pagesalways makes a draft; aPATCHwithout?draft=trueon a live page changes the live page (it is a publish);POST /api/pages/:id/clonetakestargetSiteId; media uploads needaltand a PNG/JPEG comes back as WebP; the form routes answer on the site's own domain and 404 on any other host;POST /api/users/logoutkills the token at once; the MCP endpoint answers server-sent events and needs a key. The auth plugin's API keys needAUTH_API_KEYS=truein.envand a recreated container (docker restartdoes not re-read.env; usedocker compose ... up -d --no-deps cms). The page is not generated, so a changed field or route needs its table edited by hand. (2) The checklist is a plugin, not core, because it needs a table (onboarding_progress, one row per site, declared askind: site-settingsso the core scopes it;publicFieldsis empty so nothing reaches the public site) and nothing in the core depends on it. It reads through the signed-in person's own session, so it can only show what they may see. Sharp edges found in the browser: for anyone but a Super Admin/api/users/mereturnsroleas a bare id, so the card fetches the person's own role by id (/api/roles/:idis allowed for one's own role); only Super Admins may list users, so "Invite an editor" and "Turn on backups" (not detectable from the CMS: backups run in another container) are shown to them only; a React effect that returned a promise crashed the whole dashboard (destroy is not a function) and was invisible to the unit tests; the dev server did not pick up edits to plugin files, so restart the CMS container after each change (seememory.md); the site comes from thepayload-tenantcookie, and with no site chosen a person with one site gets that one, anyone else a one-line prompt. Tests:plugins/onboarding/test/checklist.test.js(17); checked by hand as Super Admin and as the dev Site User, per-site independence and persistence across reloads included. Not built: a per-person (rather than per-site) dismissal, and a detected "backups are on" (needs the backup tool to report its last run to the CMS).AI assist in the editor: the
ai-assistplugin, and what is not obvious about it. Plugin notes inplugins/ai-assist/README.md, design and open choices inplugins/ai-assist/DESIGN.md(readiness N3, R-18). Things that are not obvious: (1) It is auifield, not a change to a collection. The plugin prepends auifield (no column, no table) topages,postsandmedia; the box reads and writes the open form withuseAllFormFieldsanddispatchFields. Nothing is saved until the editor saves. The only record it creates is a draft page (build from a brief), saved as the signed-in person withoverrideAccess: false, so role and site decide; the draft flag is set onreq.querythe way the MCP plugin does it. (2) A rich-text field does not redraw when its value is updated alone: the dispatch must also setinitialValue, or the form holds the new text while the editor still shows the old. Found in the browser. (3) Settings are a global, off by default, Super Admin only (enabledplus a list of sites); the API key is the environment variableAI_ASSIST_API_KEY(orANTHROPIC_API_KEY) when set, else an encrypted, masked, mask-means-keep field (the email and webhooks scheme, labelpolysite-plugin-ai-assist). (4) Every rule is inservice.js: collection allow-list (pages, posts, media only, so form submissions and users are unreachable), site membership (read of the site as the person; the status endpoint checks it too, found when a non-member was shown the box), the role (kit.hasCapability), a per-person rate limit in memory, and per-site daily and monthly limits counted from the usage log. The log holds who, site, action, model and token counts, never text. (5) Reuse of the MCP plugin is by importing../../mcp/src/schema.jsandmarkdown.jsat run time (both dependency-free); without the folder, page building is refused and rich-text suggestions come back as plain text. (6) Providers sit behind one small interface: the official Anthropic SDK by default (structured JSON output, effort instead of thinking settings, refusals and truncation reported) and an OpenAI-compatible endpoint. Not run against the real Anthropic API (no key was available). (7) The dev database ispolysite_dev2, notpolysite(seeDATABASE_URL); a hand-applied migration went to the wrong database once and was undone. Dev schema push created the plugin's tables on its own; the migration was generated withscripts/pluginMigrations.sh plugin ai-assistand passescheck.Right-to-left layouts (L6): logical properties everywhere, and a guard.
<html dir>was already set from the language (Issue #63), but no stylesheet used logical properties, so an Arabic page still had left-aligned bullets, quote bars on the wrong side and a menu pressed to the wrong edge. Fixed in the core, the starter theme, the ten example themes and the plugins' web parts:margin/padding/border-left|rightbecame-inline-start|end,left|right:becameinset-inline-start|end,text-align: leftbecamestart,floatbecameinline-start, Tailwindml-/text-leftbecamems-/text-start, and lopsided corner radii became per-corner logical radii (border-start-end-radius). Things that are not obvious: (1) the four-value shorthand is physical too (margin: 0 0 0 1rem,padding: 1rem 3rem 1rem 0): write the symmetric part and thenmargin-inline-start; (2) an arrow written as a character does not mirror: wrap it inclass="poly-arrow", which the core flips under[dir=rtl](main.css); (3) a centring trick (left: 50%plustranslateX(-50%)) is direction-safe and is deliberately left alone; (4) the guards areapps/web/utils/rtl.test.ts(core) andPolysite-themes/scripts/check-rtl.mjs(themes and plugin web parts;rtl-okon a line marks a deliberate exception). Checked in a real browser with an Arabic page on all eleven themes. Fixed-width or image-based designs, and any theme written later, are the theme author's to keep right.A language that is live or preview only (L4), and the signed preview link. The interim step the follow-up note recommended, instead of Payload's experimental per-locale publish status: the i18n plugin's
site-languagesrow haspreviewLanguages(a subset oflanguages, never the default; it is a plain-text list in the existing_textstable, so no migration), published to the web app assite.extensions.languages.previewLanguages. Things that are not obvious: (1) editors still see and write every offered language (the CMS'sofferedByis unchanged); only the web app'slanguagesOf()drops the hidden ones, so/ar/aboutis a plain 404 and the language is missing from the switcher,hreflang,x-defaultand the sitemap without any of them being edited. (2) A preview-only language is reached through a signed link, not a login: the site is on another origin from the admin, so the web app never sees the editor's session. The link is?_pl=<language>.<expiry>.<hex HMAC-SHA256>over host, language and expiry, made withREVALIDATE_SECRET(which the CMS and the web app already share), valid for 12 hours, for one language on one host (apps/cms/src/i18n/previewToken.tsandapps/web/utils/previewLanguage.tsare the same code). The CMS's Live Preview URL builder now also adds the language prefix and, for a preview-only language, the token (before, Live Preview ignored the language altogether). (3) The page opened that way is sentCache-Control: private, no-store(useResponseHeader;setResponseHeaderdoes not exist in app code and gave a 500) andnoindex, nofollow, and the check that it never reaches the page cache is a real request without the token, straight after (tests/e2e/multilang-extras.e2e.mjs). Links inside the previewed page do not carry the token. (4) The verified language is kept inuseState("polysite-preview-languages")so the browser agrees with the server after hydration. (5) Not done: per-document, per-language publishing; it stays blocked on Payload'sexperimental.localizeStatus.The machine-translation helper (L5): provider-agnostic, off by default, nothing machine-made goes live unreviewed. Lives in the i18n plugin (
src/translate/,src/admin/TranslateButton.js; README "Machine translation"). Design points: (1) Two switches, both needed: the server's environment (POLYSITE_TRANSLATE_PROVIDER=claude|libretranslate|echo, plus_API_KEY,_URL,_MODEL,_MAX_CHARS; the key is never in the database, a collection or git) and the site's owntranslationEnabledcheckbox, which is deliberately not a public field.echomarks text instead of translating it, for development and the end-to-end check, and refuses to run whenNODE_ENV=productionunlessPOLYSITE_TRANSLATE_ALLOW_ECHO=1(only the production-parity end-to-end stack sets it). (2) What is sent: only the prose leaves of the one document (planTranslationinwalk.js: fields the core markedlocalizedwhose type is text, textarea or rich text, found by walking the collection's field configuration, so blocks and arrays are covered and addresses, links and pictures never are) that have no translation yet; rich text sends the text nodes only, in order, so marks, links and headings survive and code blocks are skipped. No form submission, user or visitor data is reachable from it. A size cap applies (30,000 characters by default). (3) The Claude provider uses rawfetch, not the SDK, so the plugin has no dependency to install, with a JSON-schema structured output and effortlowonclaude-haiku-5-5; a refusal, a cut-off and a wrong-length answer are errors, never guesses. (4) Local API calls inside the endpoint must not be handedreq: a call given the endpoint's request keeps that request's locale and fallback, sofallbackLocale: falsewas ignored (every field looked translated: "Nothing left to translate") and a save could land in the wrong language. Reads and the save passuserandoverrideAccess: falseinstead, so the person's own access rules apply. (5) The result is saved withdraft: true; every machine-made language gets a row in the plugin's ownmachine-translationscollection (reviewed: false), shown asfr*in the Languages column, and abeforeChangehook on pages and posts refuses to publish while any row is unreviewed (marking reviewed is a separate, explicit call). Plugin errors needisPublic: trueand astatusto come back as a 4xx message (Payload'sAPIErroris not importable from a plugin). (6) Editing the plugin's server files needs a CMS restart (the dev watcher does not pick them up, Issue #62 andmemory.md).Translated page addresses (L3): an extra localized column, not a column move.
pages.slugstays the shared address and the page's identity (the default language's address, and what every older link points at). The newpages.translatedSlugis a localized nullable text field: empty means/fr/<slug>as before, set means/fr/a-propos. This replaces the follow-up note's plan (makeslugitself localized), which needed a column move and an exception tocheck:migrations; the new column is purely additive (core migrationtranslated_addresses:pages_locales.translated_slug,_pages_v_locales.version_translated_slug,site_urls_locales.localized_path), nothing is dropped and one-language sites are untouched. Things that are not obvious: (1) Lookup (usePayloadPage): in a language other than the default one query asks for the page whose address in that language is X or whose shared slug is X (where[or], limit 2) and prefers the first; a page found only by its shared slug that has its own address there is 301-redirected to it, which is how every old link keeps working. The default language never readstranslatedSlug(and the field refuses a value there). (2) The URL index getslocalizedPath(localized text), written per language bybeforeSync(addressIn), and the switcher,hreflangand the sitemap read every language's address from it withlocale=all(addressInLanguage) instead of putting a prefix on the current path; the index'spathis still the shared address, so everything that read it keeps working, and a content-type plugin may return a per-languageurlPaththrough the newctx.locale. (3) Redirects on rename: a changed or cleared translated address redirects from"<language>/<old address>"(the language is in the path because the same word can be a different page in another language); the redirect lookup in another language asks for both"<language>/x"and"x"and sends the visitor to the target's translated address.Redirects.fromPathis now normalised with the Unicode-awarenormalizeTranslatedSlug. (4) Any script is a valid address: letters and digits of every script are kept (/ar/من-نحنworks; the shared slug still allows ASCII only). (5) Uniqueness is checked per site and language against other pages' translated and shared addresses, and an address cannot be a language code or a first segment the site itself uses; the posts plugin's news section address is not checked (a page callednewswould collide). (6) Not done, on purpose: news posts keep one address under every language (the news section's own address is the same in all of them); links typed inside content or menus stay as typed (a link to an old address is redirected);site-urlsrows written before this change getlocalizedPaththe next time they are saved or reindexed, and until then the page is found by the shared address and redirected. (7) The dev stack needs the web container restarted after changingserver/api/__sitemap__/urls.ts(the sitemap kept listing the old address until it was). Verified end to end bytests/e2e/multilang-extras.e2e.mjs(own French and Arabic addresses, the 301 from the shared one, a rename, a clear, the sitemap,hreflang, the switcher and every validation).
Not Yet Built (by design, per the Rollout Plan)
- Petition/form intake through the Redis buffer (Redis is provisioned but not wired into app logic).
- A multi-step content approval workflow: drafts, version history, scheduled publishing and a ready for review marker exist (Known Issue #61), but nothing enforces an approver. (Single sign-on is the auth plugin; visitor-facing search is the optional
searchplugin: titles, addresses and body text, Known Issue #68.) enabledModulesis data a content-type plugin's routes honour (the posts routes), but Pages, Media and Forms are not independently routable, so nothing gates them per site yet.
Polysite Plugin System (plugins/)
A separate, gitignored-from-core git repo (like themes/) of independently versioned extensions; see plugins/README.md for the contract. The core builds and runs with none of them. How it works in the core app:
apps/cms/src/plugins/discoverPlugins.tsscans/repo/plugins/*/polysite.plugin.jsonat boot and loads eachsrc/index.js. The import goes throughnew Function("s","return import(s)")because Turbopack rejects a dynamic import with a non-literal path. A plugin'spackage.jsonmust have"type":"module"or discovery skips it (the error names this).POLYSITE_PLUGINS_OFF(comma-separated folder names) skips plugins without deleting anything (Known Issue #55).payload.config.tsawaits discovery at top level. Content-type plugins (manifest"kind": "content-type") are applied first, so the core's wiring (multi-tenant scoping, SEO fields, the URL index, Live Preview, the role and site module lists) can read what each of their collections declares about itself incustom.polysite(apps/cms/src/plugins/contentTypes.ts). All other plugins are applied last, so they see every collection others generated. A plugin receives akitof the few core helpers it may use (access checks, slug normalisation, per-site uniqueness) instead of importing from the app. See Known Issue #54.- A Payload plugin is a boot-time config transform, so "per-site enablement" can only be a runtime data check (
Sites.enabledModules), never a per-request toggle. Aglobalplugin is active everywhere; aper-siteone is installed once and each site stores its own setting. - Plugin-hosted admin components are referenced by relative path from
apps/cms/src. That needsturbopack.root= repo root and aresolveAliasfor@payloadcms/uiinnext.config.mjs, andapps/cms/.nextmust be cleared after changing either. The import map is generated (dev container start, production build step) and gitignored; only animportMap.d.tsstub is committed. A plugin can also give its collection a sidebar icon incustom.polysite.icon. - A plugin's web part,
plugins/<name>/web, is a Nuxt layer discovered byapps/web/nuxt.config.tsthe same way themes are. Its public routes register with the core's catch-all page through the content-route registry (apps/web/utils/contentRoutes.ts); a route that returns nothing falls through to the normal page lookup and redirects. serverURLis derived fromCMS_DOMAIN/CMS_SCHEME; without it Payload builds relative password-reset links. Consequence in dev: use the admin throughhttp://polysite.localhost, notlocalhost:3001.- Secrets stored by plugins are AES-256-GCM encrypted with a key HKDF-derived from
PAYLOAD_SECRET(one label per plugin, helper copied into each plugin so they stay independently distributable), masked on read, mask-means-keep on save. - Removing a plugin keeps its data: tables and content stay in the database, a site's or role's tick on the plugin's module is kept rather than rejected,
migrate:createrefuses while a plugin is switched off, andnpm run check:migrationsfails any migration that drops data unless it says so on purpose (Known Issue #55). - The core offers a plugin's web layer two generic hooks (
apps/web/utils/siteExtensions.ts): named slots a plugin fills with components (apprenders once for the whole app;footer-linksrenders where a layout places<TrackingSettingsLink />, a core component that shows whatever is in that slot) and site events (form_submit,page_view) a plugin can listen for. The analytics plugin's consent notice, tag loaders and cookie-settings link use them from the plugin's ownweb/layer; the core has no analytics code (Known Issue #57). - The admin sidebar (
apps/cms/src/components/AdminNav/) has collapsible groups and one level of sub-groups, which Payload itself doesn't have. Groups are Payload'sadmin.group; a collection or global nests under a sub-group by settingadmin.custom.navSubgroup. Open and closed state is kept per browser in local storage (polysite-admin-nav-open); untouched, the first group and whichever group or sub-group holds the current page start open and the rest closed. - A plugin that keeps per-site settings declares a collection with
custom.polysite.siteSettings = { key, publicFields }(manifest"kind": "site-settings"); the core makes it one row per site and exposes the public fields assite.extensions.<key>, so no plugin adds a field to Sites or Users and each plugin owns its tables and migrations (apps/cms/src/plugins/siteSettings.ts,pluginMigrations.ts, Known Issue #64). A manifest may also say"requires": { "polysite": ">=0.1.0" }; a plugin that does not fit the runningPOLYSITE_VERSIONis left out with a log line and keeps its data (plugins/requires.ts,docs/guide/releasing.md). - Payload's own job scheduler only starts once an admin page has rendered, so the core runs due jobs itself every 30 seconds (
jobs/runner.ts, Known Issue #61); the webhooks and WordPress import plugins rely on the same job queue. - The plugins in the repository today:
email,analytics,posts,notifications,auth,i18n,search,share,webhooks,wordpress-import(docs/guide/plugins.mdlists what each does).
Modules & Plugins
Started as a recommendation list; the "Add now" and "Worth adding" tiers below are now installed and verified (blocks/Media/SEO end-to-end, all 5 block types, homepage and a generic CMS page both rendering server-side — see Known Issues #10-13 for what broke along the way and how). Situational/Skip/Deferred tiers are still just that — decisions, not omissions. Package names and versions checked against the npm registry, not guessed.
Nuxt (apps/web)
Installed — tied to the product requirements:
@nuxt/image— official image module (responsive sizing, lazy loading, format conversion). Maps to the product requirements' "on-upload image optimization" requirement and the <1.5s load NFR.@nuxtjs/seo— unified SEO module (meta tags, sitemap, robots.txt, OG/Twitter cards, schema.org). Verified: rendered<title>, canonical link, and schema.org JSON-LD present on both the homepage and/about-us.nuxt-security— secure HTTP headers (CSP, HSTS), rate limiting, CSRF protection.frame-ancestorsexplicitly allows the CMS origin for Live Preview's iframe.@vee-validate/nuxt— form validation, pairing with the product requirements' "server-side validation that filters malformed email addresses" requirement for petitions.@nuxt/ui,@nuxt/icon— required upgradingnuxtitself to 4.5.2 first (see Known Issue #11); both were silently disabled under Nuxt 3 the entire time they were "installed."@nuxt/uiwent unused beyond that fix until Known Issue #28, when its bundled Tailwind CSS v4 dependency became this project's actual styling engine (see Theme Layer's "Styling" note) —@nuxt/ui's own components still aren't used, only the Tailwind pipeline it brings in.
Installed — low-risk, broadly useful:
@vueuse/nuxt— standard Vue composition-utilities collection.@nuxt/fonts— self-hosts/optimizes web fonts, reduces render-blocking and layout shift.
Situational — confirm the need first:
@nuxtjs/i18n— only if a site needs more than one language. Restructures routing/content if adopted later, so decide before building more pages, not after.@pinia/nuxt— only once client-side state gets genuinely complex (multi-step forms, etc.).
Skip:
@nuxt/content— for markdown-file-driven content, which conflicts with the Payload-CMS-driven model already built.
Payload CMS (apps/cms)
Installed — tied to the product requirements:
@payloadcms/plugin-form-builder— official dynamic form builder plugin (fields + a submissions collection). The product requirements ask for exactly this for petitions — likely covers most of that requirement rather than needing to be hand-built.@payloadcms/plugin-seo— official SEO plugin: meta title/description/image fields on any collection. Pairs with@nuxtjs/seoon the frontend — CMS holds the values, Nuxt renders them. Adding it registered a new admin preview component that neededpayload generate:importmapre-run (Known Issue #13).sharp— image-processing dependency for theMediauploads collection (built — local-disk storage, thumbnail/card sizes, WebP conversion), needed for the same "on-upload image optimization" NFR as@nuxt/imageabove. Must be explicitly imported and passed as a top-levelsharpkey inbuildConfig, not just installed (Known Issue in the numbered list above).
Worth adding when the matching feature gets built:
@payloadcms/plugin-redirects— not used: redirects are the core's ownredirectscollection, which adds per-site scoping and form/media targets.@payloadcms/plugin-nested-docs— hierarchical pages (parent/child, breadcrumbs), if nested informational pages become a real need.@payloadcms/plugin-search— installed; it builds thesite-urlsindex of every address a site answers to. Visitor-facing search is the optionalsearchplugin (plugins/search), which reads this index and then re-reads each hit live as an anonymous visitor.
Skip — contradicts a decision already made:
@payloadcms/plugin-cloud-storage/@payloadcms/storage-gcs— would push media uploads to Cloud Storage, but the product requirements already decided uploads stay on the VM's own disk specifically to avoid that managed-service cost. Only reconsider if that cost decision changes.
Installed — pulled forward from Deferred, per an explicit user decision (Known Issue #29):
@payloadcms/plugin-multi-tenant— was originally deferred until after the first deployment; it was pulled forward deliberately. Replaces the hand-rolleduserSiteIds-based site-scoping inPages.ts/Media.tswith Payload's official pattern. See Known Issue #29 for the migration and the two more Known Issue #10 prompts it produced.