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
On fjall 39.x, neither fjall create app nor fjall deploy sets a Payload project up for Lambda. You configure the project by hand, build it at the project root, and deploy what that build produced. Configure the project and Build and deploy give the steps.
Use @fjall/components-infrastructure 39.0.13 or later for this recipe (source checked at 39.2.2). Earlier 39.x releases relied on the AWS secrets extension during function initialization and could fail before the handler ran or start without the required values. From 39.0.13, Fjall’s resolver reads SSM and Secrets Manager directly before starting the runtime. The extension is no longer required by the resolver. This fixes that specific startup dependency; the manual project/build steps below still apply. Validate a deployed application’s startup, database connection and secret reads before directing production traffic to it.

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, fjall/my-payload-site/open-next.config.ts and the application’s config file. The scaffold directory is named after --name.

3. Configure the project

Add Fjall’s adapters to the Payload project by hand, set the OpenNext build command for the project’s Next.js major, and generate the first migration, as Configure the project describes.

4. Build and deploy

At the project root:
Build and deploy explains each step and the check to make before the deploy.

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 Payload application that needs a server, use --pattern payload. A Next.js app without Payload has no standalone SSR construct. For a static export, use --pattern staticsite.

Choosing a tier

Pattern applications have four tiers, prompted as Choose your configuration tier?: Standard retains automated database backups for 14 days with deletion protection on. Lightweight also inherits the untiered 14-day retention, with deletion protection off; its preset does not emit a seven-day override. Resilient retains 30 days. Customer-managed Aurora storage encryption needs the explicit configuration below on the released scaffold.
On fjall 39.x, creating a pattern application at the Resilient tier does not write the customer-managed key the table lists, so its Aurora cluster takes the AWS-managed key. Before the cluster’s first deploy, add encryption: { storageKey: { useCMK: true } } to the pattern’s database block in fjall/<app>/infrastructure.ts. Once it is deployed, leave the block as it is: the constructs document a change of storageKey after creation as replacing the cluster. That key is rotated and is kept after fjall destroy, because the cluster’s final snapshot is encrypted with it; schedule its deletion only once no snapshot it encrypts is wanted.
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.

Configure the project

On fjall 39.x, nothing in fjall create app or fjall deploy changes your Payload project, so configure it by hand, once, before the first build. Work at the project root, the directory that holds package.json and the fjall/ folder. The commands on this page use npm.

Packages

Both Payload adapters require the exact version of payload they were released with, so install them at the version npm ls payload prints.

Database adapter

In payload.config.ts, replace the Postgres adapter with Fjall’s and spread 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. The migrations import reads src/migrations/index.ts. Payload rewrites that file each time payload migrate:create creates a migration (Payload 3.90.1, read). Until the first migration exists, create it yourself so the import resolves:
Then generate the first migration, as Database migrations describes, before the first build.

Environment file

When it builds, Next.js reads .env, .env.production, .env.local and .env.production.local at the project root. It copies .env and .env.production into its standalone output whole, comments included, and OpenNext copies both into the server function (Next.js 16.3.8 and @opennextjs/aws 4.1.6, read). So every line in those two ships with the deployed site, and a line you comment out still ships. Before you build for AWS, move these two lines out of all four files and into .env.development.local, which only next dev reads, so local development keeps them and the build never sees them.
  • DATABASE_URL. fjallPostgresAdapter connects to it when the build queries the database, so an unguarded page would read your local database during the build, and, from .env or .env.production, its connection string would ship inside the function. On Lambda the adapter reads the database credentials from Secrets Manager instead.
  • NEXT_PUBLIC_SERVER_URL. A localhost value there can reach the deployed site, where fjallDefaults() would lock CORS and CSRF to it. Fjall sets the variable on the server function itself when the pattern has a custom domain.
Until 4 November 2026 this page said to comment the two lines out. If you deployed with DATABASE_URL commented out of .env or .env.production, the function carries its connection string. Move the line, build and deploy again, and rotate the credential it names unless it’s a local one.

S3 media storage

Add Fjall’s S3 plugin to the project’s plugins, which the website template keeps in src/plugins/index.ts:
awsS3Storage() reads MEDIA_BUCKET_NAME and AWS_REGION from the Lambda environment and falls back to disk storage locally. In src/collections/Media.ts, keep staticDir out of production: staticDir: <value> becomes ...(isProduction() ? {} : { staticDir: <value> }), with isProduction imported from @fjall/payload.

