Skip to main content

Overview

The Payload pattern deploys a production-ready Payload CMS application to AWS Lambda with:
  • Serverless compute - Lambda functions on ARM64 for cost efficiency
  • Managed PostgreSQL - RDS PostgreSQL (single instance by default, Aurora Serverless v2 optional)
  • S3 storage - media uploads and ISR cache
  • CloudFront CDN - global distribution with HTTPS
  • Automatic patching - Fjall configures the project for Lambda deployment
Payload CMS 3.x is built on Next.js, so it deploys to Lambda via OpenNext.

Quick Start

1. Create a Payload Project

Start with the official Payload website template:
npx create-payload-app@latest my-payload-site --template website
cd my-payload-site

2. Initialise Fjall

fjall create app --name my-payload-site --pattern payload
This creates the fjall/app/ directory with your infrastructure configuration.

3. Deploy

fjall deploy app
Fjall patches the project for Lambda, builds it, and provisions the infrastructure.

Scaffolding Payload

Payload CMS offers several official templates. Choose based on your needs: Full-featured website with pages, posts, and media:
npx create-payload-app@latest my-site --template website
Includes:
  • Pages collection with dynamic routing
  • Posts collection with categories
  • Media collection
  • Header/Footer globals
  • Draft/publish workflow
  • SEO fields

Blank Template

Minimal starting point:
npx create-payload-app@latest my-site --template blank
Includes:
  • Users collection only
  • No pre-built collections
  • Clean slate for custom schemas

E-commerce Template

Full e-commerce setup:
npx create-payload-app@latest my-store --template ecommerce
Includes:
  • Products, Categories, Orders
  • Cart functionality
  • Stripe integration ready

Interactive Setup

Or let Payload guide you:
npx create-payload-app@latest
You’ll be prompted for:
  • Project name
  • Template selection
  • Database type (choose PostgreSQL for Fjall)
  • Package manager
Choose PostgreSQL as your database when scaffolding. Fjall provisions RDS PostgreSQL for the Payload pattern.

Creating Your Fjall App

Basic Setup

After scaffolding Payload, initialise Fjall:
cd my-payload-site
fjall create app --name my-payload-site --pattern payload
payload is the only supported pattern. The interactive picker also lists a Next.js option, but it is experimental.
The nextjs pattern is experimental and not yet deployable. The PatternFactory builds only payload, so selecting Next.js fails at deploy time. Use payload.
Running fjall create app interactively asks Choose your configuration tier? with six options: Standard, Lightweight, Resilient, Enterprise, Tinkerer, and Custom. The Custom tier opens a sub-flow for database type, backup retention, deletion protection, KMS encryption, and Lambda memory/timeout. This creates:
my-payload-site/
├── fjall/
│   └── app/
│       ├── infrastructure.ts    # Your infrastructure definition
│       └── fjall.config.json    # App configuration
├── src/
│   └── ...                      # Your Payload source
└── package.json

Generated Infrastructure

The default infrastructure.ts:
import { App, PatternFactory } from "@fjall/components-infrastructure";

const app = App.getApp("app");

export const infrastructure = app.addPattern(
  PatternFactory.build("App", {
    type: "payload",
    name: "my-payload-site",
  }),
);
This provisions all resources needed for production deployment.

What’s Included

The Payload pattern provisions:
ResourcePurpose
RDS PostgreSQLPayload database (Instance by default, Aurora optional)
S3 Assets BucketMedia uploads, static files
S3 Cache BucketNext.js ISR cache
DynamoDB TableTag-to-path cache for ISR
SQS FIFO QueueBackground revalidation
Lambda (Server)SSR, API routes, admin panel
Lambda (Image)next/image optimisation
Lambda (Revalidation)Background ISR processing
CloudFrontCDN with HTTPS, routing, caching

Automatic Configuration

When you run fjall deploy, the CLI automatically patches your Payload project:

Database Adapter

Replaces your database adapter with @fjall/payload:
// Before
db: postgresAdapter({ pool: { connectionString: process.env.DATABASE_URL } });

// After (automatic)
db: await fjallPostgresAdapter({ prodMigrations: migrations });

S3 Storage

Adds the S3 storage plugin for media uploads:
plugins: [
  s3Storage({
    collections: { media: { prefix: "media" } },
    bucket: process.env.MEDIA_BUCKET_NAME,
  }),
];

Next.js Configuration

Configures Next.js for Lambda:
  • output: 'standalone' for OpenNext
  • File tracing for Sharp (ARM64 binaries)
  • Exclusions for unnecessary files

Build-Time Fixes

Patches page files to prevent database calls during build:
PageFix Applied
[slug]/page.tsxGuard returns [] at build time
posts/[slug]/page.tsxGuard returns [] at build time
page.tsx (home)Uses force-dynamic
posts/page.tsxUses force-dynamic
search/page.tsxUses force-dynamic
All patches are idempotent. Running fjall deploy multiple times is safe.

Deployment Process

First Deploy

fjall deploy app
The first deployment:
  1. Installs @fjall/payload and @payloadcms/storage-s3
  2. Patches configuration files
  3. Generates OpenNext config
  4. Builds with next build
  5. Deploys infrastructure (~10-15 minutes)

Subsequent Deploys

fjall deploy app
Updates are faster as infrastructure exists. Only changed resources update.

Post-Deployment

Access Your Site

After deployment, you’ll see:
Outputs:
  CloudFrontURL: https://d1234567890.cloudfront.net
  • Frontend: https://d1234567890.cloudfront.net
  • Admin Panel: https://d1234567890.cloudfront.net/admin

Create First Admin User

  1. Navigate to /admin
  2. The first-run screen prompts you to create the initial user
  3. Enter an email and password
  4. Sign in to the admin panel

Verify Everything Works

  • Admin panel loads at /admin
  • Can create first admin user
  • Can upload media (stored in S3)
  • Can create/publish content
  • Frontend displays published content
  • Images load correctly

Database Migrations

Payload uses migrations to manage database schema changes.

Generate Migrations

When you change your collections:
npx payload migrate:create
This creates migration files in src/migrations/.

Deploy Migrations

Migrations run automatically on Lambda cold start via prodMigrations:
db: await fjallPostgresAdapter({
  prodMigrations: migrations, // Runs on first request
  migrationDir: "./src/migrations",
});
Always generate and commit migrations before deploying schema changes.

Environment Variables

Fjall automatically configures these Lambda environment variables:

Database

DATABASE_HOST=your-db-endpoint.rds.amazonaws.com
DATABASE_PORT=<assigned-port>   # Fjall may assign a non-default port
DATABASE_NAME=payload
DATABASE_SSL=true
# Username and password fetched from Secrets Manager

OpenNext Cache

CACHE_BUCKET_NAME=your-cache-bucket
CACHE_DYNAMO_TABLE=your-tag-cache-table
REVALIDATION_QUEUE_URL=https://sqs.region.amazonaws.com/...

Media Storage

MEDIA_BUCKET_NAME=your-assets-bucket

Custom Domain

To use a custom domain:
// fjall/app/infrastructure.ts
import { App, PatternFactory } from "@fjall/components-infrastructure";

const app = App.getApp("app");

export const infrastructure = app.addPattern(
  PatternFactory.build("App", {
    type: "payload",
    name: "my-payload-site",
    domain: "cms.yourdomain.com", // Automatically creates certificate and DNS
  }),
);
Or with manual certificate configuration:
export const infrastructure = app.addPattern(
  PatternFactory.build("App", {
    type: "payload",
    name: "my-payload-site",
    cdn: {
      domainNames: ["cms.yourdomain.com"],
      certificateArn: "arn:aws:acm:us-east-1:123456789:certificate/abc-123",
    },
  }),
);
The certificate must be in us-east-1 for CloudFront.

Pattern Parameters

The Payload pattern accepts configuration options for each component:

Full Configuration Example

import { App, PatternFactory } from "@fjall/components-infrastructure";

const app = App.getApp("app");

export const infrastructure = app.addPattern(
  PatternFactory.build("App", {
    type: "payload",
    name: "my-payload-site",

    // Simple domain setup (auto-creates certificate)
    domain: "cms.example.com",

    // Database configuration
    database: {
      databaseName: "payload",
      deletionProtection: true,
      databaseInsights: {
        retentionPeriod: 7,
      },
    },

    // Lambda configuration
    compute: {
      server: {
        memorySize: 1536, // MB for server function
        timeout: 30, // Seconds
      },
      imageOptimisation: {
        memorySize: 1536, // For image processing
      },
    },

    // S3 configuration
    storage: {
      assets: { versioned: true },
      cache: { versioned: false },
      media: { versioned: true },
    },

    // Additional environment variables for server Lambda
    environment: {
      LOG_LEVEL: "info",
    },
  }),
);

