Serverless Deployment (AWS Lambda)
Guren runs on AWS Lambda's Node.js runtime. The official @guren/plugin-lambda plugin handles bundling, and a CDK construct provisions the full stack: HTTP, queues, scheduled tasks, CLI commands, and static assets.
Serverless Deployment (AWS Lambda)
Guren runs on AWS Lambda's Node.js runtime. The official @guren/plugin-lambda plugin handles bundling, and a CDK construct provisions the full stack: HTTP, queues, scheduled tasks, CLI commands, and static assets.
Setup
bunx guren plugin @guren/plugin-lambda
bun add @guren/plugin-lambda
Installing registers lambdaPlugin() in src/app.ts and scaffolds src/lambda.ts, the module whose exports become your Lambda handlers:
// src/lambda.ts (scaffolded)
import app from './app.js'
import { createLambdaHandler, createSqsHandler } from '@guren/core/lambda'
// Boots once at cold start; every handler shares the booted application.
await app.boot()
// HTTP requests via API Gateway, ALB, or a Lambda Function URL.
export const http = createLambdaHandler(app)
// SQS queue job processing (wire an SQS event source to this handler).
export const queue = createSqsHandler()
Uncomment the schedule and console exports in the scaffold once your app defines a scheduler or console kernel.
Build
bunx guren lambda:build
The command runs the deploy-runtime checks guren doctor reports (a warning, never a failure, on an in-memory session or OAuth store, a Bun-only password hasher, or filesystem provider discovery, each of which works locally and breaks on Lambda), then your app's build script, then assembles a .lambda/ directory:
| Path | Contents |
|---|---|
function/ |
Self-contained ESM bundle (handler.js) plus the SSR bundle and Drizzle migrations — deploy this as the function code |
assets/ |
public/ staged for S3, with built assets mirrored under both /assets/ and /public/assets/ |
env.json |
The environment the function expects — the same values are baked into the bundle as defaults |
Handler identifiers follow the bundle: handler.http, handler.queue, handler.schedule, handler.console.
process.env.NODE_ENV is pinned to "production" at bundle time: the bundler inlines it, so runtime configuration alone cannot fix a development-mode bundle. Inertia asset locations (GUREN_INERTIA_ENTRY, GUREN_INERTIA_STYLES, and the SSR entry) are baked in as defaults too; real function environment variables still override them.
Pass --zip to also produce function.zip for direct uploads. CDK archives the directory on its own.
Handlers
HTTP — createLambdaHandler(app)
Wraps the app's fetch handler for API Gateway v1/v2 and ALB. Routes, controllers, and middleware work identically to the serverful configuration.
Queue — createSqsHandler()
Processes SQS messages as Guren jobs. Enable ReportBatchItemFailures on the Lambda event source mapping (the bundled CDK construct does this). Standard queue batches run concurrently and report individual failures. FIFO queues, identified by their .fifo ARN, run sequentially; after the first failure, the response includes that record and every unprocessed record so SQS preserves order.
Attempts are the count already in the job body plus AWS ApproximateReceiveCount. Records without a positive receive count are reported as failures. When an attempt fails at the job's maxAttempts, the handler calls failed() once, with that attempt's error, as the queue worker does. Later deliveries run neither handle() nor failed(). Terminal messages remain in batchItemFailures so the queue's redrive policy can move them to its dead-letter queue. Configure that policy; maxAttempts does not delete messages or create a dead-letter queue.
handle() must tolerate redelivery. The limit counts AWS deliveries, which can include FIFO records left unprocessed after an earlier failure, so a record can reach the limit having never run: it goes to the dead-letter queue and failed() never sees it. Set the queue's maxReceiveCount with that behavior in mind; a lower redrive threshold can move a job before its maxAttempts callback runs. Every record the handler runs logs its job name, attempt and error as JSON when it fails, and a FIFO batch that stops logs the records it left unprocessed.
Configure the SQS driver in the config/queue.ts that guren add queue scaffolds, and list it in createApp({ config }):
// config/queue.ts
import { SQSClient } from '@aws-sdk/client-sqs'
import { createSqsAdapter, defineQueueConfig, SqsDriver } from '@guren/core'
export default defineQueueConfig((env) => ({
default: 'sqs',
drivers: {
sqs: () =>
new SqsDriver(createSqsAdapter(new SQSClient({ region: 'ap-northeast-1' })), {
queueUrl: env.SQS_QUEUE_URL,
// Optional: map logical queue names to separate SQS URLs
queueUrls: {
emails: env.SQS_EMAILS_QUEUE_URL,
},
}),
},
}))
Declare SQS_QUEUE_URL and SQS_EMAILS_QUEUE_URL in config/env.ts (see Configuration), so a function deployed without them fails at boot instead of on the first dispatch. Apps that configure the queue in a service provider keep working; see Apps with service providers.
Jobs are dispatched the same way as on the server: await SendEmailJob.dispatch({ to: 'user@example.com' }). The SqsDriver serializes the job to SQS, and the Lambda handler deserializes and executes it.
SQS polling workers
guren queue:work can also consume SQS through SqsDriver. The built-in adapter requests ApproximateReceiveCount so retry counts survive redelivery and worker restarts. Successful jobs and jobs that reach maxAttempts are deleted from SQS; grant the worker sqs:DeleteMessage permission. Failed-job records remain in process memory, so use application monitoring or persistent error reporting if they must survive a restart. Deleting a terminal failure does not move it to an SQS dead-letter queue.
Custom adapters used by polling workers should implement deleteMessage({ queueUrl, receiptHandle }) and return receiveCount from receiveMessage(), sourced from SQS ApproximateReceiveCount. An adapter that omits either one warns once and keeps its previous behaviour: acknowledged jobs stay on the queue, and retry counts restart from the message body on every redelivery. Adapters used only to dispatch jobs need neither. MessageSystemAttributeNames reaches the API only through an @aws-sdk/client-sqs release that models it (3.577.0 and later); an older client takes the same warning path. Lambda event-source processing continues to use createSqsHandler() and AWS batch acknowledgement.
Schedule — createScheduleHandler(scheduler)
Runs due tasks when invoked by EventBridge. Configure an EventBridge rule with rate(1 minute) to trigger this handler. The existing Scheduler and task definitions work without changes.
Console — createConsoleHandler(kernel)
Executes the commands registered on your app's ConsoleKernel, the one src/console.ts exports as kernel. See the console commands guide for defining commands and registering them.
Uncomment the console export in the scaffolded src/lambda.ts to enable the handler.
The kernel has no built-in commands. You need a migration command only on the Data API adapter, whose getDatabase() deliberately skips pending migrations. The other adapters apply them on first use. See the Aurora Serverless notes for that tradeoff and the migrateOnStart alternative. Running them out of band keeps the latency off the request path either way:
bunx guren make:command Migrate --command db:migrate
// app/Console/Commands/MigrateCommand.ts
import { Command } from '@guren/core'
import { migrateDatabase } from '../../../config/database.js'
export default class MigrateCommand extends Command {
static signature = 'db:migrate'
static description = 'Apply pending database migrations'
async handle(): Promise<void> {
await migrateDatabase()
}
}
make:command prints the line that registers it in src/console.ts. Then invoke via AWS CLI:
aws lambda invoke --function-name my-app-console \
--cli-binary-format raw-in-base64-out \
--payload '{"command": "db:migrate"}' response.json
Returns { exitCode: 0 } on success, { exitCode: 1 } on failure.
Server-Side Rendering
SSR works on Lambda without extra configuration. lambda:build copies the Vite SSR bundle into the function directory and bakes its location into the bundle; the server loads the renderer on the first Inertia render. Apps without an SSR build produce a CSR-only function, with no flag needed either way.
Database
Aurora Serverless v2 with the RDS Data API (recommended)
The Data API is HTTP-based: no connection pool, no RDS Proxy, and the function does not need to run inside a VPC. Use createAwsDataApiDatabase:
// config/database.ts
import { createAwsDataApiDatabase } from '@guren/core'
const database = createAwsDataApiDatabase({
migrationsFolder: new URL('../db/migrations', import.meta.url),
seedersFolder: new URL('../db/seeders', import.meta.url),
// Falls back to DATABASE_NAME / DATABASE_RESOURCE_ARN / DATABASE_SECRET_ARN
})
export const { getDatabase, migrateDatabase, closeDatabase, configureOrm, seedDatabase } = database
Install the driver alongside it (bun add @aws-sdk/client-rds-data). The function needs the rds-data actions on the cluster plus secretsmanager:GetSecretValue on the secret. The CDK construct's dataApi option (below) wires both, and authentication uses the function's IAM role. For drizzle-kit generate/push, set driver: 'aws-data-api' in drizzle.config.ts.
See the Database Guide for the full factory reference.
Classic RDS with RDS Proxy
createPostgresDatabase works against RDS when the function runs inside the VPC. Route connections through RDS Proxy and disable prepared statements, which pin proxy sessions:
// config/database.ts
import { createPostgresDatabase } from '@guren/core'
import env from './env.js'
const database = createPostgresDatabase({
migrationsFolder: new URL('../db/migrations', import.meta.url),
connectionString: (context) => (context?.env ?? env.parse(undefined, { mode: 'report' }).values).DATABASE_URL,
clientOptions: { prepare: false, max: 1 },
})
The resolver reads the validated environment when the app boots, and parses the schema itself when guren db:migrate calls it outside one (The database connection).
Only the client you use is bundled
The ORM reaches every dialect's client through a dynamic import, and bundlers
follow those whether or not the branch can be taken, so a Postgres app would
otherwise fail to build on mysql2, a database it never chose. The build reads
which factories config/database.ts calls and replaces the clients for every
other dialect with a stub that throws if it is ever reached.
Only config/database.ts (or db/config.ts) is read, so an app that opens a
second connection elsewhere should name its databases explicitly. So should
one whose config reaches a factory without naming it (through a re-export, or
an indirection into another module), which the build reports as not being able
to tell, and stubs nothing:
bunx guren lambda:build --database postgres,sqlite
Runtime Detection
Conditionally configure services based on the runtime:
import { isLambda, getLambdaContext } from '@guren/core/lambda'
if (isLambda()) {
const ctx = getLambdaContext()!
// ctx.functionName — Lambda function name
// ctx.region — AWS region
// ctx.memorySize — allocated memory (MB)
// ctx.tmpDir — writable temp directory (/tmp)
// ctx.logGroup — CloudWatch log group
}
Password Hashing
The default hasher writes node:crypto scrypt on every runtime, so a column seeded locally under Bun verifies on Lambda. No configuration is needed for new apps.
rule
Rows written as Argon2id verify only where Bun.password exists: an app that selected hasher: 'argon2', or a column seeded by a release before scrypt became the default. Migrate them before moving to Lambda, as Password hasher describes.
Logging
Lambda captures stderr to CloudWatch automatically. Use JSON-formatted console logging:
import { LogManager } from '@guren/core'
const log = new LogManager({
default: 'console',
channels: {
console: { driver: 'console', level: 'info', format: 'json', colors: false },
},
})
Static Assets
Lambda is not suited for serving static files. lambda:build stages public/ into .lambda/assets, ready for S3, and the CDK construct (below) provisions the bucket and a CloudFront distribution in front of it, with the app as the default origin. The distribution sends /public/* to the bucket, which is where the built client assets are served from (/public/assets/), plus one behavior per entry at the root of .lambda/assets, such as /robots.txt.
The distribution answers for those files before the function runs, so the guard the framework applies when it serves public/ itself never sees them. The construct restores it with a viewer-response CloudFront function on the asset behaviors: the types a browser renders as a document (.html, .htm, .svg, .xhtml, .xml) come back with Content-Disposition: attachment and X-Content-Type-Options: nosniff, at any depth and whatever the extension's case. Images, scripts, stylesheets and fonts are untouched, and the default behavior — your app — is left to its own headers.
Deploying assets by hand instead? Sync .lambda/assets to a bucket and set GUREN_INERTIA_ENTRY / GUREN_INERTIA_STYLES on the function to the CDN URLs (the values are listed in .lambda/env.json). Note that the document rule above comes with the CDK construct, not with the staged directory: a hand-rolled distribution serves an .svg under public/ inline, on your app's origin.
Configuration Notes
Service Providers
A bundle has no directory to scan, so everything the app registers is named in createApp(): services as config definitions in config, and any providers the app still has in providers:
const app = createApp({
env,
config: [database, http, session, cache, queue],
providers: [SessionDriversProvider],
routes: registerWebRoutes,
})
Migrations & Seeding
The scaffolded config/database.ts seeds the database on boot as a local development convenience (seedOnBoot: process.env.NODE_ENV !== 'production') and skips it in production. Keep that guard. Lambda boots the app on every cold start, so boot-time seeding would re-run against production data.
Migrations ship with the function: lambda:build copies db/migrations/ next to the bundle, so a db:migrate console command can apply them in place. See Console — createConsoleHandler(kernel) for the command and how to invoke it.
Seeders cannot run inside the function. They are ordinary .ts modules that import your schema and @guren/core, and the deployed function is a self-contained bundle with no node_modules and no TypeScript loader, so the Node.js runtime rejects them outright. Seed from somewhere that has the project source instead:
DATABASE_URL='<production connection string>' bunx guren db:seed --force
If a dataset must ship with the release rather than be applied by hand, express it as a migration so it travels with the function.
Storage & Filesystem
Lambda has a read-only filesystem except for /tmp (512 MB, ephemeral). Use /tmp only for transient cache. For persistent storage, use S3 via the S3Driver.
Sessions & Cache
In-memory stores are lost between invocations, so sessions need a backend that survives across Lambda invocations.
Run bunx guren add session and pick the store in config/session.ts. For most apps database is the recommended default. It persists sessions in the database your app already talks to, so there is no extra infrastructure to provision, and sessions:prune (scheduled through createScheduleHandler) keeps the table small.
DynamoDB
For apps that want session churn off the primary database, @guren/plugin-lambda adds a dynamodb driver:
bun add @aws-sdk/client-dynamodb
// config/session.ts
import { defineSessionConfig } from '@guren/core'
import { sessions } from '../db/schema.js'
export default defineSessionConfig((env) => ({
default: env.SESSION_DRIVER,
stores: {
database: { driver: 'database', table: sessions },
dynamodb: { driver: 'dynamodb' },
},
}))
config/session.ts can name the store but cannot register its driver, so add the driver to the bound manager from a provider's register():
// app/Providers/SessionDriversProvider.ts
import { ServiceProvider } from '@guren/core'
import { registerDynamoDbSessionDriver } from '@guren/plugin-lambda'
export default class SessionDriversProvider extends ServiceProvider {
register(): void {
registerDynamoDbSessionDriver(this.container.make('session'))
}
}
List session in createApp({ config }) and the provider in providers, then set SESSION_DRIVER=dynamodb on the function. Definitions bind before any provider registers, and the session manager resolves stores lazily, so the driver is in place before the boot checks that the default store's driver exists. A driver registered in boot() would be too late for that check.
The table name comes from DYNAMODB_SESSIONS_TABLE, which the CDK construct's sessionsTable sets on every function; pass table in the store config to name it yourself. Registration is a call rather than an import side effect, so a bundler that drops an unused import cannot drop the driver with it.
The table needs an id string partition key and TTL enabled on expires_at. Reads are strongly consistent, so a session written at login is readable on the redirect that follows. DynamoDB deletes expired items within 48 hours rather than at expiry, so the store treats a past expires_at as missing on its own. The TTL is the sweeper, not the clock. A DynamoDB item caps at 400 KB, so keep only ids in the session.
ElastiCache and the redis driver remain an option; cache still benefits from Redis or DynamoDB (see the infrastructure table below).
Infrastructure Recommendations
| Concern | Recommendation |
|---|---|
| HTTP trigger | API Gateway v2 (HTTP API) or ALB |
| Database | Aurora Serverless v2 + Data API via createAwsDataApiDatabase — or RDS + RDS Proxy |
| Sessions | the database driver (no extra infra) — or dynamodb (the construct's sessionsTable) / redis (ElastiCache) for high session churn |
| Cache | Redis via RedisCacheStore (@guren/core/redis ships session/rate-limit/API-token stores too), or FileStore with /tmp for ephemeral cache |
| Queue | SQS via SqsDriver + createSqsHandler() |
| Scheduling | EventBridge + createScheduleHandler() |
| CLI commands | Dedicated Lambda + createConsoleHandler() |
| Static assets | CloudFront + S3 (provisioned by the CDK construct) |
| Logging | CloudWatch (stderr, JSON format) |
Deploy with CDK
The plugin ships a CDK construct that wires the whole topology: the HTTP API, queue worker with dead-letter queue and partial batch failures, EventBridge rule, console function, and CloudFront + S3 for assets:
bun add aws-cdk-lib constructs
import { App, Stack } from 'aws-cdk-lib'
import { GurenLambdaApp } from '@guren/plugin-lambda/cdk'
const app = new App()
const stack = new Stack(app, 'MyApp')
new GurenLambdaApp(stack, 'App', {
functionDir: '../.lambda/function',
assets: { dir: '../.lambda/assets' },
queue: {}, // SQS + worker (omit if the app dispatches no jobs)
schedule: {}, // EventBridge rule, every minute (needs the schedule export)
console: true, // db:migrate & friends via `aws lambda invoke`
// Wires DATABASE_* environment plus the rds-data and secret-read grants
// onto every function.
dataApi: {
database: 'appdb',
resourceArn: process.env.DATABASE_RESOURCE_ARN!,
secretArn: process.env.DATABASE_SECRET_ARN!,
},
environment: {
APP_KEY: process.env.APP_KEY!,
},
})
Every sub-resource is exposed as a property (httpFunction, queue, distribution, ...) for further customization: attaching a custom domain, adding IAM grants, tuning memory per function. A complete, deployable CDK app lives in the deploy recipes.
bunx guren lambda:build
bunx cdk deploy
rule
If you replace lambda:build with your own bundler, disable identifier mangling. Guren stores class names inside durable records: queued jobs carry the job's wire name, which defaults to its class name, persisted notifications carry their notification type, and HTTP exceptions report their own name. A mangled build therefore cannot resolve records written by the previous deploy. With bun build, use --minify-whitespace --minify-syntax --keep-names instead of --minify: syntax minification drops the name of a named class expression such as register(class SendWelcomeMail extends Job {}), and --keep-names keeps it. With esbuild, set keepNames: true, which keeps every name even under minify: true; minifyIdentifiers: false alone still loses the same names Bun's syntax minification does. With tsdown or rolldown, mangle: false alone is not enough: the compressor inlines a single-use class as an anonymous class expression, so its name becomes "". Set minify: { compress: { keepNames: { class: true, function: true } }, mangle: false } instead of minify: true. On Bun, --keep-names / minify.keepNames does not make --minify safe: on 1.3.14 and 1.4.2, identifier minification mangles class names even with it. No flag stops Bun renaming a class when another module declares the same top-level name: one of two OrderShipped classes is bundled as OrderShipped2, and import order decides which. Give such classes distinct names or pin them as described below. Update @guren/plugin-lambda to a release that sets keepNames and lambda:build does all of this for you, including a warning when it renames a job, event, notification, agent or model. A renamed model needs a new class name: attachments and polymorphic relations store it under the bundled name, which Model.morphMap does not know.
If you must mangle, every job has to declare a jobName and every notification an explicit type, so their durable identities no longer depend on class names (see Pinning a Job's Wire Identity). Both default to the class name when not declared, and exception names are always class-name derived, so leaving identifiers intact remains the safer default.