Most “serverless” tutorials hand you a serverless.yml, run sls deploy, and call it a day. That works — until your team hits the v4 license, your CI gets blocked offline, or you want to actually understand what gets created in your AWS account.
This post takes the opposite path. We build a complete serverless stack — three Lambdas (two HTTP APIs + one SQS-triggered worker), a REST API Gateway, an SQS queue with a DLQ, and event source mappings — using nothing but the AWS SDK. Everything runs locally on LocalStack first, then deploys to real AWS by flipping one environment variable.
By the end you’ll have:
apps/web ──► API Gateway (REST v1) ──┬──► Lambda: auth-api ──┐
│ │ │
└──► Lambda: products-api│
│ │ enqueue
Postgres + Redis │ {userId,email}
▼
SQS: emails-queue
│ event source mapping
▼ (batch=5, window=2s)
Lambda: email-worker
SQS DLQ (after 3 retries)
Why ditch the Serverless Framework?
The Serverless Framework is great for a single-Lambda demo. Once you have a real monorepo, the friction starts:
- License churn: v4 introduced license keys for orgs above a revenue threshold.
- YAML opacity: when something fails, you’re debugging a YAML-to-CloudFormation translation, not the actual API call.
- Plugin sprawl: half the things you need (workspace bundling, ESM output, custom IAM trust policies) live in third-party plugins that lag behind AWS SDK changes.
- Slow inner loop: every change re-uploads the full CloudFormation stack.
What we want is a thin, scriptable layer that does exactly the same API calls — CreateFunction, CreateRestApi, CreateQueue, CreateEventSourceMapping — but written in TypeScript and committed to your repo.
Stack overview
| Layer | Choice |
|---|---|
| Monorepo | pnpm workspaces + Turborepo |
| Lambda runtime | Node 20.x (nodejs20.x), arm64 |
| HTTP framework | Hono + hono/aws-lambda adapter |
| Bundler | Rolldown — ESM bundles, ~4–23 KB per Lambda |
| Async messaging | SQS + event source mapping, DLQ with maxReceiveCount=3, partial-batch failures |
| Local cloud | localstack/localstack:3.8 (community edition: REST API v1, Lambda, IAM, SQS) |
| Deploy | TypeScript script using @aws-sdk/client-{lambda,api-gateway,sqs,iam} |
The same script targets LocalStack and real AWS — only AWS_ENDPOINT_URL differs.
Step 1: docker-compose for LocalStack
Start with a single compose file. The crucial bits are LAMBDA_EXECUTOR=docker (so Lambdas run in spawned containers, mirroring real Lambda) and mounting the host docker socket so LocalStack can launch those containers.
# docker-compose.yml
name: serverless-dev
services:
localstack:
image: localstack/localstack:3.8
container_name: localstack
ports:
- '127.0.0.1:4566:4566'
environment:
SERVICES: lambda,apigateway,iam,logs,sts,sqs
DEFAULT_REGION: ap-southeast-1
LAMBDA_EXECUTOR: docker
LAMBDA_DOCKER_NETWORK: serverless-dev_default
DOCKER_HOST: unix:///var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock
extra_hosts:
- 'host.docker.internal:host-gateway'
healthcheck:
test: ['CMD', 'curl', '-fsS', 'http://localhost:4566/_localstack/health']
interval: 5s
Two pitfalls to flag up front:
- API Gateway v2 (HTTP API) is Pro-only in LocalStack. We use REST API v1 (
@aws-sdk/client-api-gateway), which is community-supported. - LocalStack Lambda logs do not land in CloudWatch in community edition. They live in the spawned Lambda container’s stdout — you tail them via
docker logs, notawslocal logs.
Bring it up:
docker compose up -d
curl http://localhost:4566/_localstack/health
Step 2: Project layout
Three apps, each compiled to its own Lambda zip. All share workspace packages — exactly the shape that makes serverless.yml painful but a script trivial.
apps/
├── auth-api/ Hono Lambda — /auth/* (HTTP)
├── products-api/ Hono Lambda — /products/* (HTTP)
└── email-worker/ SQS-triggered Lambda (no HTTP)
packages/
├── contracts/ Zod schemas — shared between FE, BE, and SQS messages
└── shared-kernel/ Result type, logger, etc.
scripts/
└── deploy-localstack.ts ← the entire deploy pipeline
Each app has a main.ts that exports a Lambda handler:
// apps/auth-api/src/main.ts
import { handle } from 'hono/aws-lambda';
import { buildAuthApp } from './app';
export const handler = handle(buildAuthApp());
// apps/email-worker/src/main.ts
import type { SQSEvent, SQSBatchResponse } from 'aws-lambda';
import { buildEmailWorker } from './app';
const worker = buildEmailWorker();
export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
return worker.handle(event);
};
Note the worker returns a SQSBatchResponse. We’ll come back to that — it’s the magic that lets one bad message get redriven without losing the others.
Step 3: Bundle each Lambda with Rolldown
Lambda cold-start scales with code size. A monorepo without bundling drags every transitive node_modules directory into the zip — easily 50+ MB. We want sub-100 KB bundles.
Rolldown is fast (Rust-based), supports ESM output natively, and inlines workspace packages while externalizing real npm deps so they live in node_modules next to the bundle.
// apps/auth-api/rolldown.config.mjs
import { defineConfig } from 'rolldown';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(HERE, '../../dist/apps/auth-api');
const isWorkspace = (id) => id.startsWith('@yourorg/');
const isRelative = (id) => id.startsWith('.') || id.startsWith('/');
export default defineConfig({
cwd: HERE,
input: resolve(HERE, 'src/main.ts'),
output: {
dir: OUT_DIR,
entryFileNames: 'main.js',
format: 'esm',
sourcemap: true,
},
platform: 'node',
resolve: {
conditionNames: ['node', 'import', 'default'],
extensionAlias: { '.js': ['.ts', '.tsx', '.js'] },
},
// Inline workspace pkgs; externalize everything else (kept in node_modules).
external: (id) => !isRelative(id) && !isWorkspace(id),
treeshake: true,
});
Rolldown gotcha (
1.0.0-rc.18): when bundling apps that pull in workspace deps, setresolve.tsconfigFilename: falsein the rolldown config (or use the new top-leveltsconfigoption). The auto-resolver chokes on workspaceextendschains.
The result for our auth Lambda is a 23 KB dist/apps/auth-api/main.js bundle plus a sourcemap.
Step 4: Build the Lambda zip
The deploy script needs three things in the zip:
- The bundled
main.js(+ sourcemap for stack traces) - A minimal
package.jsondeclaring"type": "module"so Lambda treatsmain.jsas ESM - A flat
node_modules/containing only the prod externals
pnpm deploy is the right tool for #3. It produces a self-contained directory with hoisted node_modules — exactly what Lambda expects.
// scripts/deploy-localstack.ts (excerpt)
import { execSync } from 'node:child_process';
import AdmZip from 'adm-zip';
import { readdirSync, statSync, existsSync, rmSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
const prepareDeployDir = (appName: string): string => {
const target = resolve(process.cwd(), '.deploy', appName);
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
// Hoisted layout = flat node_modules (no symlinks, no nesting).
// Architecture flags ensure we get linux-arm64 binaries even when building
// on a different host arch.
execSync(
[
'pnpm deploy',
`--filter ${appName}`,
'--prod',
'--config.node-linker=hoisted',
'--config.supported-architectures.os=linux',
'--config.supported-architectures.cpu=arm64',
'--config.supported-architectures.libc=glibc',
target,
].join(' '),
{ stdio: 'inherit' },
);
return target;
};
const buildZip = (deployDir: string, appName: string): Buffer => {
const zip = new AdmZip();
zip.addFile('package.json', Buffer.from(JSON.stringify({ type: 'module' })));
const bundlePath = resolve(process.cwd(), 'dist/apps', appName, 'main.js');
zip.addLocalFile(bundlePath);
if (existsSync(`${bundlePath}.map`)) zip.addLocalFile(`${bundlePath}.map`);
const nm = resolve(deployDir, 'node_modules');
if (existsSync(nm)) addDirToZip(zip, deployDir, nm);
return zip.toBuffer();
};
Why
node-linker=hoisted? pnpm’s default symlink layout bloats the zip 15× because Lambda’s zip flattens symlinks into duplicated copies. Hoisted = flat.
Step 5: IAM role for the Lambdas
LocalStack accepts almost any IAM trust policy, but real AWS requires a valid AssumeRole for lambda.amazonaws.com. Same code path on both:
import {
CreateRoleCommand,
GetRoleCommand,
IAMClient,
} from '@aws-sdk/client-iam';
const iam = new IAMClient({ endpoint: ENDPOINT, region: REGION });
async function ensureRole(): Promise<string> {
try {
const { Role } = await iam.send(new GetRoleCommand({ RoleName: ROLE_NAME }));
return Role!.Arn!;
} catch {
const trust = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { Service: 'lambda.amazonaws.com' },
Action: 'sts:AssumeRole',
},
],
};
const { Role } = await iam.send(
new CreateRoleCommand({
RoleName: ROLE_NAME,
AssumeRolePolicyDocument: JSON.stringify(trust),
}),
);
return Role!.Arn!;
}
}
For real AWS, attach managed policies like AWSLambdaBasicExecutionRole and AWSLambdaSQSQueueExecutionRole afterwards. LocalStack ignores them.
Step 6: SQS queue + DLQ + redrive policy
Create the DLQ first so the main queue can reference its ARN in the redrive policy.
import {
SQSClient,
CreateQueueCommand,
GetQueueUrlCommand,
GetQueueAttributesCommand,
} from '@aws-sdk/client-sqs';
const sqs = new SQSClient({ endpoint: ENDPOINT, region: REGION });
async function ensureQueues() {
// 1) DLQ
await sqs.send(new CreateQueueCommand({ QueueName: 'emails-dlq' }))
.catch(() => undefined);
const dlqUrl = (await sqs.send(new GetQueueUrlCommand({ QueueName: 'emails-dlq' })))
.QueueUrl!;
const dlqArn = (
await sqs.send(
new GetQueueAttributesCommand({ QueueUrl: dlqUrl, AttributeNames: ['QueueArn'] }),
)
).Attributes!.QueueArn!;
// 2) Main queue with redrive policy → DLQ after 3 failed receives
const redrivePolicy = JSON.stringify({
deadLetterTargetArn: dlqArn,
maxReceiveCount: '3',
});
await sqs.send(
new CreateQueueCommand({
QueueName: 'emails-queue',
Attributes: {
VisibilityTimeout: '30',
RedrivePolicy: redrivePolicy,
},
}),
).catch(() => undefined);
return { mainUrl, mainArn, dlqUrl, dlqArn };
}
A subtle but important detail: the queue URL the producer Lambda uses is not the same URL you hit from your host machine.
From host: http://localhost:4566/000000000000/emails-queue
From Lambda: http://sqs.ap-southeast-1.localhost.localstack.cloud:4566/000000000000/emails-queue
The deploy script rewrites the URL before injecting it into the producer Lambda’s environment:
const toInternal = (u: string) =>
u.replace('http://localhost:4566', 'http://localstack:4566');
Step 7: Create the Lambda functions
Each Lambda needs a runtime, role, handler, code zip, env vars, and (optionally) architecture override. We delete + recreate so deploys are idempotent.
import {
LambdaClient,
CreateFunctionCommand,
DeleteFunctionCommand,
} from '@aws-sdk/client-lambda';
const lambda = new LambdaClient({ endpoint: ENDPOINT, region: REGION });
async function deployLambda(spec: AppSpec, roleArn: string) {
const Environment = { Variables: envForApp(spec) };
try {
await lambda.send(new DeleteFunctionCommand({ FunctionName: spec.fnName }));
} catch { /* didn't exist */ }
const zipBuf = buildZip(prepareDeployDir(spec.name), spec.name);
await lambda.send(
new CreateFunctionCommand({
FunctionName: spec.fnName,
Runtime: 'nodejs20.x',
Role: roleArn,
Handler: 'main.handler', // matches `export const handler` in main.ts
Code: { ZipFile: zipBuf },
Timeout: 10,
MemorySize: 512,
Environment,
Architectures: ['arm64'],
}),
);
}
The envForApp helper is where reality bites: from inside the Lambda container, localhost is the Lambda container itself, not your laptop. So database/redis URLs need rewriting:
const httpEnv = {
AWS_ENDPOINT_URL: 'http://localstack:4566',
DATABASE_URL: process.env.DATABASE_URL!.replace('localhost', 'host.docker.internal'),
REDIS_URL: process.env.REDIS_URL!.replace('localhost', 'host.docker.internal'),
// ... other secrets
};
In real AWS you’d skip this rewrite, run RDS inside a VPC, and put the Lambda in subnets routed to the DB. But the env-var contract is identical.
Step 8: Wire SQS → Lambda with an event source mapping
This is the piece that turns a passive Lambda into an SQS consumer. The runtime polls the queue and invokes the Lambda with batched messages.
import {
CreateEventSourceMappingCommand,
ListEventSourceMappingsCommand,
DeleteEventSourceMappingCommand,
} from '@aws-sdk/client-lambda';
async function ensureEventSourceMapping(fnName: string, queueArn: string) {
// Wipe any previous mapping so re-deploys don't double up.
const existing = await lambda.send(
new ListEventSourceMappingsCommand({ FunctionName: fnName, EventSourceArn: queueArn }),
);
for (const esm of existing.EventSourceMappings ?? []) {
if (esm.UUID) {
await lambda.send(new DeleteEventSourceMappingCommand({ UUID: esm.UUID }))
.catch(() => undefined);
}
}
await lambda.send(
new CreateEventSourceMappingCommand({
FunctionName: fnName,
EventSourceArn: queueArn,
BatchSize: 5, // up to 5 messages per invocation
MaximumBatchingWindowInSeconds: 2, // or wait at most 2s, whichever first
}),
);
}
BatchSize=5 + BatchingWindow=2s strikes a reasonable latency/throughput balance for emails. Bump BatchSize to 10 for higher-throughput, latency-tolerant work.
Partial-batch failures
The worker handler returns batchItemFailures so the runtime only redrives the failed message IDs:
export const buildEmailWorker = (deps = {}) => ({
async handle(event: SQSEvent): Promise<SQSBatchResponse> {
const failures: SQSBatchItemFailure[] = [];
for (const record of event.Records) {
try {
const job = welcomeEmailJob.parse(JSON.parse(record.body));
await service.execute(job);
} catch (err) {
logger.error({ err, messageId: record.messageId }, 'email-worker.failed');
failures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures: failures };
},
});
Without this, one poisoned message would force the entire batch back onto the queue — including the four that succeeded — leading to duplicate processing.
Step 9: REST API Gateway in front of the HTTP Lambdas
We use API Gateway v1 (REST) because v2 (HTTP API) is Pro-only on LocalStack. The wiring per Lambda is:
- Create a resource at
/<prefix>(e.g./auth) - Create a child resource at
/<prefix>/{proxy+}to catch sub-paths - Attach
ANYmethod on both, withAWS_PROXYintegration to the Lambda - Grant API Gateway permission to invoke the Lambda
import {
APIGatewayClient,
CreateRestApiCommand,
CreateResourceCommand,
PutMethodCommand,
PutIntegrationCommand,
CreateDeploymentCommand,
} from '@aws-sdk/client-api-gateway';
const apigw = new APIGatewayClient({ endpoint: ENDPOINT, region: REGION });
async function wireMethod(apiId, resourceId, fnArn, fnName) {
await apigw.send(
new PutMethodCommand({
restApiId: apiId,
resourceId,
httpMethod: 'ANY',
authorizationType: 'NONE',
requestParameters: { 'method.request.path.proxy': true },
}),
);
await apigw.send(
new PutIntegrationCommand({
restApiId: apiId,
resourceId,
httpMethod: 'ANY',
type: 'AWS_PROXY',
integrationHttpMethod: 'POST',
uri: `arn:aws:apigateway:${REGION}:lambda:path/2015-03-31/functions/${fnArn}/invocations`,
}),
);
await lambda.send(
new AddPermissionCommand({
FunctionName: fnName,
StatementId: `apigw-invoke-${resourceId}`,
Action: 'lambda:InvokeFunction',
Principal: 'apigateway.amazonaws.com',
SourceArn: `arn:aws:execute-api:${REGION}:${ACCOUNT_ID}:${apiId}/*/*`,
}),
).catch(() => { /* permission may already exist */ });
}
Trap:
{proxy+}matches one or more path segments — but not zero. SoPOST /auth/signupworks,POST /authdoesn’t. WireANYon both/authand/auth/{proxy+}to cover routes that live at the prefix root.
After registering all methods, deploy a stage:
await apigw.send(new CreateDeploymentCommand({ restApiId: apiId, stageName: 'local' }));
const baseUrl = `${ENDPOINT}/restapis/${apiId}/local/_user_request_`;
That _user_request_ suffix is a LocalStack convention; in real AWS the URL is just https://<id>.execute-api.<region>.amazonaws.com/local. Same shape, different prefix.
Step 10: One command to deploy
The deploy script’s main() ties it all together:
async function main() {
const roleArn = await ensureRole();
// 1) SQS first — the producer Lambda needs the queue URL in its env.
const queues = await ensureQueues();
// 2) Lambdas
const arns: Record<string, string> = {};
for (const app of APPS) {
arns[app.name] = await deployLambda(app, roleArn, queues);
}
// 3) Event source mappings for SQS-triggered Lambdas
for (const app of APPS) {
if (app.triggeredBy === 'sqs') {
await ensureEventSourceMapping(app.fnName, queues.main.arn);
}
}
// 4) API Gateway in front of HTTP Lambdas
const { apiId, rootResourceId } = await ensureFreshRestApi();
for (const app of APPS) {
if (!app.pathPrefix) continue;
await wireRootAndProxy(apiId, rootResourceId, app.pathPrefix, arns[app.name], app.fnName);
}
await apigw.send(new CreateDeploymentCommand({ restApiId: apiId, stageName: 'local' }));
// 5) Write the gateway URL into the frontend's .env.local
writeFileSync(
'apps/web/.env.local',
`VITE_API_BASE_URL=${ENDPOINT}/restapis/${apiId}/local/_user_request_\n`,
);
}
Run it:
pnpm build # rolldown bundles all three apps
pnpm deploy:local # tsx scripts/deploy-localstack.ts
In ~15 seconds you have a working local serverless stack. The frontend .env.local is auto-populated, so pnpm --filter web dev picks up the gateway URL on startup.
Smoke testing
# 1) Sign up — auth-api Lambda invoked through API Gateway
curl -X POST $BASE_URL/auth/signup \
-H 'content-type: application/json' \
-d '{"email":"a@b.com","password":"hunter22hunter"}'
# 2) Watch the email-worker pick up the SQS message
docker logs -f $(docker ps -qf 'name=lambda-emails-worker')
# 3) Check queue depth
awslocal sqs get-queue-attributes \
--queue-url http://localhost:4566/000000000000/emails-queue \
--attribute-names ApproximateNumberOfMessages
# 4) Drain the DLQ if anything went sideways
awslocal sqs receive-message --queue-url http://localhost:4566/000000000000/emails-dlq
Going to real AWS
The script needs three small changes:
- Unset
AWS_ENDPOINT_URLso the SDK hits real AWS endpoints instead of LocalStack. - Inject secrets via SSM Parameter Store / Secrets Manager instead of
.env(useaws ssm get-parameter --with-decryptionat deploy time, or have the Lambda read at cold start). - Put HTTP Lambdas in a VPC with subnets routed to RDS + ElastiCache. Add
VpcConfigto theCreateFunctionCommandpayload.
Operational items to add for production:
- Custom domain + ACM cert in front of API Gateway.
- CloudWatch alarm on
ApproximateNumberOfMessagesVisible > 0for the DLQ. This is your “something is stuck” pager. - RDS Proxy if your HTTP Lambda concurrency × pool size threatens RDS connection limits.
- Provisioned concurrency for cold-start-sensitive endpoints (login, /me).
- Real email provider: swap
MockEmailProviderfor SES / Resend / Postmark — one new file inpackages/email/src/infrastructure/providers/.
Everything else — the deploy script, IAM, SQS topology, event source mappings, API Gateway wiring — is identical between LocalStack and real AWS. That’s the whole point.
Why this scales better than serverless.yml
- Code over YAML: ifs, loops, helper functions, and types. Need to deploy 12 Lambdas? Loop. Need conditional staging?
if (env === 'prod'). - One source of truth for resource names: a TypeScript const beats grepping across YAML files.
- No license boundary: it’s
@aws-sdk/*, MIT. - CI-friendly: no Serverless Framework binary to install, no telemetry calls, runs offline against LocalStack.
- Debuggable: when
CreateFunctionfails, you get a real AWS SDK error — not a CloudFormation stack rollback you have to dig out of CloudTrail.
The trade-off: you write the orchestration. But the orchestration is ~400 lines, and you get to keep it.
Recap
We built a complete serverless stack from zero:
- LocalStack for parity with real AWS
- Rolldown for ESM Lambda bundles in the 4–23 KB range
pnpm deploy --node-linker=hoistedfor clean monorepo zips- IAM role + Lambdas + SQS (with DLQ) + event source mapping + REST API Gateway, all via the AWS SDK
- Partial-batch failure responses for safe SQS retries
- One command (
pnpm deploy:local) that’s idempotent and ~15s end-to-end
The full reference implementation — including Hono apps, Drizzle migrations, JWT auth, and 95%+ test coverage — lives in the lambder repo.
Next post: putting it under load with k6, watching cold-starts in OpenTelemetry, and tuning the SQS batch window for cost-vs-latency.