Prerequisites
- AWS CLI configured with valid credentials
- pnpm installed
- jq, zip installed
- CloudFormation stack
AllianceRiskStackalready deployed - Deployment bucket exists (
alliance-risk-deploy-{env})
Quick Start
From the project root:scripts/deploy-api.sh, which handles the full deployment pipeline.
Deployment Pipeline
Thedeploy-api.sh script performs the following steps:
1. Build TypeScript with tsc
@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: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:| Package | Reason |
|---|---|
@prisma/client | Generated code with WASM binaries |
.prisma/client | Generated Prisma Client (dynamic requires) |
@prisma/adapter-pg | instanceof checks require module identity |
@prisma/driver-adapter-utils | Transitive dep of adapter-pg |
@prisma/client-runtime-utils | Transitive dep of adapter-pg |
@prisma/debug | Transitive dep of driver-adapter-utils |
pg, pg-pool, pg-cloudflare | Native addon loading patterns, runtime require() |
| Transitive pg deps | pg-types, pg-protocol, postgres-array, postgres-date, postgres-interval, pg-int8, postgres-bytea, xtend, pgpass, split2 |
@nestjs/microservices | Optional NestJS dep (lazy require(), not installed) |
@nestjs/websockets | Optional NestJS dep (lazy require(), not installed) |
.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
5. Create Deployment Package
- Before esbuild: ~80 MB (all
node_modules) - After esbuild: ~8-10 MB (bundles + external packages only)
- Before esbuild: 10+ minutes
- After esbuild: Less than 2 minutes
6. Upload to S3
7. Update Lambda Functions
Both Lambda functions are updated with the same deployment package (different entry points):Lambda Configuration
API Lambda
Function Name:alliance-risk-api
Handler: dist/src/lambda.handler
Entry Point (packages/api/src/lambda.ts):
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)
Worker Lambda
Function Name:alliance-risk-worker
Handler: dist/src/worker.handler
Entry Point (packages/api/src/worker.ts):
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
| Variable | Source | Example |
|---|---|---|
ENVIRONMENT | Parameter | dev, staging, production |
COGNITO_USER_POOL_ID | !Ref UserPool | us-east-1_abc123 |
COGNITO_CLIENT_ID | !Ref UserPoolClient | 7abc123def456 |
S3_BUCKET_NAME | !Ref FileBucket | alliance-risk-files-123456789012 |
AWS_ACCOUNT_ID | !Ref AWS::AccountId | 123456789012 |
WORKER_FUNCTION_NAME | !Ref WorkerLambda | alliance-risk-worker |
CORS_ORIGIN | Parameter | * or https://app.example.com |
DATABASE_URL | Constructed (see below) | postgresql://postgres:***@host:5432/alliance_risk |
Worker Lambda
| Variable | Source | Example |
|---|---|---|
ENVIRONMENT | Parameter | dev, staging, production |
AWS_ACCOUNT_ID | !Ref AWS::AccountId | 123456789012 |
S3_BUCKET_NAME | !Ref FileBucket | alliance-risk-files-123456789012 |
DATABASE_URL | Constructed (see below) | postgresql://postgres:***@host:5432/alliance_risk |
DATABASE_URL Construction
TheDATABASE_URL is constructed dynamically in the CloudFormation template using Secrets Manager and RDS endpoint:
localhost:5432 and all database operations fail.
Critical TypeScript Configuration
The API package must haveesModuleInterop: true in packages/api/tsconfig.json:
esModuleInterop, imports like import express from 'express' compile to express_1.default() without the __importDefault helper, causing runtime crashes in Lambda:
Deployment Order
When deploying from scratch:Troubleshooting
Lambda returns HTTP 500 (often masked as CORS error on frontend)
Check CloudWatch Logs:| Error | Cause | Solution |
|---|---|---|
Cannot find module 'X' | Missing external package | Add to EXTERNALS array and copy logic in deploy-api.sh |
(0, x.default) is not a function | ESM/CJS interop issue | Ensure esModuleInterop: true in tsconfig.json |
| Prisma connection failure | DATABASE_URL missing | Check CloudFormation template and Lambda configuration |
| Empty Prisma error message | Bundling issue | Log .code and .meta instead of .message |
Lambda timeout during cold start
Cause: Prisma connection pool keeps event loop alive Solution: Ensurecontext.callbackWaitsForEmptyEventLoop = false in both lambda.ts and worker.ts
Deployment package too large (>50 MB)
Cause: External packages not optimized Solution: Check cleanup logic indeploy-api.sh (removes .d.ts, .map, README.md, test directories)
esbuild warnings about dynamic requires
Expected. Packages like Prisma use dynamicrequire() 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