Backend DevOps AWS 17 min read

Setting Up a Serverless Stack From Scratch: AWS Lambda + API Gateway + SQS on LocalStack

Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

May 3, 2026

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

LayerChoice
Monorepopnpm workspaces + Turborepo
Lambda runtimeNode 20.x (nodejs20.x), arm64
HTTP frameworkHono + hono/aws-lambda adapter
BundlerRolldown — ESM bundles, ~4–23 KB per Lambda
Async messagingSQS + event source mapping, DLQ with maxReceiveCount=3, partial-batch failures
Local cloudlocalstack/localstack:3.8 (community edition: REST API v1, Lambda, IAM, SQS)
DeployTypeScript 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, not awslocal 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, set resolve.tsconfigFilename: false in the rolldown config (or use the new top-level tsconfig option). The auto-resolver chokes on workspace extends chains.

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:

  1. The bundled main.js (+ sourcemap for stack traces)
  2. A minimal package.json declaring "type": "module" so Lambda treats main.js as ESM
  3. 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:

  1. Create a resource at /<prefix> (e.g. /auth)
  2. Create a child resource at /<prefix>/{proxy+} to catch sub-paths
  3. Attach ANY method on both, with AWS_PROXY integration to the Lambda
  4. 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. So POST /auth/signup works, POST /auth doesn’t. Wire ANY on both /auth and /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:

  1. Unset AWS_ENDPOINT_URL so the SDK hits real AWS endpoints instead of LocalStack.
  2. Inject secrets via SSM Parameter Store / Secrets Manager instead of .env (use aws ssm get-parameter --with-decryption at deploy time, or have the Lambda read at cold start).
  3. Put HTTP Lambdas in a VPC with subnets routed to RDS + ElastiCache. Add VpcConfig to the CreateFunctionCommand payload.

Operational items to add for production:

  • Custom domain + ACM cert in front of API Gateway.
  • CloudWatch alarm on ApproximateNumberOfMessagesVisible > 0 for 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 MockEmailProvider for SES / Resend / Postmark — one new file in packages/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 CreateFunction fails, 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=hoisted for 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.

serverless aws-lambda localstack api-gateway sqs typescript hono rolldown monorepo
Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Full-stack developer with 8+ years experience. Building scalable systems with Go, TypeScript, and React.