Skip to main content

Overview

The Payload pattern deploys a Payload CMS application to AWS Lambda. Payload 3.x runs on Next.js, so Fjall builds it with OpenNext and fronts it with CloudFront. One pattern statement provisions the whole stack:
  • Serverless compute: three ARM64 Lambda functions (server, image, revalidation)
  • Managed PostgreSQL: RDS Instance by default, Aurora Serverless v2 on the Resilient tier
  • S3 storage: three buckets for build assets, ISR cache, and media uploads
  • CloudFront CDN: HTTPS, path-based routing, and edge caching
  • Automatic patching: the CLI rewrites your Payload project for Lambda at deploy time

Prerequisites

Quick start

1. Scaffold a Payload project

Start with the official website template:
Choose PostgreSQL when prompted. Fjall provisions RDS PostgreSQL for this pattern and does not support MongoDB.

2. Create the Fjall application

--pattern and --tier are honoured only on the non-interactive path. On a TTY, fjall create app opens the application wizard, which has no pattern step and silently drops both flags. Either pass --non-interactive as above, or run bare fjall create and choose Patterns from the menu.
This writes fjall/my-payload-site/infrastructure.ts plus the application’s config file. The scaffold directory is named after --name.

3. Deploy

Fjall patches the project for Lambda, builds it with OpenNext, and provisions the infrastructure.

Choosing a pattern

The interactive picker offers two patterns:
A nextjs pattern exists in the registry but has no infrastructure construct, so it is not offered in the picker and --pattern nextjs is refused at create time. For a Next.js application that needs a server, use --pattern payload. For a static export, use --pattern staticsite.

Choosing a tier

Pattern applications have four tiers, prompted as Choose your configuration tier?: Standard retains backups for 14 days with deletion protection on. Lightweight retains 7 days with deletion protection off. Resilient retains 30 days with customer-managed KMS encryption. Custom replaces the preset with seven prompts: database type, instance type (Instance only), backup retention, deletion protection, KMS encryption (Aurora only), Lambda memory, and Lambda timeout.
The six-option list (Standard, Lightweight, Resilient, Enterprise, Tinkerer, Custom) belongs to the plain application wizard reached with -t/--type. Pattern applications have four tiers and no Enterprise or Tinkerer option.

What the pattern provisions

CloudFront routes four path patterns:

The generated infrastructure file

fjall/my-payload-site/infrastructure.ts looks like this:
The database and compute blocks are always written out, even when you accept every default, so the file shows you what you are getting rather than hiding it behind the pattern. storage, messaging, cdn and environment are the opposite: they appear only once you configure them. The values above are the Standard tier, which is also what set network and backup on App.getApp. Only server picks up the tier’s Lambda sizing; imageOptimisation and revalidation keep the construct defaults on every tier.
The first argument to PatternFactory.build is a derived construct id, toPascalCase(name) plus the pattern suffix, so my-payload-site yields MyPayloadSitePayload. fjall modify pattern and fjall remove pattern locate the statement by that id. Rename it by hand and the CLI can no longer find the pattern.
Edit the second argument to configure the pattern. See Configuration reference below.

What the CLI patches

fjall deploy rewrites the Payload project for Lambda before it builds. Every patch is idempotent, so repeated deploys are safe, and the CLI skips any file it does not find.

Packages

Installs @fjall/payload and @payloadcms/storage-s3.

Database adapter

Replaces the Postgres adapter in payload.config.ts and spreads Fjall’s CORS and CSRF defaults into buildConfig:
fjallDefaults() locks CORS and CSRF to NEXT_PUBLIC_SERVER_URL when a custom domain is set, and stays permissive otherwise.

S3 media storage

Adds Fjall’s S3 plugin to src/plugins/index.ts:
awsS3Storage() reads MEDIA_BUCKET_NAME and AWS_REGION from the Lambda environment and falls back to disk storage locally. It also patches src/collections/Media.ts to drop staticDir in production.

Next.js configuration

Adds output: 'standalone' for OpenNext and an outputFileTracingIncludes block that traces runtime dependencies Lambda would otherwise miss under pnpm.

Build-time guards

Payload pages that query the database during next build fail without a database connection. The CLI guards the website template’s pages: These paths track the Payload website template layout. In a project with a different structure, guard your own build-time queries with isBuildTime() from @fjall/payload:

Deploying

Bare fjall deploy opens an application picker. The positional argument is the application name, not a tier and not a domain.

Pre-flight checks

The first deploy streams a checklist before any AWS call:
  1. Detecting application pattern
  2. Configuring Payload for AWS
  3. Creating database migrations
  4. Generating Payload import map
  5. Building Next.js application
  6. Synthesising infrastructure
  7. Comparing template hashes
  8. Validating Docker configuration
  9. Validating SSM secrets

Approval gate

With changes pending, Fjall prints a deployment plan and asks Approve this plan and deploy?, defaulting to No. Rejecting the plan changes nothing. Pass --skip-confirmation or --auto-approve in CI. Resources marked for replacement or deletion trigger a separate typed-name consent step that no flag bypasses. The first deploy takes roughly 10 to 15 minutes because CloudFormation creates the VPC, database, and distribution. Later deploys update only what changed. If nothing changed, the run ends with Already up to date.

