Loading...
AWS Series Part 3 of 5 Most Technical
April 21, 2026

Payload CMS on AWS ECS Fargate

Part 3: Dockerizing Payload CMS and the importMap Problem

Production Payload CMS on AWS — 5-Part Series

Part 1: Architecture  |  Part 2: Database  |  Part 3: Docker and the importMap (this post)  |  Part 4: ALB Routing  |  Part 5: Lexical Frontend

This is the most technically dense post in the series. The importMap problem is underdocumented and the most common reason Payload CMS deployments fail silently in containerized environments. Read this one carefully.

Payload CMS 3.x is built on Next.js 15 with React Server Components. It uses a build-time mechanism called the importMap to register client-side components — specifically the Lexical rich text editor and its features.

Normally, running payload generate:importmap populates this file automatically. In a Docker build, that command requires a live database connection to introspect your collection configurations. In CI/CD pipelines, that connection is not available at build time.

The result: The Docker build succeeds. The container starts. Payload appears to work. But any field defined as richText silently does not render in the admin UI. No error. No warning. The field just is not there.

This part documents exactly how to solve it — and every other silent failure point in the Payload CMS Docker build.

Step 1: Verify Your Package Exports

Before writing your importMap, verify what your installed version of @payloadcms/richtext-lexical actually exports. These change between versions and documentation does not always keep up.

cd payload

# Check the client export path
cat node_modules/@payloadcms/richtext-lexical/package.json | \
  python3 -c "import sys,json; exp=json.load(sys.stdin).get('exports',{}); print(json.dumps(exp.get('./client',{}), indent=2))"

# List exported component names
cat node_modules/@payloadcms/richtext-lexical/dist/exports/client/index.d.ts | \
  grep "^export" | grep -i "client\|field\|cell"

# Check the rsc export path
cat node_modules/@payloadcms/richtext-lexical/package.json | \
  python3 -c "import sys,json; exp=json.load(sys.stdin).get('exports',{}); print(json.dumps(exp.get('./rsc',{}), indent=2))"

# List rsc exports
cat node_modules/@payloadcms/richtext-lexical/dist/exports/server/rsc.d.ts | \
  grep "^export"

For version 3.79.x you should see RichTextField, all the feature clients, and UploadFeatureClient on the client path. On the rsc path: RscEntryLexicalField, RscEntryLexicalCell.

Critical: RscEntryLexicalField and RscEntryLexicalCell come from the /rsc path — not /client. Most incorrect examples online use the wrong path, which is why the fields disappear silently.

Step 2: Populate the importMap

Create src/app/(payload)/importMap.js manually:

import { RichTextField as RichTextField_0 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_1 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_2 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_3 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_4 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_5 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_6 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_7 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_8 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_9 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_10 } from '@payloadcms/richtext-lexical/client'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_11 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_12 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_13 } from '@payloadcms/richtext-lexical/client'
import { RscEntryLexicalField as RscEntryLexicalField_14 } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalCell as RscEntryLexicalCell_15 } from '@payloadcms/richtext-lexical/rsc'

export const importMap = {
  "@payloadcms/richtext-lexical/client#RichTextField": RichTextField_0,
  "@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_1,
  "@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_2,
  "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_3,
  "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_4,
  "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_5,
  "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_6,
  "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_7,
  "@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_8,
  "@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_9,
  "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_10,
  "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_11,
  "@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_12,
  "@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_13,
  "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_14,
  "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_15,
}

Step 3: Fix the Layout Files

Two layout files are required. Both have non-obvious gotchas that are not documented anywhere.

Root layout — src/app/layout.tsx

import React from 'react'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return <>{children}</>
}
Do NOT add <html> and <body> tags here. Payload's admin layout handles the full HTML document. Adding them causes React hydration error #418 — almost impossible to trace back to this cause without knowing it.

Payload layout — src/app/(payload)/layout.tsx

import React from 'react'
import { RootLayout } from '@payloadcms/next/layouts'
import { handleServerFunctions } from '@payloadcms/next/utilities'
import config from '@payload-config'
import '@payloadcms/next/css'

export default async function Layout({ children }: { children: React.ReactNode }) {
  const serverFunction = await handleServerFunctions({
    config: await config,
  })
  return (
    <RootLayout config={await config} serverFunction={serverFunction}>
      {children}
    </RootLayout>
  )
}

Three things worth noting: handleServerFunctions comes from @payloadcms/next/utilities, the prop is serverFunction singular (not plural), and the CSS import must be here or the admin UI renders unstyled.

Step 4: Configure payload.config.ts for Production

import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { fileURLToPath } from 'url'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

export default buildConfig({
  serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || '',
  admin: {
    user: 'users',
    theme: 'light',
  },
  collections: [/* your collections */],
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URL },
    schemaName: 'public',
  }),
  secret: process.env.PAYLOAD_SECRET || '',
  typescript: {
    outputFile: path.resolve(dirname, 'payload-types.ts'),
  },
  graphQL: { disable: true }, // reduces bundle size if not needed
})

serverURL must be the full public URL of your deployment. Payload uses it to generate absolute URLs in API responses. A missing or incorrect serverURL causes CORS errors and broken media URLs.

Step 5: The Dockerfile

FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Dependencies
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm install --legacy-peer-deps

# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN mkdir -p public
RUN npm run generate:types || true
RUN npm run build

# Runtime
FROM base AS runner
WORKDIR /app

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir -p ./public

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NODE_ENV=production

CMD ["node", "server.js"]

Key decisions: generate:types || true prevents the build from failing if type generation fails without a database. There is no generate:importmap — that command requires a live database. We skip it entirely and rely on the manually populated importMap from Step 2. The mkdir -p public is required — Payload expects this directory to exist even if empty.

Configure next.config.mjs for standalone output:

import { withPayload } from '@payloadcms/next/withPayload'

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
}

export default withPayload(nextConfig)

Step 6: Build and Push to ECR

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=us-east-1

# Create ECR repositories
aws ecr create-repository --repository-name payload-cms --region $REGION
aws ecr create-repository --repository-name payload-frontend --region $REGION

# Login to ECR
aws ecr get-login-password --region $REGION | \
  docker login --username AWS --password-stdin \
  $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com

# Build and push — platform flag required for Apple Silicon
cd payload
docker build \
  --platform linux/amd64 \
  -t $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/payload-cms:latest \
  .

docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/payload-cms:latest
If you are building on an Apple Silicon Mac (M1/M2/M3), the --platform linux/amd64 flag is required. Without it, Docker builds an ARM image that fails to run on ECS Fargate.

Step 7: Create the ECS Task Definition

Save as payload-task-definition.json:

{
  "family": "payload-cms",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::ACCOUNT_ID:role/payload-ecs-task-execution-role",
  "containerDefinitions": [{
    "name": "payload",
    "image": "ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/payload-cms:latest",
    "portMappings": [{ "containerPort": 3000, "protocol": "tcp" }],
    "environment": [
      { "name": "NODE_ENV", "value": "production" },
      { "name": "PORT", "value": "3000" }
    ],
    "secrets": [
      {
        "name": "DATABASE_URL",
        "valueFrom": "arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:payload/database-url-SUFFIX"
      },
      {
        "name": "PAYLOAD_SECRET",
        "valueFrom": "arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:payload/secret-key-SUFFIX"
      },
      {
        "name": "PAYLOAD_PUBLIC_SERVER_URL",
        "valueFrom": "arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:payload/public-url-SUFFIX"
      }
    ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "/ecs/payload-cms",
        "awslogs-region": "us-east-1",
        "awslogs-stream-prefix": "payload"
      }
    },
    "healthCheck": {
      "command": ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"],
      "interval": 30,
      "timeout": 5,
      "retries": 3,
      "startPeriod": 60
    }
  }]
}
# Register the task definition
aws ecs register-task-definition \
  --cli-input-json file://payload-task-definition.json \
  --region $REGION

# Create the ECS cluster
aws ecs create-cluster \
  --cluster-name payload-cms \
  --capacity-providers FARGATE \
  --region $REGION

# Create CloudWatch log group
aws logs create-log-group \
  --log-group-name /ecs/payload-cms \
  --region $REGION

# Create the service
aws ecs create-service \
  --cluster payload-cms \
  --service-name payload-service \
  --task-definition payload-cms \
  --desired-count 1 \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={
    subnets=[subnet-XXXXXXXX,subnet-YYYYYYYY],
    securityGroups=[$ECS_SG],
    assignPublicIp=ENABLED
  }" \
  --region $REGION

Verify the Deployment

Tail the logs and watch for the success line:

aws logs tail /ecs/payload-cms --follow --region $REGION

# You want to see:
# Payload CMS is now running on port 3000

# You do NOT want to see:
# getFromImportMap: PayloadComponent not found in importMap

If you see the importMap warning, the rsc exports are missing from your importMap. Go back to Step 2.

Troubleshooting

Admin loads but rich text fields are missing
The importMap is populated but missing the rsc entries. Add RscEntryLexicalField and RscEntryLexicalCell from @payloadcms/richtext-lexical/rsc.

React hydration error #418
The root layout.tsx contains <html><body> tags. Remove them.

Container exits immediately after startup
Check CloudWatch logs. Common causes: DATABASE_URL is wrong or unreachable, PAYLOAD_SECRET is empty, or the Next.js build output path is incorrect.

Custom admin components cause type errors
Reference custom components as string import paths in payload.config.ts, not as direct React imports:

// Wrong
components: { graphics: { Icon: MyIcon } }

// Correct
components: { graphics: { Icon: '/src/components/admin/Icon#MyIcon' } }

Part 4 covers ALB path-based routing — including the rule priority ordering that breaks the admin UI if you get it wrong, and the internal URL pattern that lets your Astro frontend talk to Payload without external DNS round-trips.

The importMap fix documented here is one of eight undocumented problems we solved building the Chippewa County municipal CMS. Read the case study.

Share Article
Spent hours on this problem?

We have already solved it in production.

Payload CMS on AWS ECS Fargate, deployed at fixed price. Fixed price confirmed within 48 hours.

Jae S. Jung has been building since 1997: infrastructure, SaaS platforms, legacy migrations, distributed teams across four continents. Not drawing diagrams and handing them off. Actually building. That's the philosophy behind WAM DevTech. AI doesn't replace nearly 30 years of that. It amplifies it.

Share Article