Skip to main content
The CGIAR Risk Intelligence API is a NestJS application deployed to AWS Lambda. A single deployment package is used for both the API Lambda (HTTP requests) and Worker Lambda (background jobs).

Prerequisites

  • AWS CLI configured with valid credentials
  • pnpm installed
  • jq, zip installed
  • CloudFormation stack AllianceRiskStack already deployed
  • Deployment bucket exists (alliance-risk-deploy-{env})

Quick Start

From the project root:
pnpm deploy:api          # Deploy to dev environment
pnpm deploy:api staging  # Deploy to staging
This runs scripts/deploy-api.sh, which handles the full deployment pipeline.

Deployment Pipeline

The deploy-api.sh script performs the following steps:

1. Build TypeScript with tsc

pnpm --filter @alliance-risk/shared build
pnpm --filter @alliance-risk/api build
Why tsc first? NestJS uses TypeScript decorators extensively (@Injectable(), @Controller(), etc.). Decorator metadata must be preserved, so we compile with tsc before bundling.

2. Bundle with esbuild

The deployment package is created using esbuild to inline all dependencies into single-file bundles:
esbuild packages/api/dist/src/lambda.js \
  --bundle --platform=node --target=node20 --format=cjs \
  --outfile=dist/src/lambda.js \
  --external:@prisma/client --external:.prisma/client \
  --external:pg --external:pg-pool \
  --minify-syntax --minify-whitespace --tree-shaking=true

esbuild packages/api/dist/src/worker.js \
  --bundle --platform=node --target=node20 --format=cjs \
  --outfile=dist/src/worker.js \
  --external:@prisma/client --external:.prisma/client \
  --external:pg --external:pg-pool \
  --minify-syntax --minify-whitespace --tree-shaking=true
Result: All 1,400+ NestJS node_modules packages are inlined into two single-file bundles:
  • dist/src/lambda.js (~4 MB)
  • dist/src/worker.js (~4 MB)

3. Copy External Packages

Some packages cannot be bundled and must be copied separately:
PackageReason
@prisma/clientGenerated code with WASM binaries
.prisma/clientGenerated Prisma Client (dynamic requires)
@prisma/adapter-pginstanceof checks require module identity
@prisma/driver-adapter-utilsTransitive dep of adapter-pg
@prisma/client-runtime-utilsTransitive dep of adapter-pg
@prisma/debugTransitive dep of driver-adapter-utils
pg, pg-pool, pg-cloudflareNative addon loading patterns, runtime require()
Transitive pg depspg-types, pg-protocol, postgres-array, postgres-date, postgres-interval, pg-int8, postgres-bytea, xtend, pgpass, split2
@nestjs/microservicesOptional NestJS dep (lazy require(), not installed)
@nestjs/websocketsOptional NestJS dep (lazy require(), not installed)
The script uses pnpm symlink resolution to locate these packages in the .pnpm store and copies them to node_modules/ in the deployment package. Optimization: Only PostgreSQL WASM binaries are copied (other DB engines excluded), and all .d.ts, .map, README.md, test directories are removed.

4. Copy Prisma Schema

cp packages/api/prisma/schema.prisma prisma/
The Prisma schema is required at runtime for Prisma Client.

5. Create Deployment Package

cd temp-build-dir
zip -r lambda.zip dist node_modules prisma
Package Size:
  • Before esbuild: ~80 MB (all node_modules)
  • After esbuild: ~8-10 MB (bundles + external packages only)
Deployment Time:
  • Before esbuild: 10+ minutes
  • After esbuild: Less than 2 minutes

6. Upload to S3

aws s3 cp lambda.zip s3://alliance-risk-deploy-{env}/api/latest.zip
The deployment bucket is created during infrastructure deployment.

7. Update Lambda Functions

Both Lambda functions are updated with the same deployment package (different entry points):
aws lambda update-function-code \
  --function-name alliance-risk-api \
  --s3-bucket alliance-risk-deploy-{env} \
  --s3-key api/latest.zip

aws lambda update-function-code \
  --function-name alliance-risk-worker \
  --s3-bucket alliance-risk-deploy-{env} \
  --s3-key api/latest.zip

aws lambda wait function-active-v2 --function-name alliance-risk-api
aws lambda wait function-active-v2 --function-name alliance-risk-worker

Lambda Configuration

API Lambda

Function Name: alliance-risk-api Handler: dist/src/lambda.handler Entry Point (packages/api/src/lambda.ts):
import { NestFactory } from '@nestjs/core';
import serverlessExpress from '@codegenie/serverless-express';
import { AppModule } from './app.module';
import { configureApp } from './configure-app';

let cachedServer;

export const handler = async (event, context) => {
  context.callbackWaitsForEmptyEventLoop = false;

  if (!cachedServer) {
    const app = await NestFactory.create(AppModule);
    configureApp(app);
    await app.init();
    cachedServer = serverlessExpress({ app: app.getHttpAdapter().getInstance() });
  }

  return cachedServer(event, context);
};
Key Details:
  • context.callbackWaitsForEmptyEventLoop = false — Required to prevent Lambda timeout due to Prisma connection pool
  • Caching: NestJS app instance is cached across Lambda warm starts for performance
  • @codegenie/serverless-express: Adapts NestJS to AWS Lambda proxy events (API Gateway HTTP API payload format 2.0)
Timeout: 29 seconds (< API Gateway 30s limit) Memory: 1024 MB VPC: Attached to private subnets for RDS access

Worker Lambda

Function Name: alliance-risk-worker Handler: dist/src/worker.handler Entry Point (packages/api/src/worker.ts):
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

let cachedContext;

export const handler = async (event, context) => {
  context.callbackWaitsForEmptyEventLoop = false;

  if (!cachedContext) {
    cachedContext = await NestFactory.createApplicationContext(AppModule);
  }

  const jobsService = cachedContext.get(JobsService);

  if (event.action === 'run-sql') {
    // Execute raw SQL for database migrations
    const prisma = cachedContext.get(PrismaService);
    const statements = event.sql.split(';').filter(s => s.trim());
    let executed = 0;
    for (const stmt of statements) {
      await prisma.$executeRawUnsafe(stmt);
      executed++;
    }
    return { success: true, executed };
  }

  // Process async job
  const job = await jobsService.findOne(event.jobId);
  await jobsService.process(job);

  return { success: true };
};
Timeout: 900 seconds (15 minutes) Memory: 1024 MB VPC: Attached to private subnets for RDS access Special Action: run-sql — Used by migrate-remote.sh to run database migrations (see Database Migrations)

Environment Variables

Both Lambda functions receive these environment variables (defined in the CloudFormation template):

API Lambda

VariableSourceExample
ENVIRONMENTParameterdev, staging, production
COGNITO_USER_POOL_ID!Ref UserPoolus-east-1_abc123
COGNITO_CLIENT_ID!Ref UserPoolClient7abc123def456
S3_BUCKET_NAME!Ref FileBucketalliance-risk-files-123456789012
AWS_ACCOUNT_ID!Ref AWS::AccountId123456789012
WORKER_FUNCTION_NAME!Ref WorkerLambdaalliance-risk-worker
CORS_ORIGINParameter* or https://app.example.com
DATABASE_URLConstructed (see below)postgresql://postgres:***@host:5432/alliance_risk

Worker Lambda

VariableSourceExample
ENVIRONMENTParameterdev, staging, production
AWS_ACCOUNT_ID!Ref AWS::AccountId123456789012
S3_BUCKET_NAME!Ref FileBucketalliance-risk-files-123456789012
DATABASE_URLConstructed (see below)postgresql://postgres:***@host:5432/alliance_risk

DATABASE_URL Construction

The DATABASE_URL is constructed dynamically in the CloudFormation template using Secrets Manager and RDS endpoint:
DATABASE_URL: !Sub
  - "postgresql://${Username}:${Password}@${Host}:5432/alliance_risk"
  - Username: postgres
    Password: !Sub "{{resolve:secretsmanager:${DbSecret}:SecretString:password}}"
    Host: !GetAtt Database.Endpoint.Address
Critical: Without this variable, Prisma falls back to localhost:5432 and all database operations fail.

Critical TypeScript Configuration

The API package must have esModuleInterop: true in packages/api/tsconfig.json:
{
  "compilerOptions": {
    "esModuleInterop": true,
    "module": "commonjs",
    "target": "ES2021",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
  }
}
Why? Without esModuleInterop, imports like import express from 'express' compile to express_1.default() without the __importDefault helper, causing runtime crashes in Lambda:
TypeError: (0, express_1.default) is not a function

Deployment Order

When deploying from scratch:
1. Deploy infrastructure:    pnpm --filter @alliance-risk/infra cfn:deploy dev
2. Run database migrations:  pnpm migrate:remote
3. Deploy API:               pnpm deploy:api
4. Deploy web:               pnpm deploy:web
For routine code changes (no infrastructure or schema changes), only step 3 is needed.

Troubleshooting

Lambda returns HTTP 500 (often masked as CORS error on frontend)

Check CloudWatch Logs:
aws logs tail /aws/lambda/alliance-risk-api --follow
aws logs tail /aws/lambda/alliance-risk-worker --follow
Common Causes:
ErrorCauseSolution
Cannot find module 'X'Missing external packageAdd to EXTERNALS array and copy logic in deploy-api.sh
(0, x.default) is not a functionESM/CJS interop issueEnsure esModuleInterop: true in tsconfig.json
Prisma connection failureDATABASE_URL missingCheck CloudFormation template and Lambda configuration
Empty Prisma error messageBundling issueLog .code and .meta instead of .message

Lambda timeout during cold start

Cause: Prisma connection pool keeps event loop alive Solution: Ensure context.callbackWaitsForEmptyEventLoop = false in both lambda.ts and worker.ts

Deployment package too large (>50 MB)

Cause: External packages not optimized Solution: Check cleanup logic in deploy-api.sh (removes .d.ts, .map, README.md, test directories)

esbuild warnings about dynamic requires

Expected. Packages like Prisma use dynamic require() and must be external. Warnings can be ignored.

Performance Optimization

  • ARM64 Architecture: 20% cost reduction
  • Lambda Caching: NestJS app instance cached across warm starts
  • Tree Shaking: Unused code eliminated by esbuild
  • Minification: Syntax and whitespace minified (not identifier mangling)
  • Selective Prisma Binaries: Only PostgreSQL WASM copied (SQLite, MySQL excluded)

Security Best Practices

  • Secrets Manager: Database credentials never in environment variables (dynamically resolved)
  • VPC Isolation: Lambdas in private VPC, no public internet access (except via VPC endpoints)
  • Least Privilege IAM: Lambda roles grant only required permissions
  • No Hardcoded Credentials: All AWS services accessed via IAM roles

Rollback

If a deployment fails, the previous deployment package is still available in S3:
aws lambda update-function-code \
  --function-name alliance-risk-api \
  --s3-bucket alliance-risk-deploy-dev \
  --s3-key api/previous.zip
Consider versioning S3 keys with timestamps for easier rollback:
S3_KEY="api/$(date +%Y%m%d-%H%M%S).zip"
aws s3 cp lambda.zip "s3://${DEPLOY_BUCKET}/${S3_KEY}"

Build docs developers (and LLMs) love