Root Options

OptionTypeDefaultDescription
type"payload"RequiredPattern type
namestringRequiredApplication name
domainstring-Custom domain (auto-creates certificate and DNS)
environmentobject-Additional environment variables for server Lambda

Database Options

OptionTypeDefaultDescription
type"Instance" | "Aurora""Instance"Database engine variant
databaseNamestringApp nameName of the database
backupRetentionnumber7Days to retain automated backups
deletionProtectionbooleantruePrevent accidental deletion
databaseInsightsobject-Database Insights configuration
databaseInsights.retentionPeriodnumber7Days to retain database insights data
See the DatabaseFactory documentation for the full list of available database parameters.

CDN Options

OptionTypeDefaultDescription
domainNamesstring[]-Custom domain names
certificateArnstring-ACM certificate ARN (must be in us-east-1)

Compute Options

OptionTypeDefaultDescription
server.memorySizenumber1536Server Lambda memory in MB
server.timeoutnumber30Server Lambda timeout in seconds
server.ephemeralStorageSizenumber512Ephemeral storage in MB
imageOptimisation.memorySizenumber1536Image Lambda memory in MB
revalidation.memorySizenumber768Revalidation Lambda memory in MB

Messaging Options

OptionTypeDefaultDescription
messaging.revalidationQueue.visibilityTimeoutnumber30SQS visibility timeout in seconds
messaging.revalidationQueue.messageRetentionPeriodnumber345600Message retention in seconds

Breaking Out Components

Need more control? You can break out of the pattern and use individual factories.

Why Break Out?

  • Add additional resources (Redis, queues, etc.)
  • Customise networking (VPC peering, private subnets)
  • Share resources between multiple apps
  • Fine-grained IAM permissions

From Pattern to Factories

The Payload pattern can be approximated with this factory composition:
This example demonstrates factory composition concepts. The actual Payload pattern includes additional configuration like environment variables, IAM policies, and CDN behaviours that are automatically configured for optimal OpenNext deployment.
import {
  App,
  DatabaseFactory,
  StorageFactory,
  ComputeFactory,
  CdnFactory,
  MessagingFactory,
} from "@fjall/components-infrastructure";
import * as lambda from "aws-cdk-lib/aws-lambda";

const app = App.getApp("MyPayloadApp");

// 1. Database (Aurora PostgreSQL)
const database = app.addDatabase(
  DatabaseFactory.build("PayloadDB", {
    type: "Aurora",
    databaseName: "payload",
  }),
);

// 2. Assets Storage (S3) - Static assets like JS, CSS, images
const assetsBucket = app.addStorage(StorageFactory.build("AssetsBucket", {}));

// 3. Media Storage (S3) - User uploads
const mediaBucket = app.addStorage(StorageFactory.build("MediaBucket", {}));

// 4. Cache Storage (S3) - ISR cache
const cacheBucket = app.addStorage(StorageFactory.build("CacheBucket", {}));

// 5. Tag Cache (DynamoDB)
const tagCache = app.addDatabase(
  DatabaseFactory.build("TagCache", {
    type: "DynamoDB",
    partitionKey: { name: "tag", type: "S" },
    sortKey: { name: "path", type: "S" },
    globalSecondaryIndexes: [
      {
        indexName: "revalidate",
        partitionKey: { name: "path", type: "S" },
        sortKey: { name: "revalidatedAt", type: "N" },
      },
    ],
  }),
);

// 6. Revalidation Queue (SQS FIFO)
const revalidationQueue = app.addMessaging(
  MessagingFactory.build("RevalidationQueue", {
    type: "queue",
    queueType: "fifo",
    deadLetterQueue: { enabled: true, maxReceiveCount: 3 },
  }),
);

// 7. Server Lambda (OpenNext)
// The actual pattern uses code deployment; container deployment shown for advanced use
const serverFunction = app.addCompute(
  ComputeFactory.build("Server", {
    type: "lambda",
    deployment: "code",
    handler: "index.handler",
    code: lambda.Code.fromAsset(".open-next/server-function"),
    memorySize: 1536,
    timeout: 30,
    connections: [
      database,
      assetsBucket,
      mediaBucket,
      cacheBucket,
      tagCache,
      revalidationQueue,
    ],
  }),
);

