> ## Documentation Index
> Fetch the complete documentation index at: https://alliancebioversityciat-alliance-risk-analysis-tool.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploying the NestJS API

> Deploy the NestJS backend to AWS Lambda using esbuild bundling

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:

```bash theme={null}
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

```bash theme={null}
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:

```bash theme={null}
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:

| 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) |

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

```bash theme={null}
cp packages/api/prisma/schema.prisma prisma/
```

The Prisma schema is required at runtime for Prisma Client.

### 5. Create Deployment Package

```bash theme={null}
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

```bash theme={null}
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):

```bash theme={null}
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`):**

```typescript theme={null}
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`):**

```typescript theme={null}
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](https://alliancebioversityciat-alliance-risk-analysis-tool.mintlify.site/deployment/database-migrations.md))

## 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

The `DATABASE_URL` is constructed dynamically in the CloudFormation template using Secrets Manager and RDS endpoint:

```yaml theme={null}
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`:

```json theme={null}
{
  "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:

```bash theme={null}
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:**

```bash theme={null}
aws logs tail /aws/lambda/alliance-risk-api --follow
aws logs tail /aws/lambda/alliance-risk-worker --follow
```

**Common Causes:**

| 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:** 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:

```bash theme={null}
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:

```bash theme={null}
S3_KEY="api/$(date +%Y%m%d-%H%M%S).zip"
aws s3 cp lambda.zip "s3://${DEPLOY_BUCKET}/${S3_KEY}"
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.