Run locally, then use Postgres and SQS
Run one task through the local CLI worker, then switch the same application to Postgres and SQS.
Build a task that returns a greeting, run it with the local CLI worker, then run the same code against Postgres and an AWS SQS queue. One runtime owns one task and one queue; environment configuration selects the lane.
You need Node.js 22.18.0 or newer, a JavaScript package manager, and a TypeScript application with "type": "module" in its package.json. The AWS step also requires a reachable Postgres database, a dedicated SQS standard queue, and AWS credentials. You can complete the local steps without those resources.
This tutorial covers the application and worker. For an infrastructure deployment using SST, Lambda, and Fargate, see the larger AWS example.
Install the packages
Run these commands from your application root. Stay in that directory for the remaining commands, including commands in other terminals.
npm install @runlane/core @runlane/cli @runlane/lane-local @runlane/lane-postgres-sqs @runlane/transport-sqs @aws-sdk/client-sqs dotenv zodThe CLI loads TypeScript through its packaged loader, so this exercise needs no build step.
Define one task
Create src/tasks/greet.ts:
import { task } from '@runlane/core'
import * as z from 'zod'
export const greet = task({
id: 'greeting.create',
schema: z.object({ name: z.string().min(1) }),
output: z.object({ message: z.string() }),
run({ name }) {
return { message: `Hello, ${name}!` }
},
})The input schema requires a name. The worker stores the returned greeting as the run's output.
Configure the runtime
Add these values to the existing .env in your application root, or create it if needed:
APP_ENV=local
RUNLANE_ENVIRONMENT=tutorialCreate src/env.ts to load and validate the configuration. It loads .env.local before .env, preserving values already supplied by the process environment. Local mode needs only the mode and environment name. Production mode also requires the database and queue URLs:
import { config as loadDotenv } from 'dotenv'
import * as z from 'zod'
loadDotenv({ path: ['.env.local', '.env'], quiet: true })
export const env = z
.object({ RUNLANE_ENVIRONMENT: z.string().min(1) })
.and(
z.discriminatedUnion('APP_ENV', [
z.object({ APP_ENV: z.literal('local') }),
z.object({
APP_ENV: z.literal('production'),
DATABASE_URL: z.url(),
RUNLANE_SQS_QUEUE_URL: z.url(),
}),
]),
)
.parse(process.env)Create src/runlane.ts:
import { SQSClient } from '@aws-sdk/client-sqs'
import { createRunlane, queue } from '@runlane/core'
import { createLocalLane } from '@runlane/lane-local'
import { postgresSqsLane } from '@runlane/lane-postgres-sqs'
import { sqsQueue } from '@runlane/transport-sqs'
import { env } from './env.js'
import { greet } from './tasks/greet.js'
const defaultQueue = queue({ name: 'default', default: true })
export const runlane = createRunlane({
environment: { name: env.RUNLANE_ENVIRONMENT },
lane:
env.APP_ENV === 'local'
? createLocalLane()
: postgresSqsLane({
postgres: { connectionString: env.DATABASE_URL },
sqs: {
client: new SQSClient({}),
queues: [sqsQueue(defaultQueue, { queueUrl: env.RUNLANE_SQS_QUEUE_URL })],
},
}),
queues: [defaultQueue],
tasks: { greet },
})The task uses the default queue. In production, that same queue is bound to an SQS URL. RUNLANE_ENVIRONMENT names the namespace for runs; every process working on these runs must use the same value.
The inline lane conditional works because this application uses APIs shared by both lanes, including trigger() and createDeliveryWorker(). No runtime type check is needed. A Lambda entrypoint calling the SQS-only createDeliveryHandler() needs a statically concrete SQS runtime; the AWS example exposes a named createSqsAppRuntime() for that case.
Create runlane.config.ts at the application root so the CLI loads the application's runtime:
import { type RunlaneCliConfig } from '@runlane/cli'
import { runlane } from './src/runlane.js'
export default { runtime: runlane } satisfies RunlaneCliConfigRun locally
In the first terminal:
npm exec -- runlane devKeep it running. The CLI starts a worker and maintenance, then opens a control bridge so other CLI commands reach this same in-memory runtime.
The runtime module loads .env.local and .env when the CLI imports it. Load environment variables for the CLI shows the complete import order and the alternative of loading them from runlane.config.ts.
In a second terminal, from the same application directory:
npm exec -- runlane trigger greeting.create '{"name":"Ada"}'
npm exec -- runlane runs listCopy the run id printed by the trigger command:
npm exec -- runlane runs get <run-id> --events --jsonWait for status to become succeeded. The run's output is:
{ "message": "Hello, Ada!" }If the CLI cannot find the dev process, check that both terminals use the same directory and config. If input validation fails, pass a non-empty name.
Stop the worker with Ctrl+C. Local run history disappears when the process exits. A separate application process cannot share that in-memory lane; add Runlane to your app shows how application code and a local worker share one runtime instance.
Switch to Postgres and SQS
Use a disposable database and queue for this exercise. The migration command below creates Runlane tables, and the AWS resources may incur charges. Use a queue dedicated to this application so another consumer cannot receive its wakeups.
Configure AWS credentials for each process. For this exercise, the identity needs sqs:SendMessage, sqs:ReceiveMessage, sqs:DeleteMessage, and sqs:ChangeMessageVisibility on the queue. AWS documents these actions in its SQS permissions reference.
Update the same .env with your database and queue details. If .env.local overrides these values, update it too. Each new CLI process loads the files, so both terminals use the same configuration:
APP_ENV=production
RUNLANE_ENVIRONMENT=tutorial
DATABASE_URL=<postgres-connection-url>
RUNLANE_SQS_QUEUE_URL=<sqs-standard-queue-url>
AWS_REGION=<queue-region>Keep .env and .env.local out of source control. Use the database's required TLS settings in its connection URL. In deployed services, inject these values through the platform's environment configuration; those values take precedence over the files.
Apply the package-owned Runlane migrations once, before starting workers:
npm exec -- runlane adapter postgres migrateThe command validates the migration history and reports the installed schema version. Runtime startup does not inspect or apply migrations. If the migration command reports a connection or permission error, correct the database configuration before continuing.
Run the durable worker
In the first terminal:
npm exec -- runlane workThis command uses the SQS lane's delivery worker. Leave it running.
In the second terminal, trigger and inspect a new run:
npm exec -- runlane trigger greeting.create '{"name":"Ada"}'
npm exec -- runlane runs get <run-id> --events --jsonReplace <run-id> with the new id. Wait for succeeded and the same greeting output. This time Postgres holds the run and SQS delivers its wakeup. Stop the worker with Ctrl+C, then repeat the inspection: the stored result remains available.
Run a maintenance pass:
npm exec -- runlane tickFor deployment, run runlane work as a supervised service and schedule runlane tick regularly, for example once per minute, with the same application directory, configuration, and credentials. Maintenance advances due retries, schedules, waits, and recovery; the delivery worker alone does not perform it. Run maintenance covers its lifecycle, and deploy with Postgres and SQS shows native Lambda entrypoints.
When finished, stop the worker and remove only the disposable database and queue you created for this exercise. Keep resources that contain data you need.