// 8. Image Optimisation Lambda
const imageFunction = app.addCompute(
  ComputeFactory.build("ImageOptimiser", {
    type: "lambda",
    deployment: "code",
    handler: "index.handler",
    code: lambda.Code.fromAsset(".open-next/image-optimization-function"),
    memorySize: 1536,
    connections: [assetsBucket],
  }),
);

// 9. Revalidation Lambda (processes ISR queue)
const revalidationFunction = app.addCompute(
  ComputeFactory.build("Revalidation", {
    type: "lambda",
    deployment: "code",
    handler: "index.handler",
    code: lambda.Code.fromAsset(".open-next/revalidation-function"),
    memorySize: 768,
    timeout: 30,
    connections: [revalidationQueue, tagCache, cacheBucket],
  }),
);

// 10. CloudFront CDN
// Note: CdnFactory uses originType to determine the origin source
const cdn = app.addCdn(
  CdnFactory.build("CDN", {
    originType: "auto",
    origin: serverFunction,
    behaviours: [
      {
        pathPattern: "/_next/image*",
        origin: imageFunction,
        cachePolicy: "CACHING_OPTIMIZED",
      },
      {
        pathPattern: "/_next/static/*",
        origin: assetsBucket,
        cachePolicy: "CACHING_OPTIMIZED",
      },
      {
        pathPattern: "/media/*",
        origin: mediaBucket,
        cachePolicy: "CACHING_OPTIMIZED",
      },
      {
        pathPattern: "/_next/data/*",
        origin: serverFunction,
        cachePolicy: "CACHING_DISABLED",
      },
    ],
  }),
);

export const infrastructure = app;

Adding Extra Resources

Once broken out, easily add more resources:
// Add a separate worker queue
const workerQueue = app.addMessaging(
  MessagingFactory.build("WorkerQueue", {
    type: "queue",
    queueType: "standard",
  }),
);

// Add a worker Lambda
const worker = app.addCompute(
  ComputeFactory.build("Worker", {
    type: "lambda",
    deployment: "code",
    handler: "worker.handler",
    connections: [database, workerQueue],
  }),
);

Sharing Resources Between Apps

Create shared infrastructure:
// shared/infrastructure.ts
import { App, DatabaseFactory } from "@fjall/components-infrastructure";

const shared = App.getApp("Shared");

export const sharedDatabase = shared.addDatabase(
  DatabaseFactory.build("SharedDB", {
    type: "Aurora",
    databaseName: "shared",
  }),
);

export const infrastructure = shared;
Reference from your Payload app:
// app/infrastructure.ts
import { App, ComputeFactory } from "@fjall/components-infrastructure";
import { sharedDatabase } from "../shared/infrastructure";

const app = App.getApp("PayloadApp");

const server = app.addCompute(
  ComputeFactory.build("Server", {
    type: "lambda",
    deployment: "container",
    connections: [sharedDatabase], // Reference shared resource
  }),
);

export const infrastructure = app;

Troubleshooting

Build Fails: “Error occurred prerendering page”

Cause: Page tries to access database at build time. Fix: The Fjall CLI handles this automatically. Update to the latest version:
npm update -g @fjall/cli
fjall deploy app

403 on Admin Panel Actions

Cause: CSRF configuration issue. Fix: Set csrf: [] explicitly in payload.config.ts:
export default buildConfig({
  cors: ["*"],
  csrf: [], // Must be explicit, not omitted
  // ...
});

Media Uploads Disappear

Cause: Using local filesystem instead of S3. Fix: Fjall automatically configures S3 storage. If you have customised plugins/index.ts, keep the S3 plugin in the list.

”relation does not exist”

Cause: Migrations not generated or prodMigrations not configured. Fix:
npx payload migrate:create
git add src/migrations/
git commit -m "Add migrations"
fjall deploy app

Costs

Estimated monthly costs for a low-traffic site:
ResourceCost
RDS PostgreSQL~$12-25 (db.t4g.micro, always on)
Lambda~$0-5 (pay per request)
S3~$0.50-2
CloudFront~$1-5
Total~$15-40/month
Set database.type: "Aurora" to use Aurora Serverless v2, which scales to zero when idle and can lower the database cost for bursty or low-traffic sites.

Next Steps

Add Resources

Extend your app with additional AWS services

Compute Factory

Learn about Lambda and ECS options

Storage Factory

Configure databases and S3 buckets

CI/CD

Automate deployments with Buildkite

Fjall: External: