* docs: add page-copy single-source spec and implementation plan * feat(content): add typed home section schemas and migrate home copy into content Add 7 bespoke home* section schemas (+ techStack ctas) to the page section union and populate content/pages/en/home.json with home copy moved verbatim from messages. * refactor(web): source home page copy from @repo/content; drop home namespace from messages Feed every home section from content via findSection/data props (replacing next-intl getTranslations), remove the home namespace from messages/en.json, and add a regression guard plus content-based tests. Render output unchanged. * docs: add get-started/movement page-copy dedup spec and plan * feat(content): add get-started & movement section schemas and content docs Move get-started/movement page copy verbatim into content/pages so it is single-sourced in @repo/content. * refactor(web): source get-started/movement copy from content; drop those namespaces from messages Feed both pages from content via findSection/data props (replacing next-intl), switch them to content-based metadata, delete pages.getStarted/movement from messages, and add content-based tests + ownership guard. Render unchanged. * docs: add navbar-pages CMS copy spec and plan * feat(content): add navbar page section schemas + Pages docs (book, brand-kit, research, node-programme, lambda-prize, manifesto, media, podcast, broadcast, tech-stack explorer) * refactor(web): source navbar pages copy from content; drop those namespaces from messages Migrate book, brand-kit, research (incl. rich-text via a tagged-text renderer), node-programme, lambda-prize, manifesto, media, podcast, logos-broadcast-network, and the tech-stack explorer to read copy from content via findSection/data props and content-based metadata; remove the migrated pages.* namespaces from messages; update guards + contracts. Render output unchanged. * feat(web): source activist/coalition pages copy from content (CMS title/description) Migrate activistBuilder, activistLeaderSteward, and coalitionPartner page metadata from next-intl messages.pages.* into @repo/content PageCopy JSON, following the established bookCopy pattern. - Add activistBuilderCopySectionSchema, activistLeaderStewardCopySectionSchema, coalitionPartnerCopySectionSchema to packages/content/src/schemas/pages.ts and wire them into the pageSectionSchema discriminated union - Create content/pages/en/activist-builder.json, activist-leader-steward.json, coalition-partner.json with verbatim title/description/heading values from the old messages namespace - Switch all three route pages from createTranslatedPageMetadata to createPageMetadata(ROUTE), removing the NAMESPACE constant - Remove activistBuilder, activistLeaderSteward, coalitionPartner from apps/web/messages/en.json pages object - Add schema parse tests, renderToStaticMarkup page tests, messages ownership guards, and content-route contract entries for all three pages * chore(web): remove dead faq (pages.faq + unused FaqSection components) * fix: remove stale faq links * fix: pass payload env through turbo build * fix: preserve lambda prize support labels * fix: preserve live copy after master rebase * fix: address page copy review feedback * refactor: move design guide copy to content * feat: add schemas for home and page copy sections - Introduced home section schemas including homeSocialProof, homeChoosePath, homeDecide, homeStartBuilding, homeAbout, homeUseCases, and homeBuilderPortal. - Added page copy section schemas such as getStartedCopy, movementCopy, bookCopy, designGuideCopy, activistBuilderCopy, activistLeaderStewardCopy, coalitionPartnerCopy, researchCopy, nodeProgrammeCopy, lambdaPrizeCopy, mediaCopy, podcastCopy, broadcastCopy, and manifestoCopy. - Created shared schema for section keys to maintain consistency across sections. * chore(web): remove dead pages.faq from messages * fix: resolve page copy migration conflicts * fix: source connect form page copy from content * fix: remove duplicated afform page copy exports * fix: stop generating afform page copy exports * fix(cms): require only server actions key * fix(cms): load env files before build validation * fix(cms): make env loader available during build * fix(cms): load next env via require * test(cms): isolate missing env validation * feat: add homepage testnet highlight * fix: restore lambda prize support links
9.4 KiB
Logos CMS
Payload CMS 3.83 admin app on Next.js 16. Admin UI lives at /admin, the app runs on port 3001.
Postgres is not the production source of truth for content — it stores users, sessions, drafts, and PR cache. Published content changes flow through the GitHub "Create PR" workflow targeting develop. See AGENTS.md for the content-workflow rules.
Prerequisites
- Node.js
>=24 <25 - pnpm
11.1.0(via Corepack:corepack enable) - A reachable Postgres instance (local, Supabase, or the bundled Docker Postgres)
- For Docker deploys: Docker + Docker Compose
All commands run from the monorepo root so pnpm can resolve workspace packages (@repo/content, etc.).
Development
Local development runs next dev directly against your own Postgres, with Payload schema auto-sync enabled.
1. Configure environment
cp apps/cms/.env.example apps/cms/.env
Fill in at minimum:
| Variable | Purpose |
|---|---|
PAYLOAD_SECRET |
Required to boot. Generate with openssl rand -hex 32. |
DATABASE_URL |
Postgres connection string. |
NEXT_PUBLIC_SERVER_URL |
CMS origin — defaults to http://localhost:3001. |
NEXT_PUBLIC_WEB_URL |
Web frontend origin — defaults to http://localhost:3000. |
The GITHUB_* variables are only required to exercise the Admin "Create PR" action. See .env.example for every option and its default.
Isolate dev data from other environments by setting
PAYLOAD_DB_SCHEMA=payload_devwhen sharing one Postgres database.
2. Run
pnpm --filter cms dev # from the repo root
# or
pnpm dev:cms # turbo wrapper (also builds workspace deps)
The dev wrapper (scripts/dev.ts) starts Next on port 3001 and auto-accepts Payload's interactive schema-push prompt. It refuses to run in deployment environments — production/staging must use the build/start path with reviewed migrations, never local dev schema sync.
Common tasks
pnpm --filter cms lint # eslint, zero warnings
pnpm --filter cms check-types # payload typegen + tsc --noEmit
pnpm --filter cms test # node:test suite
pnpm --filter cms generate-types # regenerate Payload types after schema changes
pnpm --filter cms generate-import-map
Run generate-types after any Payload collection or schema change that affects generated types.
Production deployment (Docker)
The production image bundles the built monorepo and runs next start. The build context must be the monorepo root so pnpm can resolve workspace packages.
Option A — Docker Compose (recommended)
Brings up the CMS plus a self-hosted Postgres on an internal network. From the repo root:
# 1. Create the env file and fill in real values
cp apps/cms/.env.docker.example .env.docker
# 2. Build and start
docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build
Required values in .env.docker:
| Variable | Notes |
|---|---|
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
Credentials for the bundled Postgres. |
PAYLOAD_SECRET |
openssl rand -hex 32. |
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY |
Build time stable Server Actions key. Generate with openssl rand -base64 32. Use the same value across CMS deployments. |
NEXT_PUBLIC_SERVER_URL |
Baked into the client bundle at build time — must be the real public CMS origin. Changing it requires a rebuild. |
NEXT_PUBLIC_WEB_URL |
Build-time public web origin (same caveat). |
CMS_PORT |
Host port mapped to the container's :3000 (default 3001). |
The compose file constructs DATABASE_URL automatically from the Postgres vars and connects over the internal network — Postgres is not published to the host by default.
What's persisted:
pgdatavolume — Postgres datacms_uploadsvolume — uploaded media (/app/apps/web/public/cms/uploads)
The container entrypoint applies database migrations before the server starts, so the schema is ready on first boot — no manual step needed. See Database migrations below for how this works and how to add a migration.
Put a reverse proxy (TLS termination) in front of CMS_PORT in production.
Option B — standalone image
Build and run the image against an external Postgres (e.g. Supabase). Build from the repo root:
export NEXT_SERVER_ACTIONS_ENCRYPTION_KEY='<stable openssl rand -base64 32 value>'
docker build -f apps/cms/Dockerfile \
--build-arg NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="$NEXT_SERVER_ACTIONS_ENCRYPTION_KEY" \
--build-arg NEXT_PUBLIC_SERVER_URL=https://cms.logos.co \
--build-arg NEXT_PUBLIC_WEB_URL=https://logos.co \
-t logos-cms .
Run, injecting runtime secrets:
docker run -d --name logos-cms -p 3001:3000 \
-e DATABASE_URL='postgresql://user:password@host:5432/db' \
-e PAYLOAD_SECRET='<openssl rand -hex 32>' \
-e NEXT_PUBLIC_SERVER_URL='https://cms.logos.co' \
-e NEXT_PUBLIC_WEB_URL='https://logos.co' \
-v cms_uploads:/app/apps/web/public/cms/uploads \
logos-cms
NEXT_PUBLIC_*are inlined into the client bundle at build time, so they must be the real public origins passed as--build-arg.DATABASE_URLandPAYLOAD_SECRETare not baked in — they're supplied at runtime, and the build never connects to the database.
Build vs. runtime variables
| Variable | When it's read | Notes |
|---|---|---|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY |
Build time | Stable key required by Next Server Actions across CMS deployments. |
NEXT_PUBLIC_SERVER_URL, NEXT_PUBLIC_WEB_URL |
Build time (inlined into client bundle) | Must be real public origins; changing requires a rebuild. |
DATABASE_URL, PAYLOAD_SECRET |
Runtime | Never baked into the image. |
PAYLOAD_DB_SCHEMA, PAYLOAD_DB_POOL_MAX |
Runtime | Defaults: payload, 10. |
PAYLOAD_DB_PUSH |
Runtime | Only affects local dev (NODE_ENV !== production). Ignored in production — see Database migrations. |
GITHUB_* |
Runtime | Optional — only for the Admin "Create PR" action. |
For Vercel CMS deployments, set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY as a
project environment variable for production and preview builds.
Database migrations
Payload never auto-syncs the schema (push) when NODE_ENV=production — that includes both self-hosted Docker and Vercel. Production schema is owned entirely by the reviewed SQL migrations in src/migrations/.
How it runs in Docker: the container's entrypoint runs payload migrate before next start. Migrations are idempotent — already-applied ones (tracked in the payload-migrations table) are skipped — so a fresh database is set up on first boot and restarts are no-ops.
Local dev uses schema auto-push instead (PAYLOAD_DB_PUSH defaults on; set PAYLOAD_DB_PUSH=false to disable), so you normally don't run migrations during development.
When you change a collection or schema, generate a migration and commit it alongside the code:
pnpm --filter cms migrate:create # generate a new migration from current schema
pnpm --filter cms migrate:status # list applied / pending migrations
pnpm --filter cms migrate # apply pending migrations (run against the target DB)
The generated file under src/migrations/ and the updated src/migrations/index.ts must be committed — production applies exactly what's in that directory.
Caveats:
- A failed migration blocks the deploy. The entrypoint runs under
set -e, so ifpayload migrateexits non-zero the container stops before serving traffic. Test migrations against a staging database first. - Single-instance assumption. The entrypoint migrates on every container start. If you scale the CMS to more than one replica, run migrations as a separate one-shot step (or gate them behind a leader) so replicas don't migrate the same database concurrently — the bundled
docker-compose.prod.ymlruns a singlecmsservice, so this is safe as-is.
First admin user
There is no environment variable for the initial admin account — Payload never bakes credentials into the image or config. The first admin is created interactively:
- After the container boots and migrations run, the
userstable is empty. - Open
/adminon the public CMS origin (NEXT_PUBLIC_SERVER_URL, e.g.https://cms.logos.co). - Payload serves a "Create first user" screen — the email / password you enter there becomes the first admin account.
Additional accounts are created afterward from the Users collection inside Admin.
Do this immediately after the first deploy. Until the first user exists, anyone who reaches
/admincan claim the admin account. Keep/adminbehind your reverse proxy / network controls until you've created it.
Health check
The image exposes a health endpoint used by Docker's HEALTHCHECK:
GET /api/health
Environment file reference
| File | Used by | Committed? |
|---|---|---|
.env.example |
Local dev template → copy to apps/cms/.env |
template only |
.env.docker.example |
Docker Compose template → copy to repo-root .env.docker |
template only |
Never commit the filled-in .env / .env.docker files — secrets are injected at runtime, never baked into the image.