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
Prerequisites
Quick start
1. Scaffold a Payload project
Start with the official website template:2. Create the Fjall application
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: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.
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:
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.
Edit the second argument to configure the pattern. See Configuration reference below.
Configure the project
On fjall 39.x, nothing infjall 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 ofpayload they were released with, so install them at the version npm ls payload prints.
Database adapter
Inpayload.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:
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.fjallPostgresAdapterconnects to it when the build queries the database, so an unguarded page would read your local database during the build, and, from.envor.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, wherefjallDefaults()would lock CORS and CSRF to it. Fjall sets the variable on the server function itself when the pattern has a custom domain.
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 insrc/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:
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 duringnext 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:
-
Install the OpenNext builder:
Do not run the build through
npx open-next: where the builder is not installed, npx fetchesopen-next3.1.3 from the registry instead, the highest version under the builder’s old name, outside that range. -
Write the import map with the project’s own Payload CLI:
-
Build:
Without
--config-path, OpenNext reads anopen-next.config.tsat 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’sbuildCommand, ornpm run buildwithout one, so on Next.js 16 set it first, as OpenNext build command describes. -
Check that
.open-next/server-functions/default/node_modules/sharp/package.jsonand.open-next/image-optimization-function/node_modules/sharp/package.jsonboth say 0.35.5, with@img/sharp-linux-arm64beside 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 assharp-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/aws4.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. -
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:--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:- Detecting application pattern
- Synthesising infrastructure
- Comparing template hashes
- Validating SSM secrets
--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
- Open
/admin - The first-run screen prompts for the initial user
- Enter an email address and password
- 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 viaprodMigrations, which you wire into the adapter when you configure the project:
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
Setdomain and Fjall creates the ACM certificate and the Route53 alias record:
cdn block:
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
Aurora adds
writer and readers. Instance adds readReplica. See the DatabaseFactory reference for the full property set.
Compute options
Each ofserver, 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
fjall add or by editing the file directly. See Add Resources.
Troubleshooting
Build fails with “Error occurred prerendering page”
A page queries the database duringnext 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. KeepawsS3Storage() 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: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: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
Related documentation
Fjall:- Create Application - Scaffolding options
- Compute Factory - Lambda and ECS configuration
- Storage Factory - S3 bucket options
- CDN Factory - CloudFront behaviours
- Network Factory - VPC and networking
- Payload CMS docs - Official Payload documentation
- OpenNext - Next.js to AWS Lambda
- Aurora Serverless v2 - AWS Aurora documentation