Next.js configuration

next.config needs no output setting: OpenNext builds Next.js in standalone mode itself (@opennextjs/aws 4.1.6, read).

OpenNext build command

Next.js 16 builds with Turbopack by default, and a Turbopack build does not run on Lambda. It refers to native modules such as sharp by hashed names (sharp-<hash>) that do not resolve inside the OpenNext bundle, so every route answers 500 (vercel/next.js issue 87737, open on 2026-09-28; measured on Next.js 16.3.3 with @opennextjs/aws 4.1.0). The fjall/my-payload-site/open-next.config.ts that fjall 39.x writes names no build command, so OpenNext runs npm run build, which on Next.js 16 is the Turbopack build. Check the project’s Next.js major with npm ls next. On Next.js 16, add a buildCommand to the config object in fjall/my-payload-site/open-next.config.ts, leaving the rest of the file as it is:
On Next.js 15, leave buildCommand out. Next.js 15 builds with webpack already, has no --webpack option and refuses the command above as an unknown option. Payload 3.72 to 3.75 accept Next.js 15.

Build-time guards

Payload pages that query the database during next build fail without a database connection. Guard them yourself. In the website template, these are the pages to change: These paths track the Payload website template layout. In a project with a different structure, guard your own build-time queries the same way, with isBuildTime() from @fjall/payload:

Build and deploy

On fjall 39.x, fjall deploy runs its build steps in fjall/<app>, which fails for an app laid out as fjall create app lays it out, while the Payload construct stages <project root>/.open-next. So build at the project root and deploy what that build produced:
  1. Install the OpenNext builder:
    Do not run the build through npx open-next: where the builder is not installed, npx fetches open-next 3.1.3 from the registry instead, the highest version under the builder’s old name, outside that range.
  2. Write the import map with the project’s own Payload CLI:
  3. Build:
    Without --config-path, OpenNext reads an open-next.config.ts at the project root instead, where an older setup may have left one with an older sharp block. With none there, OpenNext uses its own defaults, which give the server function no sharp at all. The build runs the config’s buildCommand, or npm run build without one, so on Next.js 16 set it first, as OpenNext build command describes.
  4. Check that .open-next/server-functions/default/node_modules/sharp/package.json and .open-next/image-optimization-function/node_modules/sharp/package.json both say 0.35.5, with @img/sharp-linux-arm64 beside each. Then check that the build did not run with Turbopack. This command must print nothing:
    A file it prints refers to sharp by a hashed name such as sharp-20c6a5da84e2135f, which the Turbopack build writes and the webpack build does not, and which does not resolve on Lambda, as OpenNext build command says. Set the build command and build again. Measured on 15 October 2026 with Next.js 16.3.8 and @opennextjs/aws 4.1.6 on a minimal application that imports sharp: the Turbopack build prints the server chunk that loads sharp, and the webpack build prints nothing. A Payload website-template bundle built with webpack on Next.js 15.4.10, and the sharp 0.35.5 linux arm64 packages the install block puts into the function, print nothing as well.
  5. Deploy:
fjall 39.0.7 writes the open-next.config.ts whose install block puts sharp 0.35.5 for linux arm64 into both functions. An app scaffolded by an earlier CLI keeps the block it was scaffolded with until you replace its sharpInstallConfig declaration with this one:
Rebuild with steps 2 to 4 before every deploy that changes the project’s code or migrations: --skip-build deploys whatever .open-next holds. 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 deploy streams a checklist:
  1. Detecting application pattern
  2. Synthesising infrastructure
  3. Comparing template hashes
  4. Validating SSM secrets
Without --skip-build, the deploy also generates the Payload import map and runs the OpenNext build in fjall/my-payload-site, before that checklist and outside it, where they fail for the layout fjall create app produces. With --skip-build it skips both, because you ran them at the project root.

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 you wire into the adapter when you configure the project:
Generate and commit migrations before you build a schema change, then build and deploy as Build and deploy shows. A build 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 validation refuses one as a key the schema does not declare.
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. No fjall 39.x release guards pages for you: guard the query as Build-time guards shows, then build and deploy again as Build and deploy shows.

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, or the deployed build predates them:
Then build and deploy again as Build and deploy shows.

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: