feat(migrations): add initial migration for database schema setup

- Created initial migration file to set up the database schema with necessary tables and types.
- Added migration index file to register the new migration.
- Introduced a production-ready Docker Compose configuration for deploying the CMS with a self-hosted PostgreSQL database.
This commit is contained in:
jinhojang6
2026-05-29 08:55:41 +09:00
parent 6ef717c74f
commit afb28862c8
12 changed files with 4436 additions and 2 deletions
+37
View File
@@ -0,0 +1,37 @@
# Keep the Docker build context small and reproducible.
# pnpm-lock.yaml + sources are needed; everything below is not.
# Dependencies (reinstalled inside the image)
**/node_modules
.pnpm-store
**/.pnpm-store
# Build outputs / caches
**/.next
**/out
**/dist
**/build
**/.turbo
**/coverage
**/*.tsbuildinfo
# VCS / CI
.git
.github
# Local env files — secrets are injected at runtime via docker-compose, never baked
**/.env
**/.env.*
!**/.env.example
# Vercel
**/.vercel
# Editor / OS noise
**/.DS_Store
.claude
# Docker files themselves
**/Dockerfile
**/.dockerignore
docker-compose*.yml
+1
View File
@@ -13,6 +13,7 @@ node_modules
.env.development.local
.env.test.local
.env.production.local
.env.docker
.claude
# Testing
+41
View File
@@ -0,0 +1,41 @@
# =============================================================================
# Environment for docker-compose.prod.yml — copy to .env.docker at the repo
# root and fill in real values. NEVER commit the filled-in file.
#
# cp apps/cms/.env.docker.example .env.docker
# docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build
# =============================================================================
# --- Self-hosted Postgres ----------------------------------------------------
POSTGRES_USER=logos
POSTGRES_PASSWORD=change-me-strong-password
POSTGRES_DB=logos_cms
# --- Payload core ------------------------------------------------------------
# Generate with: openssl rand -hex 32
PAYLOAD_SECRET=change-me-openssl-rand-hex-32
# --- Public URLs (BAKED INTO THE CLIENT BUNDLE AT BUILD TIME) -----------------
# Must be the real public origins. Changing these requires a rebuild.
NEXT_PUBLIC_SERVER_URL=https://cms.logos.co
NEXT_PUBLIC_WEB_URL=https://logos.co
# --- Host port mapping -------------------------------------------------------
# Host port that maps to the container's :3000. Put a reverse proxy (TLS) in
# front of this in production.
CMS_PORT=3001
# --- Postgres tuning (optional — defaults shown) -----------------------------
# PAYLOAD_DB_SCHEMA=payload
# PAYLOAD_DB_POOL_MAX=10
# Schema auto-sync on boot. Keep "true" for the first deploy; flip to "false"
# once SQL migrations are wired up so prod no longer mutates schema on boot.
# PAYLOAD_DB_PUSH=true
# --- GitHub "Create PR" workflow (optional admin feature) --------------------
GITHUB_OWNER=logos-co
GITHUB_REPO=logos-web
GITHUB_APP_ID=123456
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
GITHUB_INSTALLATION_ID=12345678
GITHUB_PR_BASE_BRANCH=develop
+95
View File
@@ -0,0 +1,95 @@
# =============================================================================
# Production image for apps/cms (Payload CMS 3.83 + Next.js 16)
#
# Build context MUST be the monorepo root so pnpm can resolve workspace
# packages (@repo/content). From the repo root:
#
# docker build -f apps/cms/Dockerfile \
# --build-arg NEXT_PUBLIC_SERVER_URL=https://cms.example.com \
# --build-arg NEXT_PUBLIC_WEB_URL=https://www.example.com \
# -t logos-cms .
#
# NEXT_PUBLIC_* values are inlined into the client bundle at build time, so
# they must be the real public origins here — not at runtime.
# =============================================================================
FROM node:24-bookworm-slim AS base
ENV PNPM_HOME=/pnpm
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
# -----------------------------------------------------------------------------
# deps — install only what cms (and its workspace deps) need, with a frozen
# lockfile for reproducible builds.
# -----------------------------------------------------------------------------
FROM base AS deps
# Copy the workspace manifest layer first for better layer caching.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
COPY apps/cms/package.json apps/cms/package.json
COPY packages/config/package.json packages/config/package.json
COPY packages/content/package.json packages/content/package.json
COPY packages/tokens/package.json packages/tokens/package.json
COPY packages/types/package.json packages/types/package.json
COPY packages/ui/package.json packages/ui/package.json
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --filter cms...
# -----------------------------------------------------------------------------
# builder — copy sources and build the Next.js production output.
# -----------------------------------------------------------------------------
FROM base AS builder
ARG NEXT_PUBLIC_SERVER_URL
ARG NEXT_PUBLIC_WEB_URL
# Build-time placeholders: payload.config.ts validates these env vars at module
# load (during `next build`). DATABASE_URL/PAYLOAD_SECRET are NOT inlined and
# are overridden with real values at runtime; the build never connects to the DB.
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
NEXT_PUBLIC_SERVER_URL=${NEXT_PUBLIC_SERVER_URL} \
NEXT_PUBLIC_WEB_URL=${NEXT_PUBLIC_WEB_URL} \
DATABASE_URL=postgresql://build:build@localhost:5432/build \
PAYLOAD_SECRET=build-time-placeholder-secret
# Bring in the resolved node_modules from the deps stage, then layer the full
# monorepo sources on top.
COPY --from=deps /app/ ./
COPY . .
RUN pnpm --filter cms build
# -----------------------------------------------------------------------------
# runner — production runtime. Runs `next start` via pnpm.
# -----------------------------------------------------------------------------
FROM base AS runner
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
# Run as an unprivileged user (with a home dir so any tool that needs a cache
# has a writable location).
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --create-home --uid 1001 --gid nodejs nextjs
# Copy the fully built monorepo (sources + node_modules + .next output).
COPY --from=builder --chown=nextjs:nodejs /app ./
# Media uploads are written here at runtime; mount a volume on this path so
# uploaded files survive container restarts (see docker-compose).
RUN mkdir -p /app/apps/web/public/cms/uploads \
&& chown -R nextjs:nodejs /app/apps/web/public/cms/uploads \
&& chmod +x /app/apps/cms/docker-entrypoint.sh
USER nextjs
WORKDIR /app/apps/cms
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# The entrypoint applies DB migrations, then execs the CMD below. `next start`
# runs via the workspace-local binary to avoid invoking pnpm/corepack at runtime
# (corepack would try to download a package manager into a non-writable cache
# and crash-loop the container).
ENTRYPOINT ["/app/apps/cms/docker-entrypoint.sh"]
CMD ["node_modules/.bin/next", "start"]
+174
View File
@@ -0,0 +1,174 @@
# 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`](./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
```bash
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`](./.env.example) for every option and its default.
> Isolate dev data from other environments by setting `PAYLOAD_DB_SCHEMA=payload_dev` when sharing one Postgres database.
### 2. Run
```bash
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
```bash
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:
```bash
# 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_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:
- **`pgdata`** volume — Postgres data
- **`cms_uploads`** volume — 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](#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:
```bash
docker build -f apps/cms/Dockerfile \
--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:
```bash
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_URL` and `PAYLOAD_SECRET` are **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_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](#database-migrations). |
| `GITHUB_*` | Runtime | Optional — only for the Admin "Create PR" action. |
### 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/`](./src/migrations/).
**How it runs in Docker:** the container's [entrypoint](./docker-entrypoint.sh) 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:
```bash
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.
### 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`](./.env.example) | Local dev template → copy to `apps/cms/.env` | template only |
| [`.env.docker.example`](./.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.
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Production container entrypoint.
#
# Payload does NOT push the schema in production (see @payloadcms/db-postgres
# connect.js: pushDevSchema only runs when NODE_ENV !== 'production'). The
# repo policy is migrations for any deployed environment, so we apply pending
# SQL migrations before the server starts accepting traffic.
#
# `payload migrate` is idempotent — already-applied migrations are skipped, so
# this is safe to run on every (re)start.
set -e
echo "[entrypoint] Applying Payload migrations..."
PAYLOAD_CONFIG_PATH=payload.config.ts node_modules/.bin/payload migrate
echo "[entrypoint] Migrations done. Starting server..."
exec "$@"
+3
View File
@@ -12,6 +12,9 @@
"check-types": "next typegen && tsc --noEmit",
"generate-types": "PAYLOAD_CONFIG_PATH=payload.config.ts payload generate:types",
"generate-import-map": "PAYLOAD_CONFIG_PATH=payload.config.ts payload generate:importmap",
"migrate": "PAYLOAD_CONFIG_PATH=payload.config.ts payload migrate",
"migrate:create": "PAYLOAD_CONFIG_PATH=payload.config.ts payload migrate:create",
"migrate:status": "PAYLOAD_CONFIG_PATH=payload.config.ts payload migrate:status",
"save-as-pr-smoke": "tsx scripts/save-as-pr-smoke.ts",
"sync-from-content": "tsx scripts/sync-from-content.ts"
},
+2 -2
View File
@@ -41,8 +41,8 @@ import {
export const Rfps: CollectionConfig = {
slug: 'rfps',
labels: {
plural: 'RFSs',
singular: 'RFS',
plural: 'RFPs',
singular: 'RFP',
},
admin: {
components: recentPrAdminComponents,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,492 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
// Payload's `migrate` CLI does not auto-create a custom schema (the adapter
// uses schemaName: 'payload'). Without this, a fresh/self-hosted Postgres
// fails the first migration with: schema "payload" does not exist.
// Keep this line if the initial migration is ever regenerated.
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS "payload";`)
await db.execute(sql`
CREATE TYPE "payload"."enum_pages_status" AS ENUM('draft', 'published');
CREATE TYPE "payload"."enum__pages_v_version_status" AS ENUM('draft', 'published');
CREATE TYPE "payload"."enum_builder_listing_settings_page" AS ENUM('ideas', 'rfps');
CREATE TYPE "payload"."enum_builder_listing_settings_default_view" AS ENUM('grid', 'list');
CREATE TYPE "payload"."enum_rfps_status" AS ENUM('draft', 'review', 'published', 'archived');
CREATE TYPE "payload"."enum_rfps_reward_currency" AS ENUM('USDC');
CREATE TYPE "payload"."enum_ideas_status" AS ENUM('draft', 'review', 'published', 'archived');
CREATE TYPE "payload"."enum_ideas_reward_currency" AS ENUM('USDC');
CREATE TYPE "payload"."enum_builder_resources_status" AS ENUM('draft', 'review', 'published', 'archived');
CREATE TYPE "payload"."enum_circles_status" AS ENUM('draft', 'review', 'published', 'archived');
CREATE TYPE "payload"."enum_circle_events_status" AS ENUM('draft', 'review', 'published', 'archived');
CREATE TYPE "payload"."enum_circle_initiatives_status" AS ENUM('draft', 'review', 'published', 'archived');
CREATE TYPE "payload"."enum_circle_resources_status" AS ENUM('draft', 'review', 'published', 'archived');
CREATE TYPE "payload"."enum_content_change_requests_status" AS ENUM('draft', 'open', 'merged', 'closed');
CREATE TABLE "payload"."users_sessions" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"created_at" timestamp(3) with time zone,
"expires_at" timestamp(3) with time zone NOT NULL
);
CREATE TABLE "payload"."users" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"email" varchar NOT NULL,
"reset_password_token" varchar,
"reset_password_expiration" timestamp(3) with time zone,
"salt" varchar,
"hash" varchar,
"login_attempts" numeric DEFAULT 0,
"lock_until" timestamp(3) with time zone
);
CREATE TABLE "payload"."media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"public_path" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric
);
CREATE TABLE "payload"."pages" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar,
"title" varchar,
"route" varchar,
"page" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"_status" "payload"."enum_pages_status" DEFAULT 'draft'
);
CREATE TABLE "payload"."_pages_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_slug" varchar,
"version_title" varchar,
"version_route" varchar,
"version_page" jsonb,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "payload"."enum__pages_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
);
CREATE TABLE "payload"."builder_hub_settings" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"settings" jsonb NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."builder_listing_settings" (
"id" serial PRIMARY KEY NOT NULL,
"page" "payload"."enum_builder_listing_settings_page" NOT NULL,
"title" varchar NOT NULL,
"description" varchar NOT NULL,
"breadcrumb_label" varchar NOT NULL,
"submit_cta_label" varchar NOT NULL,
"submit_cta_href" varchar NOT NULL,
"submit_cta_external" boolean DEFAULT false,
"default_view" "payload"."enum_builder_listing_settings_default_view" DEFAULT 'grid' NOT NULL,
"page_size" numeric NOT NULL,
"previous_label" varchar NOT NULL,
"next_label" varchar NOT NULL,
"bottom_cta_title" varchar NOT NULL,
"bottom_cta_label" varchar NOT NULL,
"bottom_cta_href" varchar NOT NULL,
"bottom_cta_external" boolean DEFAULT false,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."site_settings_content" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"settings" jsonb NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."site_navigation_content" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"navigation" jsonb NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."site_footer_content" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"footer" jsonb NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."rfps" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"status" "payload"."enum_rfps_status" DEFAULT 'draft' NOT NULL,
"title" varchar NOT NULL,
"tagline" varchar,
"summary" varchar NOT NULL,
"description" varchar NOT NULL,
"cta_label" varchar,
"reward_amount" numeric NOT NULL,
"reward_currency" "payload"."enum_rfps_reward_currency" DEFAULT 'USDC' NOT NULL,
"reward_xp" numeric,
"apply_url" varchar NOT NULL,
"featured" boolean DEFAULT false,
"order" numeric,
"published_at" timestamp(3) with time zone,
"closes_at" timestamp(3) with time zone,
"owner_name" varchar,
"owner_handle" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."rfps_texts" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"text" varchar
);
CREATE TABLE "payload"."ideas" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"status" "payload"."enum_ideas_status" DEFAULT 'draft' NOT NULL,
"title" varchar NOT NULL,
"tagline" varchar,
"summary" varchar NOT NULL,
"description" varchar NOT NULL,
"cta_label" varchar,
"submitter_name" varchar,
"submitter_handle" varchar NOT NULL,
"reward_amount" numeric,
"reward_currency" "payload"."enum_ideas_reward_currency" DEFAULT 'USDC',
"reward_xp" numeric,
"discussion_url" varchar,
"featured" boolean DEFAULT false,
"order" numeric,
"submitted_at" timestamp(3) with time zone,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."ideas_texts" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"text" varchar
);
CREATE TABLE "payload"."builder_resources" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"status" "payload"."enum_builder_resources_status" DEFAULT 'draft' NOT NULL,
"title" varchar NOT NULL,
"description" varchar NOT NULL,
"cta_label" varchar NOT NULL,
"href" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."circles_organizers" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"handle" varchar
);
CREATE TABLE "payload"."circles" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"status" "payload"."enum_circles_status" DEFAULT 'draft' NOT NULL,
"name" varchar NOT NULL,
"description" varchar NOT NULL,
"city" varchar NOT NULL,
"country" varchar NOT NULL,
"region" varchar,
"lat" numeric NOT NULL,
"lng" numeric NOT NULL,
"timezone" varchar NOT NULL,
"member_count" numeric,
"discord_channel" varchar,
"discord_url" varchar,
"forum_url" varchar,
"join_url" varchar NOT NULL,
"image_src" varchar,
"image_alt" varchar,
"image_width" numeric,
"image_height" numeric,
"order" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."circle_events_hosted_by" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL
);
CREATE TABLE "payload"."circle_events" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"status" "payload"."enum_circle_events_status" DEFAULT 'draft' NOT NULL,
"circle_slug" varchar NOT NULL,
"title" varchar NOT NULL,
"location_label" varchar NOT NULL,
"starts_at" timestamp(3) with time zone NOT NULL,
"ends_at" timestamp(3) with time zone,
"timezone" varchar NOT NULL,
"venue_name" varchar,
"address" varchar,
"event_url" varchar,
"featured" boolean DEFAULT false,
"sequence_number" numeric,
"image_src" varchar,
"image_alt" varchar,
"image_width" numeric,
"image_height" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."circle_initiatives" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"status" "payload"."enum_circle_initiatives_status" DEFAULT 'draft' NOT NULL,
"circle_slug" varchar NOT NULL,
"href" varchar NOT NULL,
"location_label" varchar NOT NULL,
"title" varchar NOT NULL,
"description" varchar NOT NULL,
"cta_label" varchar NOT NULL,
"image_src" varchar,
"image_alt" varchar,
"image_width" numeric,
"image_height" numeric,
"featured" boolean DEFAULT false,
"order" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."circle_resources" (
"id" serial PRIMARY KEY NOT NULL,
"slug" varchar NOT NULL,
"status" "payload"."enum_circle_resources_status" DEFAULT 'draft' NOT NULL,
"title" varchar NOT NULL,
"description" varchar NOT NULL,
"cta_label" varchar NOT NULL,
"href" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."content_change_requests" (
"id" serial PRIMARY KEY NOT NULL,
"content_type" varchar NOT NULL,
"target_path" varchar NOT NULL,
"branch_name" varchar NOT NULL,
"pull_request_number" numeric,
"pull_request_url" varchar,
"status" "payload"."enum_content_change_requests_status" DEFAULT 'draft' NOT NULL,
"commit_sha" varchar,
"created_by_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."payload_kv" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar NOT NULL,
"data" jsonb NOT NULL
);
CREATE TABLE "payload"."payload_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar,
"value" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."payload_preferences_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
CREATE TABLE "payload"."payload_migrations" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar,
"batch" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload"."users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."_pages_v" ADD CONSTRAINT "_pages_v_parent_id_pages_id_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."pages"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."rfps_texts" ADD CONSTRAINT "rfps_texts_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."rfps"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."ideas_texts" ADD CONSTRAINT "ideas_texts_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."ideas"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."circles_organizers" ADD CONSTRAINT "circles_organizers_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "payload"."circles"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."circle_events_hosted_by" ADD CONSTRAINT "circle_events_hosted_by_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "payload"."circle_events"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."content_change_requests" ADD CONSTRAINT "content_change_requests_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "payload"."users"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "users_sessions_order_idx" ON "payload"."users_sessions" USING btree ("_order");
CREATE INDEX "users_sessions_parent_id_idx" ON "payload"."users_sessions" USING btree ("_parent_id");
CREATE INDEX "users_updated_at_idx" ON "payload"."users" USING btree ("updated_at");
CREATE INDEX "users_created_at_idx" ON "payload"."users" USING btree ("created_at");
CREATE UNIQUE INDEX "users_email_idx" ON "payload"."users" USING btree ("email");
CREATE INDEX "media_updated_at_idx" ON "payload"."media" USING btree ("updated_at");
CREATE INDEX "media_created_at_idx" ON "payload"."media" USING btree ("created_at");
CREATE UNIQUE INDEX "media_filename_idx" ON "payload"."media" USING btree ("filename");
CREATE UNIQUE INDEX "pages_slug_idx" ON "payload"."pages" USING btree ("slug");
CREATE UNIQUE INDEX "pages_route_idx" ON "payload"."pages" USING btree ("route");
CREATE INDEX "pages_updated_at_idx" ON "payload"."pages" USING btree ("updated_at");
CREATE INDEX "pages_created_at_idx" ON "payload"."pages" USING btree ("created_at");
CREATE INDEX "pages__status_idx" ON "payload"."pages" USING btree ("_status");
CREATE INDEX "_pages_v_parent_idx" ON "payload"."_pages_v" USING btree ("parent_id");
CREATE INDEX "_pages_v_version_version_slug_idx" ON "payload"."_pages_v" USING btree ("version_slug");
CREATE INDEX "_pages_v_version_version_route_idx" ON "payload"."_pages_v" USING btree ("version_route");
CREATE INDEX "_pages_v_version_version_updated_at_idx" ON "payload"."_pages_v" USING btree ("version_updated_at");
CREATE INDEX "_pages_v_version_version_created_at_idx" ON "payload"."_pages_v" USING btree ("version_created_at");
CREATE INDEX "_pages_v_version_version__status_idx" ON "payload"."_pages_v" USING btree ("version__status");
CREATE INDEX "_pages_v_created_at_idx" ON "payload"."_pages_v" USING btree ("created_at");
CREATE INDEX "_pages_v_updated_at_idx" ON "payload"."_pages_v" USING btree ("updated_at");
CREATE INDEX "_pages_v_latest_idx" ON "payload"."_pages_v" USING btree ("latest");
CREATE UNIQUE INDEX "builder_hub_settings_slug_idx" ON "payload"."builder_hub_settings" USING btree ("slug");
CREATE INDEX "builder_hub_settings_updated_at_idx" ON "payload"."builder_hub_settings" USING btree ("updated_at");
CREATE INDEX "builder_hub_settings_created_at_idx" ON "payload"."builder_hub_settings" USING btree ("created_at");
CREATE UNIQUE INDEX "builder_listing_settings_page_idx" ON "payload"."builder_listing_settings" USING btree ("page");
CREATE INDEX "builder_listing_settings_updated_at_idx" ON "payload"."builder_listing_settings" USING btree ("updated_at");
CREATE INDEX "builder_listing_settings_created_at_idx" ON "payload"."builder_listing_settings" USING btree ("created_at");
CREATE UNIQUE INDEX "site_settings_content_slug_idx" ON "payload"."site_settings_content" USING btree ("slug");
CREATE INDEX "site_settings_content_updated_at_idx" ON "payload"."site_settings_content" USING btree ("updated_at");
CREATE INDEX "site_settings_content_created_at_idx" ON "payload"."site_settings_content" USING btree ("created_at");
CREATE UNIQUE INDEX "site_navigation_content_slug_idx" ON "payload"."site_navigation_content" USING btree ("slug");
CREATE INDEX "site_navigation_content_updated_at_idx" ON "payload"."site_navigation_content" USING btree ("updated_at");
CREATE INDEX "site_navigation_content_created_at_idx" ON "payload"."site_navigation_content" USING btree ("created_at");
CREATE UNIQUE INDEX "site_footer_content_slug_idx" ON "payload"."site_footer_content" USING btree ("slug");
CREATE INDEX "site_footer_content_updated_at_idx" ON "payload"."site_footer_content" USING btree ("updated_at");
CREATE INDEX "site_footer_content_created_at_idx" ON "payload"."site_footer_content" USING btree ("created_at");
CREATE UNIQUE INDEX "rfps_slug_idx" ON "payload"."rfps" USING btree ("slug");
CREATE INDEX "rfps_updated_at_idx" ON "payload"."rfps" USING btree ("updated_at");
CREATE INDEX "rfps_created_at_idx" ON "payload"."rfps" USING btree ("created_at");
CREATE INDEX "rfps_texts_order_parent" ON "payload"."rfps_texts" USING btree ("order","parent_id");
CREATE UNIQUE INDEX "ideas_slug_idx" ON "payload"."ideas" USING btree ("slug");
CREATE INDEX "ideas_updated_at_idx" ON "payload"."ideas" USING btree ("updated_at");
CREATE INDEX "ideas_created_at_idx" ON "payload"."ideas" USING btree ("created_at");
CREATE INDEX "ideas_texts_order_parent" ON "payload"."ideas_texts" USING btree ("order","parent_id");
CREATE UNIQUE INDEX "builder_resources_slug_idx" ON "payload"."builder_resources" USING btree ("slug");
CREATE INDEX "builder_resources_updated_at_idx" ON "payload"."builder_resources" USING btree ("updated_at");
CREATE INDEX "builder_resources_created_at_idx" ON "payload"."builder_resources" USING btree ("created_at");
CREATE INDEX "circles_organizers_order_idx" ON "payload"."circles_organizers" USING btree ("_order");
CREATE INDEX "circles_organizers_parent_id_idx" ON "payload"."circles_organizers" USING btree ("_parent_id");
CREATE UNIQUE INDEX "circles_slug_idx" ON "payload"."circles" USING btree ("slug");
CREATE INDEX "circles_updated_at_idx" ON "payload"."circles" USING btree ("updated_at");
CREATE INDEX "circles_created_at_idx" ON "payload"."circles" USING btree ("created_at");
CREATE INDEX "circle_events_hosted_by_order_idx" ON "payload"."circle_events_hosted_by" USING btree ("_order");
CREATE INDEX "circle_events_hosted_by_parent_id_idx" ON "payload"."circle_events_hosted_by" USING btree ("_parent_id");
CREATE UNIQUE INDEX "circle_events_slug_idx" ON "payload"."circle_events" USING btree ("slug");
CREATE INDEX "circle_events_circle_slug_idx" ON "payload"."circle_events" USING btree ("circle_slug");
CREATE INDEX "circle_events_updated_at_idx" ON "payload"."circle_events" USING btree ("updated_at");
CREATE INDEX "circle_events_created_at_idx" ON "payload"."circle_events" USING btree ("created_at");
CREATE UNIQUE INDEX "circle_initiatives_slug_idx" ON "payload"."circle_initiatives" USING btree ("slug");
CREATE INDEX "circle_initiatives_circle_slug_idx" ON "payload"."circle_initiatives" USING btree ("circle_slug");
CREATE INDEX "circle_initiatives_updated_at_idx" ON "payload"."circle_initiatives" USING btree ("updated_at");
CREATE INDEX "circle_initiatives_created_at_idx" ON "payload"."circle_initiatives" USING btree ("created_at");
CREATE UNIQUE INDEX "circle_resources_slug_idx" ON "payload"."circle_resources" USING btree ("slug");
CREATE INDEX "circle_resources_updated_at_idx" ON "payload"."circle_resources" USING btree ("updated_at");
CREATE INDEX "circle_resources_created_at_idx" ON "payload"."circle_resources" USING btree ("created_at");
CREATE INDEX "content_change_requests_content_type_idx" ON "payload"."content_change_requests" USING btree ("content_type");
CREATE INDEX "content_change_requests_target_path_idx" ON "payload"."content_change_requests" USING btree ("target_path");
CREATE UNIQUE INDEX "content_change_requests_branch_name_idx" ON "payload"."content_change_requests" USING btree ("branch_name");
CREATE INDEX "content_change_requests_status_idx" ON "payload"."content_change_requests" USING btree ("status");
CREATE INDEX "content_change_requests_created_by_idx" ON "payload"."content_change_requests" USING btree ("created_by_id");
CREATE INDEX "content_change_requests_updated_at_idx" ON "payload"."content_change_requests" USING btree ("updated_at");
CREATE INDEX "content_change_requests_created_at_idx" ON "payload"."content_change_requests" USING btree ("created_at");
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload"."payload_kv" USING btree ("key");
CREATE INDEX "payload_preferences_key_idx" ON "payload"."payload_preferences" USING btree ("key");
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload"."payload_preferences" USING btree ("updated_at");
CREATE INDEX "payload_preferences_created_at_idx" ON "payload"."payload_preferences" USING btree ("created_at");
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload"."payload_preferences_rels" USING btree ("order");
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload"."payload_preferences_rels" USING btree ("parent_id");
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload"."payload_preferences_rels" USING btree ("path");
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload"."payload_preferences_rels" USING btree ("users_id");
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload"."payload_migrations" USING btree ("updated_at");
CREATE INDEX "payload_migrations_created_at_idx" ON "payload"."payload_migrations" USING btree ("created_at");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "payload"."users_sessions" CASCADE;
DROP TABLE "payload"."users" CASCADE;
DROP TABLE "payload"."media" CASCADE;
DROP TABLE "payload"."pages" CASCADE;
DROP TABLE "payload"."_pages_v" CASCADE;
DROP TABLE "payload"."builder_hub_settings" CASCADE;
DROP TABLE "payload"."builder_listing_settings" CASCADE;
DROP TABLE "payload"."site_settings_content" CASCADE;
DROP TABLE "payload"."site_navigation_content" CASCADE;
DROP TABLE "payload"."site_footer_content" CASCADE;
DROP TABLE "payload"."rfps" CASCADE;
DROP TABLE "payload"."rfps_texts" CASCADE;
DROP TABLE "payload"."ideas" CASCADE;
DROP TABLE "payload"."ideas_texts" CASCADE;
DROP TABLE "payload"."builder_resources" CASCADE;
DROP TABLE "payload"."circles_organizers" CASCADE;
DROP TABLE "payload"."circles" CASCADE;
DROP TABLE "payload"."circle_events_hosted_by" CASCADE;
DROP TABLE "payload"."circle_events" CASCADE;
DROP TABLE "payload"."circle_initiatives" CASCADE;
DROP TABLE "payload"."circle_resources" CASCADE;
DROP TABLE "payload"."content_change_requests" CASCADE;
DROP TABLE "payload"."payload_kv" CASCADE;
DROP TABLE "payload"."payload_preferences" CASCADE;
DROP TABLE "payload"."payload_preferences_rels" CASCADE;
DROP TABLE "payload"."payload_migrations" CASCADE;
DROP TYPE "payload"."enum_pages_status";
DROP TYPE "payload"."enum__pages_v_version_status";
DROP TYPE "payload"."enum_builder_listing_settings_page";
DROP TYPE "payload"."enum_builder_listing_settings_default_view";
DROP TYPE "payload"."enum_rfps_status";
DROP TYPE "payload"."enum_rfps_reward_currency";
DROP TYPE "payload"."enum_ideas_status";
DROP TYPE "payload"."enum_ideas_reward_currency";
DROP TYPE "payload"."enum_builder_resources_status";
DROP TYPE "payload"."enum_circles_status";
DROP TYPE "payload"."enum_circle_events_status";
DROP TYPE "payload"."enum_circle_initiatives_status";
DROP TYPE "payload"."enum_circle_resources_status";
DROP TYPE "payload"."enum_content_change_requests_status";`)
}
+9
View File
@@ -0,0 +1,9 @@
import * as migration_20260528_233905_initial from './20260528_233905_initial';
export const migrations = [
{
up: migration_20260528_233905_initial.up,
down: migration_20260528_233905_initial.down,
name: '20260528_233905_initial'
},
];
+72
View File
@@ -0,0 +1,72 @@
# =============================================================================
# Production deployment for apps/cms with a self-hosted Postgres.
#
# Usage:
# 1. cp apps/cms/.env.docker.example .env.docker # then fill in real values
# 2. docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build
#
# The container entrypoint applies Payload SQL migrations before starting, so
# the schema is set up on first boot — no manual migration step is needed.
# (In production Payload never schema-pushes; PAYLOAD_DB_PUSH only affects dev.)
# =============================================================================
services:
postgres:
image: postgres:17-bookworm
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
# Not published to the host — only the cms service reaches it over the
# internal network. Uncomment to expose for external admin access.
# ports:
# - "5432:5432"
cms:
build:
context: .
dockerfile: apps/cms/Dockerfile
args:
# Baked into the client bundle at build time — must be the real origins.
NEXT_PUBLIC_SERVER_URL: ${NEXT_PUBLIC_SERVER_URL}
NEXT_PUBLIC_WEB_URL: ${NEXT_PUBLIC_WEB_URL}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3000
# Connects to the self-hosted Postgres over the compose network.
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
PAYLOAD_SECRET: ${PAYLOAD_SECRET}
NEXT_PUBLIC_SERVER_URL: ${NEXT_PUBLIC_SERVER_URL}
NEXT_PUBLIC_WEB_URL: ${NEXT_PUBLIC_WEB_URL}
PAYLOAD_DB_SCHEMA: ${PAYLOAD_DB_SCHEMA:-payload}
PAYLOAD_DB_PUSH: ${PAYLOAD_DB_PUSH:-true}
PAYLOAD_DB_POOL_MAX: ${PAYLOAD_DB_POOL_MAX:-10}
# GitHub "Create PR" workflow (optional — admin feature).
GITHUB_OWNER: ${GITHUB_OWNER:-}
GITHUB_REPO: ${GITHUB_REPO:-}
GITHUB_APP_ID: ${GITHUB_APP_ID:-}
GITHUB_APP_PRIVATE_KEY: ${GITHUB_APP_PRIVATE_KEY:-}
GITHUB_INSTALLATION_ID: ${GITHUB_INSTALLATION_ID:-}
GITHUB_PR_BASE_BRANCH: ${GITHUB_PR_BASE_BRANCH:-develop}
ports:
- "${CMS_PORT:-3001}:3000"
volumes:
# Persist uploaded media (Payload writes to apps/web/public/cms/uploads).
- cms_uploads:/app/apps/web/public/cms/uploads
volumes:
pgdata:
cms_uploads: