top of page
Search

If you HAVE to go serverless on AWS- read on

  • Writer: Mark Kendall
    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)

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

 
 
 

Recent Posts

See All

Comments


Post: Blog2_Post

Subscribe Form

Thanks for submitting!

©2020 by LearnTeachMaster DevOps. Proudly created with Wix.com

bottom of page