ArticlesAWSLatest NewsNode.jsShowcaseTutorial
AWS Lambda Monorepo with Node.js 22 and pnpm Workspaces
27 Mar 2022 · Updated 27 Sept 2026 · 9 min read

What changed in this update: Updated September 2026 for Node.js 22 (
nodejs22.x), pnpm workspaces instead of npm workspaces, ES modules, esbuild bundling in place of the old install script, and OIDC credentials in the Bitbucket pipeline.
What is a Monorepo
A monorepo is a single repository which stores all the code for multiple independent projects. It can be used to create a single source of truth and enable code reuse between the different functions which are provided.
Why would you use a monorepo?
One of the problems during the development of individual microservices comes from copying and pasting code (lazy developer === happy developer). During the development of microservices you will create modules which provide functionality which can be reused, such as data formatting functions or wrappers for external libraries.
Another problem which can occur in microservices is the management of external dependencies. Having to manage compatible versions between dependencies can become dependency hell. For example, I used to reach for axios in every microservice. If that library needed an update due to a vulnerability, then multiple updates, releases and deployments would have to happen to update every service which included it. (These days Node.js has fetch built in, which is one less dependency to patch, so the example below uses that instead.)
Benefits of a Monorepo
There are a number of benefits to using a monorepo:
- Easily reuse code between functions
- Improve management of external libraries
- Code refactoring made easier
- Team collaboration can be improved
Limitations and Disadvantages
As with everything there are some limitations or disadvantages:
- Increased complexity of the repository
- Deployments (especially Bitbucket) have limitations
- There is a learning curve to how to set up and manage a repo
How to Create a JavaScript Monorepo for AWS Lambda
This is not a definitive how-to guide, this is the process of me learning how to set up a monorepo for AWS Lambdas which use Node. The information here is what I have learnt from implementing this technique. Some of the steps are self-explanatory but where I feel there were decisions made I will try to explain why I made those choices.
The project uses the following:
- Node.js 22 (the
nodejs22.xLambda runtime) - pnpm workspaces
- esbuild
- SAM (AWS Serverless Application Model)
- CloudFormation nested stacks
- Bitbucket Pipelines
The original version of this post used npm workspaces. They still work, but pnpm has become the default choice for JavaScript monorepos: it’s fast, it keeps one copy of each package on disk, and it’s strict about dependencies, so a function can’t quietly use a package it never declared.
Create a project folder:
mkdir node-monorepo-lambda
cd node-monorepo-lambda
git init
Create the root package.json file:
Node.js 22 ships with Corepack, which can install pnpm for you and pin the version in package.json (as the packageManager field) so CI uses the same one you do:
corepack enable
corepack use pnpm@latest
pnpm init
Mark the root package as private and an ES module, and add a script that builds every workspace (keep the packageManager line Corepack added):
{
"name": "node-monorepo-lambda",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm --recursive run build"
}
}
Add multiple Lambda projects to separate folders
mkdir -p lambda-a/src
mkdir -p lambda-b/src
Initialise workspace folders to enable shared dependencies
To enable the monorepo to share dependencies it will use pnpm workspaces. Where npm lists workspaces in the root package.json, pnpm uses a pnpm-workspace.yaml file at the root of the repo:
packages:
- "lambda-*"
- "packages/*"
Then give each Lambda its own package.json:
(cd lambda-a && pnpm init)
(cd lambda-b && pnpm init)
Edit each one so it’s private and an ES module, with a build script that bundles the function. This is lambda-a/package.json; lambda-b is the same with its own name:
{
"name": "lambda-a",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/app.js --bundle --platform=node --target=node22 --format=esm --outfile=dist/app.mjs"
}
}
Create a root CloudFormation Template which will create a nested stack
To deploy the monorepo the decision was made to have one AWS CloudFormation stack. This template uses the SAM transform to tell AWS to convert from Serverless to CloudFormation.
Each microservice Lambda function has its own template file which is referenced from the following root template as a nested application. This enables the functions to be decoupled and allows them to manage their own resources.
The root stack could always reference infrastructure resources which may be required across the whole organisation.
touch template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: SAM app which has multiple Lambdas which are in a monorepo
Resources:
LambdaFunctionA:
Type: AWS::Serverless::Application
Properties:
Location: lambda-a/template.yaml
LambdaFunctionB:
Type: AWS::Serverless::Application
Properties:
Location: lambda-b/template.yaml
AWS::Serverless::Application is SAM’s wrapper around AWS::CloudFormation::Stack. When you deploy, SAM uploads each nested template (and the code it points at) to S3 and swaps the local path for the S3 URL.
Create AWS SAM Templates for each function
This snippet is taken and modified from the AWS SAM ‘hello-world’ template. It deploys a Lambda function using Node.js 22 and an API Gateway which has a GET endpoint at /hello.
The output of the template will be the fully qualified URL for the endpoint which will return a simple JSON structure.
touch lambda-a/template.yaml
touch lambda-b/template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: SAM app which has Lambda and an API Gateway
Globals:
Function:
Timeout: 3
Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: dist/
Handler: app.lambdaHandler
Runtime: nodejs22.x
Architectures:
- arm64
Events:
HelloWorld:
Type: Api
Properties:
Path: /hello
Method: get
Outputs:
HelloWorldApi:
Description: "API Gateway endpoint URL for Prod stage for Hello World function"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
Note that CodeUri points at dist/, not src/. More on why in the deployment section.
Create Lambda Handler Files
These handler files are referenced in the SAM template. Any calls to the endpoint will trigger this function. A call is made to the https://checkip.amazonaws.com/ endpoint which returns the caller’s IP address. This data is passed to the shared package @monorepo/create-response which will return a formatted response which can then be passed back to API Gateway.
touch lambda-a/src/app.js
touch lambda-b/src/app.js
import { createResponse } from '@monorepo/create-response';
const url = 'https://checkip.amazonaws.com/';
export const lambdaHandler = async () => {
try {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`checkip returned ${res.status}`);
}
return createResponse(200, {
message: 'hello world',
location: (await res.text()).trim(),
});
} catch (err) {
console.error(err);
return createResponse(500, { message: 'Something went wrong' });
}
};
The original version returned the raw error object to API Gateway, which gets you an unhelpful 502 at best. Returning a proper 500 response is kinder to whoever is calling it.
Install shared tooling for each Lambda
The function code above has no third-party runtime dependencies now that fetch is built into Node.js. It does need esbuild to bundle it, so add that as a dev dependency of every Lambda in one go. The --filter flag targets workspaces, the same way npm’s -w flag did:
pnpm --filter "./lambda-*" add --save-dev esbuild
pnpm keeps a single lockfile at the root, so every function gets the same esbuild version and there’s one place to update it.
Using a shared module in both Lambdas
As both Lambda functions can use the same createResponse function, we can add a packages folder which contains shared modules. These are added to the monorepo in the same way that the functions are created as workspaces (the packages/* line in pnpm-workspace.yaml already covers them). The packages folder separates these shared modules from the Lambda functions.
mkdir -p packages/create-response
(cd packages/create-response && pnpm init)
touch packages/create-response/index.js
Give it a scoped name so it’s obvious it lives in this repo, and point exports at the entry file:
{
"name": "@monorepo/create-response",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": "./index.js"
}
Add the following code which is a helper function to create a response object. This is a very basic function which takes a status and body argument, and returns an object to the caller.
export function createResponse(status, body) {
return {
statusCode: status,
body: JSON.stringify(body),
};
}
Add the package as a dependency of each of the Lambdas. The workspace:* protocol tells pnpm to always link the local copy rather than look for it on the npm registry:
pnpm --filter "./lambda-*" add "@monorepo/create-response@workspace:*"
Each Lambda’s package.json now contains:
"dependencies": {
"@monorepo/create-response": "workspace:*"
}
Monorepo Complete
You now have a working monorepo with shared tooling and a shared module. Once you have run pnpm install at the root, if you look in lambda-a/node_modules/@monorepo you will see that create-response is a symlink back to packages/create-response.
You can add devDependencies to the root package.json with pnpm add --save-dev --workspace-root so that they can be shared amongst each of the functions. Each Lambda should have a test suite, and they can all share a testing framework such as Vitest or Jest. This means that these dependencies are then managed in one place where they can be kept up to date.
Using Bitbucket Pipelines to Deploy Lambdas to AWS
Using CI/CD pipelines to deploy code is generally the best practice to get your code from your local environment into the cloud. AWS SAM provides a helpful CLI (Command Line Interface) to deploy SAM projects into your AWS environments.
Bitbucket is a Git repository management solution which offers pipelines. These pipelines provide containerised environments which can be triggered upon commits to deploy your solution to the cloud.
One quirk when deploying Lambdas to AWS using SAM is that the dependencies that are deployed are not always what you are expecting. If you were to use the sam deploy command when you have all your devDependencies installed then they also get deployed, which can result in some pretty large functions.
During the initial deployments of the monorepo, the shared packages were failing when using the sam build command. The command copies each function’s folder into an .aws-sam build folder and installs its dependencies there, and as the workspace symlinks are not recreated the deployments would fail or be missing the shared module. With pnpm it’s worse still, as npm has no idea what workspace:* means.
My original fix was a shell script that looped over every function and ran npm install --production in each one. These days I bundle instead. Each Lambda’s build script runs esbuild, which follows the workspace symlinks, pulls in the shared module and writes a single dist/app.mjs file. That is why the templates point CodeUri at dist/: SAM uploads exactly that file and nothing else, so no devDependencies and no symlink surprises. It also means you skip sam build entirely and go straight to sam deploy. Add dist/ to your .gitignore.
Build everything from the root:
pnpm build
The pipeline uses the AWS SAM build image for Node.js 22, which includes the SAM CLI. Rather than storing long-lived access keys as repository variables, it uses Bitbucket’s OpenID Connect support to assume an IAM role: add Bitbucket as an OIDC identity provider in IAM, create a role that trusts it, and save the role’s ARN as an AWS_ROLE_ARN repository variable. Once authenticated, dependencies are installed, the functions are built, and sam deploy deploys the stack to the AWS Cloud.
image: public.ecr.aws/sam/build-nodejs22.x
pipelines:
branches:
main:
- step:
name: Build and Deploy
oidc: true
script:
- export AWS_WEB_IDENTITY_TOKEN_FILE=$(pwd)/web-identity-token
- echo $BITBUCKET_STEP_OIDC_TOKEN > $(pwd)/web-identity-token
- corepack enable
- pnpm install --frozen-lockfile
- pnpm build
- >
sam deploy --no-confirm-changeset --no-fail-on-empty-changeset
--stack-name lambda-monorepo
--resolve-s3
--region $AWS_DEFAULT_REGION
--capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND
AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE are picked up automatically by SAM (and the AWS CLI), so there’s no aws configure step. --resolve-s3 lets SAM create and manage the artifacts bucket for you, and CAPABILITY_AUTO_EXPAND is needed because the nested applications use the SAM transform too.
Thank you for taking the time to read this blog post. If you have any suggestions or thoughts then please reach out, I’ll be happy to discuss any of the decisions. Hopefully as I discover more about workspaces and monorepos I will be able to share that knowledge here.


