@fjall/payload
AWS Lambda adapters for Payload CMS. This package provides drop-in replacements for Payload's database and storage adapters, optimised for serverless deployments on AWS.
Installation
npm install @fjall/payload @payloadcms/storage-s3
Features
fjallPostgresAdapter- PostgreSQL adapter with AWS Secrets Manager integrationawsS3Storage- S3 storage plugin for media uploadsfjallDefaults- Sensible CORS/CSRF defaults for LambdaisProduction/isLambda- Environment detection helpers
Usage
Database Adapter
// payload.config.ts
import { buildConfig } from "payload";
import { fjallPostgresAdapter, fjallDefaults } from "@fjall/payload";
import { migrations } from "./migrations";
export default buildConfig({
...fjallDefaults(),
db: await fjallPostgresAdapter({
migrationDir: "./src/migrations",
prodMigrations: migrations,
}),
// ... rest of config
});
The adapter automatically:
- Fetches database credentials from AWS Secrets Manager in Lambda
- Falls back to
DATABASE_URLfor local development - Configures SSL for Aurora PostgreSQL
- Makes Payload's migration runs wait for each other, so environments that start together run each migration once. Each run holds Postgres advisory lock (1718250604, 1) on a connection of its own while it migrates, and a run that waits 15 minutes for it fails with an error that names the lock
S3 Storage Plugin
// plugins/index.ts
import { awsS3Storage } from "@fjall/payload";
import { Plugin } from "payload";
export const plugins: Plugin[] = [awsS3Storage()];
The plugin automatically:
- Uses
MEDIA_BUCKET_NAMEenvironment variable set by Fjall infrastructure - Handles presigned URLs for uploads
- Falls back to local filesystem in development
Environment Detection
import { isProduction, isLambda } from "@fjall/payload";
// Conditional logic based on environment
if (isProduction()) {
// Running in AWS with Fjall infrastructure
}
if (isLambda()) {
// Running in AWS Lambda runtime
}
Conditional Static Directory
For Media collections that need local uploads in dev but S3 in production:
import { isProduction } from "@fjall/payload";
import path from "path";
export const Media: CollectionConfig = {
slug: "media",
upload: {
...(isProduction()
? {}
: {
staticDir: path.resolve(__dirname, "../../public/media"),
}),
},
};
Environment Variables
Production (Lambda)
Set automatically by Fjall infrastructure:
| Variable | Description |
|---|---|
DATABASE_HOST | Aurora PostgreSQL endpoint |
DATABASE_PORT | Database port (default: 5432) |
DATABASE_NAME | Database name |
DATABASE_SSL | Enable SSL (true) |
MEDIA_BUCKET_NAME | S3 bucket for uploads |
Credentials fetched from Secrets Manager:
DATABASE_USERNAMEDATABASE_PASSWORD
Development
| Variable | Description |
|---|---|
DATABASE_URL | Full PostgreSQL connection string |
Setting up and deploying a Payload project
Neither fjall create app nor fjall deploy sets a Payload project up for Lambda. fjall create app --name <app> --pattern payload --non-interactive writes fjall/<app>/infrastructure.ts, fjall/<app>/open-next.config.ts, the CDK project files beside them and the application's config file, and nothing changes your project's own files. In a terminal without --non-interactive, fjall create app opens the application wizard, which ignores --pattern: run fjall create and choose Patterns instead. Configure the project by hand, once, then build it at the project root and deploy what that build produced. The full recipe is on the Payload pattern page.
On Lambda, a site deployed this way needs fjall 39.0.13 or later constructs. The Payload pattern gives its server function the database user name and password, and PAYLOAD_SECRET, through Fjall's secrets support, and through fjall 39.0.12 that support does not deliver them. Read from the code, from 39.0.8 to 39.0.12 each start of the function stops in Fjall's secrets resolver before its handler runs, and before 39.0.8 a start could instead skip the resolver and start without the secrets. The cause is the version of AWS's Parameters and Secrets Lambda Extension that fjall 39.x pins, 1.0.103: measured on 15 October 2026 on arm64 in us-east-2, it answered each of 40 reads over the first 8 seconds of a function's start-up with a not-ready refusal, and, read from the code, the resolver treats that refusal as final. From 39.0.13 the resolver reads Systems Manager and Secrets Manager itself, so that no longer stops a Payload site whose function can reach the two services, and fjallPostgresAdapter takes the values the resolver handed over. No Payload site has been served end to end yet.
Configure the project
Work at the project root, the directory that holds package.json and the fjall/ folder. The commands below use npm.
- Install this package and the S3 storage adapter:
npm install @fjall/payload @payloadcms/storage-s3. - In
payload.config.ts, usefjallPostgresAdapterwithprodMigrations, and spreadfjallDefaults()intobuildConfig, as Database Adapter shows. Until the first migration exists, createsrc/migrations/index.tsholdingexport const migrations = [];so the import resolves, then generate the first migration withnpx payload migrate:createbefore the first build. - Add
awsS3Storage()to the project's plugins, and keepstaticDirout of production in the Media collection, as S3 Storage Plugin and Conditional Static Directory show. - Guard every database query that runs during
next build, as the Payload pattern page shows for the website template's pages: agenerateStaticParamsreturns[]whenisBuildTime()is true, and a page that queries as it renders exportsdynamic = "force-dynamic". - Before you build for AWS, comment out
DATABASE_URLandNEXT_PUBLIC_SERVER_URLwherever.envor.env.productionsets them, and wherever.env.localor.env.production.localdoes, which the build reads. Next.js copies.envand.env.productioninto its standalone output and OpenNext copies both into the server function (Next.js 16.3.8 and@opennextjs/aws4.1.6, read), so every value in them ships with the deployed site. - Check the project's Next.js major with
npm ls next, then look for abuildCommandin theconfigobject offjall/<app>/open-next.config.ts: fjall 39.x writes that file without one, and later CLIs writebuildCommand: "npm run build -- --webpack". On Next.js 16 the config needs that line, so add it if it is missing: Next.js 16 builds with Turbopack by default, and a Turbopack build refers to sharp by a hashed name that does not resolve on Lambda, so every route answers 500 (vercel/next.js issue 87737, open on 2026-09-28; measured on Next.js 16.3.3 with@opennextjs/aws4.1.0). On Next.js 15, delete the line if it is there, and OpenNext runsnpm run build: Next.js 15 builds with webpack already and refuses--webpackas an unknown option (Next.js 15.4.11, read).
Build and deploy
fjall deploy runs its build steps in fjall/<app>, which fails for an app laid out as fjall create app lays it out. At the project root:
-
Install the OpenNext builder:
npm install --save-dev @opennextjs/aws@^4.1.6. Do not build throughnpx open-next: where the builder is not installed, npx fetchesopen-next3.1.3 from the registry instead, outside that range. -
Write the import map with the project's own Payload CLI:
./node_modules/.bin/payload generate:importmap. -
Build:
./node_modules/.bin/open-next build --config-path fjall/<app>/open-next.config.ts. Without--config-path, OpenNext reads anopen-next.config.tsat the project root instead. -
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:grep -rlE 'sharp-[0-9a-f]{8,}' .open-next/server-functions/defaultmust print nothing. A file it prints refers to sharp by the hashed name a Turbopack build writes; 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 grep prints the server chunk of a Turbopack build and nothing for a webpack build; a Payload website-template bundle built with webpack on Next.js 15.4.10 prints nothing too.fjall 39.0.7 and later write the install block that puts sharp 0.35.5 into both functions. An app scaffolded by an earlier CLI keeps the block it was scaffolded with until you replace its
sharpInstallConfigdeclaration with the one the Payload pattern page gives. -
Deploy:
fjall deploy <app> --skip-build.
Rebuild with steps 2 to 4 before every deploy that changes the project's code or migrations: --skip-build deploys whatever .open-next holds.
Licence
Proprietary — see LICENSE.