If you HAVE to go serverless on AWS- read on
- Mark Kendall
- 1 day ago
- 6 min read
Awesome — here’s a battle-tested playbook to make Node.js + Lambda development feel as predictable as Spring Boot. It locks versions, standardizes the workflow, and gives you local dev, tests, CI, and CDK deploys without yak-shaving.
⸻
0) Opinionated defaults (copy these)
• Runtime: Node.js 20.x
• Lang: TypeScript
• Module format: CommonJS (to avoid ESM edge cases)
• Bundler: esbuild (fast, zero config)
• Infra: AWS CDK (TypeScript) + aws-cdk-lib
• Lambda construct: NodejsFunction (auto-bundles with esbuild)
• Local dev: unit tests + “invoke with sample event” + optional LocalStack
• Observability: AWS Lambda Powertools (TS) for logs/metrics/tracing
• Config: SSM Parameter Store / Secrets Manager (no secrets in code)
• Auth: AWS named profiles (or SSO) — never “default” credentials
• CI: GitHub Actions (or Jenkins) with npm ci + cdk synth/diff/deploy
⸻
1) One-time machine setup
# Mac / Linux — pin Node with Volta (or use nvm if you prefer)
curl https://get.volta.sh | bash
exec $SHELL -l
volta install node@20.17.0
volta install npm@10
volta pin node@20.17.0
volta pin npm@10
# AWS CLI + CDK
brew install awscli
npm i -g aws-cdk@2
# (Optional) LocalStack for deeper local tests
brew install localstack/tap/localstack
Configure AWS profile (choose one approach):
# Static keys (for sandboxes)
aws configure --profile dev
# OR: SSO (recommended in enterprises)
aws configure sso --profile dev
⸻
2) Project scaffold (monorepo-ready but simple)
lambda-ts-starter/
├─ infra/ # CDK app & stacks
│ ├─ bin/infra.ts
│ └─ lib/app-stack.ts
├─ services/
│ └─ hello/
│ ├─ src/handler.ts
│ ├─ package.json
│ ├─ tsconfig.json
│ └─ jest.config.cjs
├─ package.json # root scripts & workspaces
├─ tsconfig.base.json # shared TS config
└─ .nvmrc (optional if not using Volta)
Root package.json (workspaces + pinned engines):
{
"name": "lambda-ts-starter",
"private": true,
"workspaces": ["infra", "services/*"],
"engines": { "node": "20.x" },
"scripts": {
"build": "npm run -w services/hello build && npm run -w infra build",
"test": "npm run -w services/hello test",
"lint": "eslint .",
"synth": "npm run -w infra synth",
"diff": "npm run -w infra diff",
"deploy": "npm run -w infra deploy",
"invoke:hello": "node services/hello/local-invoke.cjs"
},
"devDependencies": {
"eslint": "^9",
"@types/jest": "^29"
}
}
Shared tsconfig.base.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"sourceMap": true,
"outDir": "dist"
}
}
⸻
3) The Lambda (TypeScript, CJS, Powertools)
services/hello/package.json
{
"name": "@svc/hello",
"type": "commonjs",
"main": "dist/handler.js",
"scripts": {
"build": "esbuild src/handler.ts --bundle --platform=node --target=node20 --outfile=dist/handler.js",
"test": "jest --passWithNoTests"
},
"dependencies": {
"@aws-lambda-powertools/logger": "^2",
"@aws-lambda-powertools/metrics": "^2",
"@aws-lambda-powertools/tracer": "^2"
},
"devDependencies": {
"esbuild": "^0.23",
"jest": "^29",
"ts-jest": "^29",
"typescript": "^5.5"
}
}
services/hello/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts", "local-events/**/*.json"]
}
services/hello/src/handler.ts
import { Logger } from "@aws-lambda-powertools/logger";
import { Metrics, MetricUnits } from "@aws-lambda-powertools/metrics";
const logger = new Logger({ serviceName: "hello" });
const metrics = new Metrics({ namespace: "App" });
export const handler = async (event: any) => {
logger.addContext({ requestId: event?.requestContext?.requestId });
logger.info("Received event", { event });
const name = event?.queryStringParameters?.name ?? "world";
metrics.addMetric("HelloInvocations", MetricUnits.Count, 1);
return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ message: `Hello, ${name}!` })
};
};
Local sample event & invoker
services/hello/local-events/api-gw.json
{
"version": "2.0",
"routeKey": "GET /hello",
"rawPath": "/hello",
"queryStringParameters": { "name": "Mark" },
"requestContext": { "requestId": "local-1" }
}
services/hello/local-invoke.cjs
const { handler } = require("./dist/handler.js");
const fs = require("fs");
(async () => {
const event = JSON.parse(fs.readFileSync(__dirname + "/local-events/api-gw.json","utf8"));
const res = await handler(event);
console.log("Response:\n", res);
})();
Build & invoke locally (no AWS needed):
npm ci
npm run build
npm run invoke:hello
⸻
4) CDK infra (deployable, repeatable)
infra/package.json
{
"name": "infra",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.json",
"synth": "cdk synth -c profile=dev",
"diff": "cdk diff -c profile=dev",
"deploy": "cdk deploy --require-approval never -c profile=dev"
},
"dependencies": {
"aws-cdk-lib": "2.152.0",
"constructs": "^10",
"dotenv": "^16"
},
"devDependencies": {
"typescript": "^5.5"
}
}
infra/tsconfig.json
{ "extends": "../tsconfig.base.json", "include": ["bin/**/*.ts","lib/**/*.ts"] }
infra/bin/infra.ts
#!/usr/bin/env node
import "dotenv/config";
import * as cdk from "aws-cdk-lib";
import { AppStack } from "../lib/app-stack";
const app = new cdk.App();
new AppStack(app, "app-stack", {
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: "us-east-1" }
});
infra/lib/app-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigw from "aws-cdk-lib/aws-apigateway";
import * as iam from "aws-cdk-lib/aws-iam";
import * as path from "path";
export class AppStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const helloFn = new NodejsFunction(this, "HelloFn", {
runtime: lambda.Runtime.NODEJS_20_X,
entry: path.join(__dirname, "../../services/hello/src/handler.ts"),
handler: "handler",
bundling: {
// keep CJS, externalize aws-sdk v3
minify: true,
target: "node20",
format: cdk.aws_lambda_nodejs.OutputFormat.CJS,
externalModules: ["@aws-sdk/*"]
},
environment: {
POWERTOOLS_SERVICE_NAME: "hello",
NODE_OPTIONS: "--enable-source-maps"
}
});
// Example least-privilege policy (adjust to your needs)
helloFn.addToRolePolicy(new iam.PolicyStatement({
actions: ["ssm:GetParameter"],
resources: ["*"]
}));
const api = new apigw.RestApi(this, "Api", {
deployOptions: { stageName: "dev" }
});
const hello = api.root.addResource("hello");
hello.addMethod("GET", new apigw.LambdaIntegration(helloFn));
}
}
Deploy with a named profile:
export AWS_PROFILE=dev
npm run build
npm run synth
npm run deploy
⸻
5) Testing like a backend team
services/hello/jest.config.cjs
module.exports = {
testEnvironment: "node",
transform: { "^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }] },
testMatch: ["**/__tests__/**/*.test.ts"]
};
services/hello/__tests__/handler.test.ts
import { handler } from "../src/handler";
test("returns 200 with greeting", async () => {
const res = await handler({ queryStringParameters: { name: "Mark" } } as any);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).message).toMatch("Mark");
});
Run:
npm test
⸻
6) Config & secrets (no leaks)
• Non-secret config: process.env.FOO via CDK environment.
• Secrets: store in SSM Parameter Store or Secrets Manager. Fetch at runtime using AWS SDK v3; cache in-memory.
• IAM: grant only the parameter/secret ARNs you read (no wildcards in prod).
⸻
7) Observability (Powertools)
You already wired Logs/Metrics. Flip on tracing:
• Enable X-Ray on the function (CDK: tracing: lambda.Tracing.ACTIVE)
• Use @aws-lambda-powertools/tracer to annotate subsegments around I/O.
⸻
8) Local “bigger” options (when you need them)
• LocalStack: emulate AWS services for integration tests.
• SAM CLI: sam local start-api if you want API Gateway emulation (you can synth a SAM template from CDK assets or write a tiny template for the bundled file).
• Docker images for Lambdas: if you need absolute runtime parity, package functions as container images — then your local run equals prod.
⸻
9) CI example (GitHub Actions)
.github/workflows/cicd.yml
name: ci
on: [push]
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run build
- run: npm test
deploy-dev:
needs: build-test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run build
- name: Configure AWS (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: us-east-1
- run: npm run deploy
(Swap for Jenkins if needed — npm ci && npm run build && npm test && npm run deploy with an AWS profile or OIDC role.)
⸻
10) Troubleshooting quick hits
• “Unknown file extension .ts” / ESM errors: ensure type is not "module"; keep CJS; bundle with esbuild; deploy the bundled JS.
• Different Node locally vs Lambda: pin Node with Volta; set Lambda runtime to NODEJS_20_X; target node20 in esbuild.
• AWS creds flakiness: always use a named profile (AWS_PROFILE=dev) or SSO; never rely on “default.”
• Cold start pain: keep functions small; use Powertools, lazy-init SDK clients; consider provisioned concurrency for critical paths.
• VPC timeouts: avoid VPC unless you need private resources; if you must, add NAT or VPC endpoints for AWS APIs.
⸻
What you get with this playbook
• Deterministic Node/tooling (Volta pinning)
• One-command build/test/invoke locally
• CDK synth/diff/deploy that Just Works™
• Proper logs/metrics/tracing from day one
• A path to LocalStack/SAM if you need heavier local
Comments