| id | ADR-001 | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| title | Standard pattern for bootstrapping a Lambda web application | |||||||||
| status | Proposed | |||||||||
| date | 2026-03-16 | |||||||||
| authors |
|
|||||||||
| owner | David Doolin | |||||||||
| reviewers | ||||||||||
| approvers | ||||||||||
| decision_type | Technical | |||||||||
| impact_level | High | |||||||||
| version | 0.1 | |||||||||
| change_log |
|
|||||||||
| related_documents |
|
This ADR defines the repeatable pattern for building a new AWS Lambda
web application where the application code lives in its own repository
and all infrastructure is managed by Terraform in the form-terra
repository. The pattern covers project structure, handler design,
deployment packaging, CI/CD, local development, and the boundary
between application and infrastructure concerns.
The intended consumer of this document is an autonomous coding agent given a PRD. The agent should be able to produce a working, deployable Lambda application by following these steps without human interaction.
Each new Lambda project requires the same scaffolding: handler, tests, CI/CD, deployment packaging, local dev server, and Terraform coordination. Without a documented pattern, each project reinvents these decisions, producing inconsistent results and requiring human guidance.
- Solo developer
- Near-zero AWS cost (free tier or minimal spend)
- Must run locally without Lambda emulation
- Infrastructure lives in
form-terra, not in the application repo - All projects deploy to
us-west-1 - CloudFront sits in front of all Lambda function URLs
| Project | Runtime | Framework | Data |
|---|---|---|---|
| CM02 | Node.js 20 | None (raw handler) | OSCAL JSON |
| slacronym | Node.js 20 (ESM) | None (raw handler) | JSON file |
| retirement | Ruby 3.3 | Sinatra + lamby | In-memory SQLite3 |
| this-day | Ruby 3.3 | Roda + lamby | Bundled SQLite3 |
The application repo contains only application code, tests, CI/CD,
and deployment packaging. It does not create or manage any AWS
resources. All infrastructure is defined in form-terra:
| Concern | Where |
|---|---|
| Lambda function, API Gateway, IAM role | form-terra |
| S3 buckets, IAM policies for data access | form-terra |
| CloudFront distribution, custom domain, ACM cert | form-terra |
| GitHub Actions OIDC deploy role | form-terra |
| Application code, handler, tests, CI/CD workflow | Application repo |
| Optional: Terraform for custom domain mapping | Application repo (terraform/) |
The application repo may include a terraform/ directory for
resources tightly coupled to the application (e.g., custom domain
mapping, API Gateway route overrides) but this is optional.
Every project follows this layout. Adapt filenames for the runtime.
project-name/
├── .github/
│ └── workflows/
│ └── ci-cd.yml # Test on PR, deploy on push to master
├── data/ # Static data files (JSON, SQLite3, etc.)
├── lib/ # Application modules
├── public/ # Static assets (HTML form, CSS, JS)
│ └── index.html
├── test/ # Tests (Jest, RSpec, node:test)
├── terraform/ # Optional: app-specific Terraform
├── docs/
│ └── adrs/ # Architecture decision records
├── .gitignore
├── .prettierignore # If using Prettier
├── index.js | app.rb # Lambda handler entry point
├── serve.js | config.ru # Local development server
├── package.json | Gemfile # Dependencies
├── plan.md # Implementation plan
└── README.md
The Lambda handler is the entry point. It must:
- Route by HTTP method — serve HTML on GET, process input on POST, handle OPTIONS for CORS
- Return API Gateway v2 response format —
{ statusCode, headers, body } - Include CORS headers on all responses
- Parse the body — handle both
event.body(string from API Gateway) and direct event invocation (object) - Validate input before processing
- Return structured errors —
{ errors: [...] }for validation,{ error: "message" }for other failures - Log structured JSON to stdout for CloudWatch
const fs = require("fs");
const path = require("path");
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
exports.handler = async (event) => {
const method = event.requestContext?.http?.method || event.httpMethod;
if (method === "OPTIONS") {
return { statusCode: 204, headers: CORS_HEADERS, body: "" };
}
if (method === "GET") {
const html = fs.readFileSync(
path.join(__dirname, "public", "index.html"), "utf8"
);
return {
statusCode: 200,
headers: { ...CORS_HEADERS, "Content-Type": "text/html" },
body: html,
};
}
if (method && method !== "POST") {
return {
statusCode: 405,
headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ error: `Method ${method} not allowed` }),
};
}
// Parse, validate, process, return result
};require "lamby"
require_relative "lib/my_app"
def handler(event:, context:)
Lamby.handler(MyApp, event, context)
endEvery project must include a local dev server that runs the same application logic without requiring AWS credentials or Lambda emulation.
- Node.js:
serve.jsusinghttp.createServer, wrapping the same validation and processing logic. Write output files tooutput/instead of uploading to S3. - Ruby:
config.ruwithrackup. The Rack app runs identically to Lambda minus the lamby handler path.
Add "start": "node serve.js" (or equivalent) to package.json.
Default to a port other than 3000 (commonly in use).
Validation is a separate module (lib/validate.js or equivalent):
- Trim inputs before checking length
- Return an array of error strings, not a single error
- Define max lengths as constants
- Validate at the handler level, not inside business logic
- Test framework: Jest (Node.js), RSpec (Ruby), or node:test
- Mock external services (S3, DynamoDB) in unit tests
- Test the handler with realistic API Gateway event shapes
- Test validation including boundary conditions (exact max length, max + 1, whitespace-padded inputs)
- E2E test: invoke the handler with a full realistic payload, verify response shape and output validity
GitHub Actions workflow (.github/workflows/ci-cd.yml):
name: CI/CD
on:
push:
branches: [master]
pull_request:
branches: [master]
permissions:
contents: read
id-token: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- # Setup runtime (Node/Ruby)
- # Install dependencies
- # Lint/format check
- # Run tests
deploy:
needs: test
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- # Setup runtime
- # Install production dependencies only
- # Build deployment zip (exclude tests, dev deps, large data files)
- # Configure AWS credentials via OIDC
- # Upload zip to Lambda
- # Wait for update
- # Smoke test (invoke and verify response with jq)Critical details:
- Branch name must match the actual default branch (
masterormain) - OIDC trust condition in form-terra must match the branch name
- Smoke test must parse the Lambda response body (use
jq), not grep raw invocation metadata - Required GitHub Secrets:
AWS_DEPLOY_ROLE_ARN,LAMBDA_FUNCTION_NAME
The deployment zip must include only what Lambda needs:
zip -r deployment.zip \
index.js \
lib/ \
data/app-specific-data.json \
public/ \
node_modules/ \
-x "*.test.js" "data/large-reference-files.*"Exclude: tests, dev dependencies, large reference data, .git,
node_modules/.cache, documentation.
When creating a new Lambda project, the following must be added to
form-terra:
- Lambda function — runtime, handler, memory, timeout
- API Gateway HTTP API — routes (GET /, POST /api/endpoint)
- Lambda execution role — basic execution + any S3/DynamoDB access
- Lambda permission — allow API Gateway to invoke
- GitHub Actions OIDC deploy role — scoped to the repo and branch
- S3 bucket (if needed) — for artifacts, with lifecycle rules
- IAM policy (if needed) — for S3/DynamoDB access, least privilege
- Outputs — API endpoint URL, deploy role ARN, function name
The application repo README must document which secrets to set from the Terraform outputs.
Every project README follows this structure:
- Title and summary — what it does, one-line architecture diagram
- Form fields / API — what the user provides
- Example — realistic example with all fields populated
- CI/CD — branch, what runs, required secrets
- Infrastructure setup — pointer to form-terra, what it provides
- Local development —
npm install && npm start - Data storage (if applicable) — bucket, access pattern, lifecycle
An autonomous agent given a PRD should execute these steps in order:
- Initialize git repo, create
.gitignore(node_modules/, output/) - Create
package.jsonwith name, scripts (start, test, format), and dependencies - Create the data layer — extract or generate any static data files
into
data/ - Create the core business logic in
lib/— the module that does the actual work (PDF generation, calculation, lookup, etc.) - Create
lib/validate.js— input validation with trimmed length checks - Create
index.js— Lambda handler following the skeleton above - Create
public/index.html— web form that POSTs to/api/endpoint - Create
serve.js— local dev server that writes output to disk instead of S3 - Create tests for validation, business logic, handler, and e2e
- Run tests, fix any failures
- Create
.prettierignoreand run formatter - Write
README.mdfollowing the structure above - Write
plan.mddocumenting what was built
- Create
.github/workflows/ci-cd.ymlfollowing the template above - Ensure branch names in the workflow match the actual default branch
- Add Terraform resources to form-terra for the new Lambda
- Apply Terraform, collect outputs
- Set GitHub Secrets from Terraform outputs
- Push to trigger first deployment
- Verify smoke test passes
- Add any optional Terraform in the application repo (custom domain)
- Refine based on testing against the live endpoint
- New Lambda projects can be scaffolded in minutes by an agent
- Consistent structure across all projects makes maintenance easier
- Clear separation between application and infrastructure concerns
- Every project works locally without AWS credentials
- Pattern is opinionated — projects that don't fit the mold need explicit deviations documented
- Two-repo coordination (app + form-terra) requires understanding the boundary
| Risk | Mitigation |
|---|---|
| Agent scaffolds CI for wrong branch name | Verify with git branch before writing workflow |
| Agent creates Terraform resources that reference undefined policies | All cross-repo references must use data sources or be clearly commented |
| Agent over-engineers the solution | Follow the PRD scope; do not add features not requested |
- Domain Tags: serverless, deployment, bootstrap
- Technology Tags: lambda, node.js, ruby, terraform, github-actions, api-gateway, cloudfront
- Search Keywords: lambda bootstrap, project template, agent automation, form-terra, OIDC deploy