After the deploy

Find your URL

The CDN stack exports the distribution domain as <PatternName>DistributionDomainName. Read it from the deploy output or from the application status:
  • Frontend: https://d1234567890.cloudfront.net
  • Admin panel: https://d1234567890.cloudfront.net/admin

Create the first admin user

  1. Open /admin
  2. The first-run screen prompts for the initial user
  3. Enter an email address and password
  4. Sign in

Verify the deployment

  • Admin panel loads at /admin
  • First admin user can be created
  • Media uploads land in S3
  • Content can be created and published
  • Published content renders on the frontend
  • Images load through /_next/image

Database migrations

Payload manages schema changes with migration files.

Generate a migration

Apply migrations

Migrations run on Lambda cold start via prodMigrations, which the CLI wires into the adapter for you:
Generate and commit migrations before deploying a schema change. A deploy without them leaves the Lambda querying tables that do not exist.

Environment variables

Fjall injects these into the server Lambda. Do not set them yourself. Database credentials come from Secrets Manager, never from an environment variable. Add your own variables through the pattern’s environment block, which merges over the injected set.

Custom domain

Set domain and Fjall creates the ACM certificate and the Route53 alias record:
To supply your own certificate instead, use the cdn block:
The certificate must live in us-east-1 for CloudFront. Setting a domain also sets NEXT_PUBLIC_SERVER_URL, which locks Payload’s CORS and CSRF to that origin.

Configuration reference

Root options

The config schema is strict. An unknown key fails validation before synth.

Database options

databaseInsights accepts only mode and encryptionKey. There is no retentionPeriod key, and passing one fails validation with Unrecognized key: retentionPeriod.
Aurora adds writer and readers. Instance adds readReplica. See the DatabaseFactory reference for the full property set.

Compute options

Each of server, imageOptimisation, and revalidation accepts the same three keys. The defaults above are the construct’s. A tier preset overrides the server memory and timeout at create time, so a Standard application is written with 1536 MB and 60 seconds.

Storage options

The media bucket follows the application’s backup tier, so versioning is on at Resilient and Enterprise. Assets and cache stay unversioned at every tier because both churn, and each carries the fjall:s3:versioning-exempt tag saying so (build-output and isr-cache), so a posture scan reads the choice as deliberate; versioned: true drops the tag. An explicit value always wins:

Messaging options

CDN options

Prefer the root-level domain for a simple setup. The cdn block is for supplying a certificate you already manage.

Full example

Going beyond the pattern

The pattern is a composition of the same factories you can call directly. Replace the pattern statement with individual factory calls when you need to:
  • Add resources the pattern does not create, such as a worker queue or Redis
  • Customise networking, for example VPC peering or private subnets
  • Share a database between several applications
  • Narrow IAM permissions past the pattern’s defaults
The composition looks like this. It is a starting point, not a byte-for-byte reproduction: the pattern also wires environment variables, IAM grants, and CloudFront behaviours that you would rebuild by hand.
Once broken out, add resources with fjall add or by editing the file directly. See Add Resources.

Troubleshooting

Build fails with “Error occurred prerendering page”

A page queries the database during next build. The CLI guards the website template’s pages automatically, so first update the CLI:
For a custom page, guard the query with isBuildTime() from @fjall/payload.

403 on admin panel actions

Payload’s CSRF list does not include the origin you are calling from. fjallDefaults() sets cors and csrf correctly, so keep the spread first in buildConfig and do not redeclare either key after it:

Media uploads disappear

The Media collection is still writing to the local filesystem. Keep awsS3Storage() in the plugins array and keep staticDir behind isProduction() in src/collections/Media.ts.

”relation does not exist”

Migrations were never generated or never committed:

fjall modify pattern cannot find the pattern

The construct id in PatternFactory.build was renamed. Restore it to toPascalCase(name) plus Payload, so my-payload-site becomes MyPayloadSitePayload.

Costs

The database and the NAT gateway are always-on, so they set the floor. Lambda, S3 and CloudFront are usage-priced and stay small on a low-traffic site. Indicative us-east-1 on-demand list prices, excluding data transfer:
No payload tier scaffolds t4g.micro. Lightweight writes t4g.small and Standard writes t4g.large, and neither writes multiAz, so the RDS construct’s own default takes over and both come up Multi-AZ, roughly doubling the instance and storage lines. Add multiAz: false to the database block yourself if you do not need the standby.
Set database.type: "Aurora" for Aurora Serverless v2, which scales down when idle and can lower the database line for bursty or low-traffic sites. Run fjall costs --app <name> once deployed for figures from your own account.

Next Steps

Deploy Application

Flags, targets, and the approval gate

Custom Domain

Point a domain at your CloudFront distribution

Add Resources

Extend the application with more AWS services

Database Factory

Every database property the pattern exposes

Static Site Pattern

Deploy a pre-built folder instead

CI/CD

Automate deployments with Buildkite
Fjall: External: