[{"data":1,"prerenderedAt":1674},["Reactive",2],{"content-query-0M0JaJsjtY":3},{"_path":4,"_dir":5,"_draft":6,"_partial":6,"_locale":7,"title":8,"description":9,"published":10,"slug":11,"body":12,"_type":1668,"_id":1669,"_source":1670,"_file":1671,"_stem":1672,"_extension":1673},"/articles/serverless-on-aws-without-a-serverless-framework","articles",false,"","Serverless on AWS without a serverless framework","Terraform owns every resource, the frontend gets its config from Terraform outputs, Lambda functions are pnpm workspace packages, and one zod contract drives the API gateway, the client and the handlers. The template I start every serverless project from, and the parts worth stealing.","2026/7/27","serverless-on-aws-without-a-serverless-framework",{"type":13,"children":14,"toc":1657},"root",[15,23,28,35,49,58,63,151,157,170,183,249,359,420,433,439,460,473,602,623,659,665,678,904,909,917,930,1079,1092,1408,1437,1458,1464,1488,1518,1526,1537,1558,1563,1576,1582,1609,1615,1629,1635,1640,1646,1651],{"type":16,"tag":17,"props":18,"children":19},"element","p",{},[20],{"type":21,"value":22},"text","Every serverless framework I have used wants to own the infrastructure. SST, the Serverless Framework, SAM, Amplify Gen 2. They are all good at the first week and all painful in month six, when you need a resource the framework did not anticipate and you end up with two sources of truth for one AWS account.",{"type":16,"tag":17,"props":24,"children":25},{},[26],{"type":21,"value":27},"So the template I start projects from at Storm Reply UK does it the other way round. Terraform owns every resource. The frontend is told where things are by Terraform outputs. Lambda functions are ordinary packages in a pnpm monorepo and Terraform builds them. There is no framework in the middle, and after a year of using it on real projects I would not go back. This post is the four ideas that make it work and the rough edges I have not sanded off.",{"type":16,"tag":29,"props":30,"children":32},"h2",{"id":31},"what-it-deploys",[33],{"type":21,"value":34},"What it deploys",{"type":16,"tag":17,"props":36,"children":37},{},[38,40,47],{"type":21,"value":39},"Two React SPAs on CloudFront, Cognito for auth, an API Gateway REST API with a Lambda authorizer, one Lambda per API path, two DynamoDB tables, a Bedrock knowledge base for retrieval, and the usual monitoring, budget and DNS. The second SPA is a CloudWatch dashboard gated to a ",{"type":16,"tag":41,"props":42,"children":44},"code",{"className":43},[],[45],{"type":21,"value":46},"Monitoring",{"type":21,"value":48}," Cognito group, which reads metrics directly using credentials from the identity pool.",{"type":16,"tag":17,"props":50,"children":51},{},[52],{"type":16,"tag":53,"props":54,"children":57},"img",{"alt":55,"src":56},"Architecture: CloudFront and S3 serve two React SPAs, Cognito issues tokens, API Gateway checks them with a Lambda authorizer and routes each path to its own Lambda, which talks to DynamoDB or Bedrock. Terraform owns every box and its outputs feed the frontend config","/articles/serverless-toolkit-architecture.svg",[],{"type":16,"tag":17,"props":59,"children":60},{},[61],{"type":21,"value":62},"The monorepo layout is what you would expect, with one addition that matters and gets its own section below:",{"type":16,"tag":64,"props":65,"children":69},"pre",{"className":66,"code":67,"language":68,"meta":7,"style":7},"language-yaml shiki shiki-themes github-dark","# pnpm-workspace.yaml\npackages:\n  - \"apps/*\"\n  - \"packages/*\"\n  # API lambda functions are workspace packages too, so they can import shared packages\n  - \"apps/web/functions/*\"\n","yaml",[70],{"type":16,"tag":41,"props":71,"children":72},{"__ignoreMap":7},[73,85,101,116,129,138],{"type":16,"tag":74,"props":75,"children":78},"span",{"class":76,"line":77},"line",1,[79],{"type":16,"tag":74,"props":80,"children":82},{"style":81},"--shiki-default:#6A737D",[83],{"type":21,"value":84},"# pnpm-workspace.yaml\n",{"type":16,"tag":74,"props":86,"children":88},{"class":76,"line":87},2,[89,95],{"type":16,"tag":74,"props":90,"children":92},{"style":91},"--shiki-default:#85E89D",[93],{"type":21,"value":94},"packages",{"type":16,"tag":74,"props":96,"children":98},{"style":97},"--shiki-default:#E1E4E8",[99],{"type":21,"value":100},":\n",{"type":16,"tag":74,"props":102,"children":104},{"class":76,"line":103},3,[105,110],{"type":16,"tag":74,"props":106,"children":107},{"style":97},[108],{"type":21,"value":109},"  - ",{"type":16,"tag":74,"props":111,"children":113},{"style":112},"--shiki-default:#9ECBFF",[114],{"type":21,"value":115},"\"apps/*\"\n",{"type":16,"tag":74,"props":117,"children":119},{"class":76,"line":118},4,[120,124],{"type":16,"tag":74,"props":121,"children":122},{"style":97},[123],{"type":21,"value":109},{"type":16,"tag":74,"props":125,"children":126},{"style":112},[127],{"type":21,"value":128},"\"packages/*\"\n",{"type":16,"tag":74,"props":130,"children":132},{"class":76,"line":131},5,[133],{"type":16,"tag":74,"props":134,"children":135},{"style":81},[136],{"type":21,"value":137},"  # API lambda functions are workspace packages too, so they can import shared packages\n",{"type":16,"tag":74,"props":139,"children":141},{"class":76,"line":140},6,[142,146],{"type":16,"tag":74,"props":143,"children":144},{"style":97},[145],{"type":21,"value":109},{"type":16,"tag":74,"props":147,"children":148},{"style":112},[149],{"type":21,"value":150},"\"apps/web/functions/*\"\n",{"type":16,"tag":29,"props":152,"children":154},{"id":153},"idea-1-the-frontend-reads-terraform-outputs",[155],{"type":21,"value":156},"Idea 1: the frontend reads Terraform outputs",{"type":16,"tag":17,"props":158,"children":159},{},[160,162,168],{"type":21,"value":161},"Amplify is in this stack, but only as the client library. It configures Cognito and attaches the bearer token to REST calls. There is no Amplify backend, no Amplify hosting, no ",{"type":16,"tag":41,"props":163,"children":165},{"className":164},[],[166],{"type":21,"value":167},"amplify/",{"type":21,"value":169}," directory. The config it needs is generated from Terraform.",{"type":16,"tag":17,"props":171,"children":172},{},[173,175,181],{"type":21,"value":174},"The trick is a naming convention on outputs. Anything prefixed ",{"type":16,"tag":41,"props":176,"children":178},{"className":177},[],[179],{"type":21,"value":180},"Amplify_",{"type":21,"value":182}," is folded into a nested object by splitting on underscores:",{"type":16,"tag":64,"props":184,"children":188},{"className":185,"code":186,"language":187,"meta":7,"style":7},"language-hcl shiki shiki-themes github-dark","output \"Amplify_Auth_Cognito_userPoolId\" {\n  value = module.auth.user_pool_id\n}\n\noutput \"Amplify_API_REST_main-api_endpoint\" {\n  value = module.api.invoke_url\n}\n","hcl",[189],{"type":16,"tag":41,"props":190,"children":191},{"__ignoreMap":7},[192,200,208,216,225,233,241],{"type":16,"tag":74,"props":193,"children":194},{"class":76,"line":77},[195],{"type":16,"tag":74,"props":196,"children":197},{},[198],{"type":21,"value":199},"output \"Amplify_Auth_Cognito_userPoolId\" {\n",{"type":16,"tag":74,"props":201,"children":202},{"class":76,"line":87},[203],{"type":16,"tag":74,"props":204,"children":205},{},[206],{"type":21,"value":207},"  value = module.auth.user_pool_id\n",{"type":16,"tag":74,"props":209,"children":210},{"class":76,"line":103},[211],{"type":16,"tag":74,"props":212,"children":213},{},[214],{"type":21,"value":215},"}\n",{"type":16,"tag":74,"props":217,"children":218},{"class":76,"line":118},[219],{"type":16,"tag":74,"props":220,"children":222},{"emptyLinePlaceholder":221},true,[223],{"type":21,"value":224},"\n",{"type":16,"tag":74,"props":226,"children":227},{"class":76,"line":131},[228],{"type":16,"tag":74,"props":229,"children":230},{},[231],{"type":21,"value":232},"output \"Amplify_API_REST_main-api_endpoint\" {\n",{"type":16,"tag":74,"props":234,"children":235},{"class":76,"line":140},[236],{"type":16,"tag":74,"props":237,"children":238},{},[239],{"type":21,"value":240},"  value = module.api.invoke_url\n",{"type":16,"tag":74,"props":242,"children":244},{"class":76,"line":243},7,[245],{"type":16,"tag":74,"props":246,"children":247},{},[248],{"type":21,"value":215},{"type":16,"tag":64,"props":250,"children":254},{"className":251,"code":252,"language":253,"meta":7,"style":7},"language-python shiki shiki-themes github-dark","def parse_terraform_output(output_json):\n    \"\"\"Fold 'terraform output -json' into the nested shape Amplify.configure wants.\"\"\"\n    amplify_config = {}\n    for key, payload in output_json.items():\n        if not key.startswith(\"Amplify\"):\n            continue\n        parts = key.split(\"_\")[1:]          # drop the Amplify prefix\n        level = amplify_config\n        for part in parts[:-1]:\n            level = level.setdefault(part, {})\n        level[parts[-1]] = payload[\"value\"]\n    return amplify_config\n","python",[255],{"type":16,"tag":41,"props":256,"children":257},{"__ignoreMap":7},[258,266,274,282,290,298,306,314,323,332,341,350],{"type":16,"tag":74,"props":259,"children":260},{"class":76,"line":77},[261],{"type":16,"tag":74,"props":262,"children":263},{},[264],{"type":21,"value":265},"def parse_terraform_output(output_json):\n",{"type":16,"tag":74,"props":267,"children":268},{"class":76,"line":87},[269],{"type":16,"tag":74,"props":270,"children":271},{},[272],{"type":21,"value":273},"    \"\"\"Fold 'terraform output -json' into the nested shape Amplify.configure wants.\"\"\"\n",{"type":16,"tag":74,"props":275,"children":276},{"class":76,"line":103},[277],{"type":16,"tag":74,"props":278,"children":279},{},[280],{"type":21,"value":281},"    amplify_config = {}\n",{"type":16,"tag":74,"props":283,"children":284},{"class":76,"line":118},[285],{"type":16,"tag":74,"props":286,"children":287},{},[288],{"type":21,"value":289},"    for key, payload in output_json.items():\n",{"type":16,"tag":74,"props":291,"children":292},{"class":76,"line":131},[293],{"type":16,"tag":74,"props":294,"children":295},{},[296],{"type":21,"value":297},"        if not key.startswith(\"Amplify\"):\n",{"type":16,"tag":74,"props":299,"children":300},{"class":76,"line":140},[301],{"type":16,"tag":74,"props":302,"children":303},{},[304],{"type":21,"value":305},"            continue\n",{"type":16,"tag":74,"props":307,"children":308},{"class":76,"line":243},[309],{"type":16,"tag":74,"props":310,"children":311},{},[312],{"type":21,"value":313},"        parts = key.split(\"_\")[1:]          # drop the Amplify prefix\n",{"type":16,"tag":74,"props":315,"children":317},{"class":76,"line":316},8,[318],{"type":16,"tag":74,"props":319,"children":320},{},[321],{"type":21,"value":322},"        level = amplify_config\n",{"type":16,"tag":74,"props":324,"children":326},{"class":76,"line":325},9,[327],{"type":16,"tag":74,"props":328,"children":329},{},[330],{"type":21,"value":331},"        for part in parts[:-1]:\n",{"type":16,"tag":74,"props":333,"children":335},{"class":76,"line":334},10,[336],{"type":16,"tag":74,"props":337,"children":338},{},[339],{"type":21,"value":340},"            level = level.setdefault(part, {})\n",{"type":16,"tag":74,"props":342,"children":344},{"class":76,"line":343},11,[345],{"type":16,"tag":74,"props":346,"children":347},{},[348],{"type":21,"value":349},"        level[parts[-1]] = payload[\"value\"]\n",{"type":16,"tag":74,"props":351,"children":353},{"class":76,"line":352},12,[354],{"type":16,"tag":74,"props":355,"children":356},{},[357],{"type":21,"value":358},"    return amplify_config\n",{"type":16,"tag":17,"props":360,"children":361},{},[362,364,370,372,378,380,386,388,394,396,402,404,410,412,418],{"type":21,"value":363},"That writes ",{"type":16,"tag":41,"props":365,"children":367},{"className":366},[],[368],{"type":21,"value":369},"packages/auth/src/amplify-config.json",{"type":21,"value":371},", which the auth provider imports and hands to ",{"type":16,"tag":41,"props":373,"children":375},{"className":374},[],[376],{"type":21,"value":377},"Amplify.configure()",{"type":21,"value":379},". Running ",{"type":16,"tag":41,"props":381,"children":383},{"className":382},[],[384],{"type":21,"value":385},"pnpm checkout --env dev",{"type":21,"value":387}," re-inits Terraform against the dev state bucket, runs ",{"type":16,"tag":41,"props":389,"children":391},{"className":390},[],[392],{"type":21,"value":393},"terraform output -json",{"type":21,"value":395},", regenerates the file and sets the environment name in ",{"type":16,"tag":41,"props":397,"children":399},{"className":398},[],[400],{"type":21,"value":401},".env.local",{"type":21,"value":403},". Point the same command at ",{"type":16,"tag":41,"props":405,"children":407},{"className":406},[],[408],{"type":21,"value":409},"test",{"type":21,"value":411},", ",{"type":16,"tag":41,"props":413,"children":415},{"className":414},[],[416],{"type":21,"value":417},"prod",{"type":21,"value":419}," or your sandbox and the frontend follows.",{"type":16,"tag":17,"props":421,"children":422},{},[423,425,431],{"type":21,"value":424},"The state bucket per environment lives in a plain ",{"type":16,"tag":41,"props":426,"children":428},{"className":427},[],[429],{"type":21,"value":430},"config.ini",{"type":21,"value":432}," at the repo root. Two keys per section, region and bucket name, no secrets. Every script reads it. It is the least clever file in the repo and the one I would keep in any rewrite.",{"type":16,"tag":29,"props":434,"children":436},{"id":435},"idea-2-lambdas-are-workspace-packages-and-terraform-builds-them",[437],{"type":21,"value":438},"Idea 2: Lambdas are workspace packages and Terraform builds them",{"type":16,"tag":17,"props":440,"children":441},{},[442,444,450,452,458],{"type":21,"value":443},"Each function under ",{"type":16,"tag":41,"props":445,"children":447},{"className":446},[],[448],{"type":21,"value":449},"apps/web/functions/",{"type":21,"value":451}," has its own ",{"type":16,"tag":41,"props":453,"children":455},{"className":454},[],[456],{"type":21,"value":457},"package.json",{"type":21,"value":459}," and is a pnpm workspace member. That gives it real dependency management and, more importantly, lets it import the shared packages: the API contracts, the tsconfig, an esbuild wrapper.",{"type":16,"tag":17,"props":461,"children":462},{},[463,465,471],{"type":21,"value":464},"Terraform packages it using the community Lambda module's ",{"type":16,"tag":41,"props":466,"children":468},{"className":467},[],[469],{"type":21,"value":470},"source_path.commands",{"type":21,"value":472},". The commands install the workspace, build with esbuild and zip the output. The odd-looking part is the lock directory:",{"type":16,"tag":64,"props":474,"children":476},{"className":185,"code":475,"language":187,"meta":7,"style":7},"source_path = [{\n  path = \"${path.root}/../apps/web/functions/${var.slug}\"\n  commands = [\n    \"set -e\",\n    \"LOCK=../../../../.pnpm-install.lock\",\n    \"until mkdir \\\"$LOCK\\\" 2>/dev/null; do sleep 1; done; trap 'rmdir \\\"$LOCK\\\"' EXIT\",\n    \"pnpm install --frozen-lockfile\",\n    \"rmdir \\\"$LOCK\\\"; trap - EXIT\",\n    \"pnpm run build\",\n    \":zip dist\",\n  ]\n  patterns = [\"!node_modules/.*\", \"!dist/.*\", \"!.*\\\\.js$\"]\n}]\n\nhash_extra = local.shared_packages_hash\n",[477],{"type":16,"tag":41,"props":478,"children":479},{"__ignoreMap":7},[480,488,496,504,512,520,528,536,544,552,560,568,576,585,593],{"type":16,"tag":74,"props":481,"children":482},{"class":76,"line":77},[483],{"type":16,"tag":74,"props":484,"children":485},{},[486],{"type":21,"value":487},"source_path = [{\n",{"type":16,"tag":74,"props":489,"children":490},{"class":76,"line":87},[491],{"type":16,"tag":74,"props":492,"children":493},{},[494],{"type":21,"value":495},"  path = \"${path.root}/../apps/web/functions/${var.slug}\"\n",{"type":16,"tag":74,"props":497,"children":498},{"class":76,"line":103},[499],{"type":16,"tag":74,"props":500,"children":501},{},[502],{"type":21,"value":503},"  commands = [\n",{"type":16,"tag":74,"props":505,"children":506},{"class":76,"line":118},[507],{"type":16,"tag":74,"props":508,"children":509},{},[510],{"type":21,"value":511},"    \"set -e\",\n",{"type":16,"tag":74,"props":513,"children":514},{"class":76,"line":131},[515],{"type":16,"tag":74,"props":516,"children":517},{},[518],{"type":21,"value":519},"    \"LOCK=../../../../.pnpm-install.lock\",\n",{"type":16,"tag":74,"props":521,"children":522},{"class":76,"line":140},[523],{"type":16,"tag":74,"props":524,"children":525},{},[526],{"type":21,"value":527},"    \"until mkdir \\\"$LOCK\\\" 2>/dev/null; do sleep 1; done; trap 'rmdir \\\"$LOCK\\\"' EXIT\",\n",{"type":16,"tag":74,"props":529,"children":530},{"class":76,"line":243},[531],{"type":16,"tag":74,"props":532,"children":533},{},[534],{"type":21,"value":535},"    \"pnpm install --frozen-lockfile\",\n",{"type":16,"tag":74,"props":537,"children":538},{"class":76,"line":316},[539],{"type":16,"tag":74,"props":540,"children":541},{},[542],{"type":21,"value":543},"    \"rmdir \\\"$LOCK\\\"; trap - EXIT\",\n",{"type":16,"tag":74,"props":545,"children":546},{"class":76,"line":325},[547],{"type":16,"tag":74,"props":548,"children":549},{},[550],{"type":21,"value":551},"    \"pnpm run build\",\n",{"type":16,"tag":74,"props":553,"children":554},{"class":76,"line":334},[555],{"type":16,"tag":74,"props":556,"children":557},{},[558],{"type":21,"value":559},"    \":zip dist\",\n",{"type":16,"tag":74,"props":561,"children":562},{"class":76,"line":343},[563],{"type":16,"tag":74,"props":564,"children":565},{},[566],{"type":21,"value":567},"  ]\n",{"type":16,"tag":74,"props":569,"children":570},{"class":76,"line":352},[571],{"type":16,"tag":74,"props":572,"children":573},{},[574],{"type":21,"value":575},"  patterns = [\"!node_modules/.*\", \"!dist/.*\", \"!.*\\\\.js$\"]\n",{"type":16,"tag":74,"props":577,"children":579},{"class":76,"line":578},13,[580],{"type":16,"tag":74,"props":581,"children":582},{},[583],{"type":21,"value":584},"}]\n",{"type":16,"tag":74,"props":586,"children":588},{"class":76,"line":587},14,[589],{"type":16,"tag":74,"props":590,"children":591},{"emptyLinePlaceholder":221},[592],{"type":21,"value":224},{"type":16,"tag":74,"props":594,"children":596},{"class":76,"line":595},15,[597],{"type":16,"tag":74,"props":598,"children":599},{},[600],{"type":21,"value":601},"hash_extra = local.shared_packages_hash\n",{"type":16,"tag":17,"props":603,"children":604},{},[605,607,613,615,621],{"type":21,"value":606},"Terraform builds every function's package in parallel, and parallel ",{"type":16,"tag":41,"props":608,"children":610},{"className":609},[],[611],{"type":21,"value":612},"pnpm install",{"type":21,"value":614}," calls in one workspace corrupt each other, so the first one to ",{"type":16,"tag":41,"props":616,"children":618},{"className":617},[],[619],{"type":21,"value":620},"mkdir",{"type":21,"value":622}," the lock wins and the rest wait. It is a one-line mutex and it has never failed.",{"type":16,"tag":17,"props":624,"children":625},{},[626,628,634,636,642,644,649,651,657],{"type":21,"value":627},"The ",{"type":16,"tag":41,"props":629,"children":631},{"className":630},[],[632],{"type":21,"value":633},"hash_extra",{"type":21,"value":635}," line is the part that took me longest to get right. The module decides whether to rebuild a function by hashing its source, and the source patterns exclude ",{"type":16,"tag":41,"props":637,"children":639},{"className":638},[],[640],{"type":21,"value":641},"node_modules",{"type":21,"value":643},". So a change to a shared package would never redeploy the functions that import it. ",{"type":16,"tag":41,"props":645,"children":647},{"className":646},[],[648],{"type":21,"value":633},{"type":21,"value":650}," is a hash of every file under ",{"type":16,"tag":41,"props":652,"children":654},{"className":653},[],[655],{"type":21,"value":656},"packages/*/src",{"type":21,"value":658}," plus the lockfile. Change the contracts package and every function redeploys. Change one handler and only that function does.",{"type":16,"tag":29,"props":660,"children":662},{"id":661},"idea-3-one-contract-three-consumers",[663],{"type":21,"value":664},"Idea 3: one contract, three consumers",{"type":16,"tag":17,"props":666,"children":667},{},[668,670,676],{"type":21,"value":669},"The API is defined once, as zod schemas in ",{"type":16,"tag":41,"props":671,"children":673},{"className":672},[],[674],{"type":21,"value":675},"packages/api-contracts",{"type":21,"value":677},":",{"type":16,"tag":64,"props":679,"children":683},{"className":680,"code":681,"language":682,"meta":7,"style":7},"language-typescript shiki shiki-themes github-dark","export const todos = group(\"/todos\", {\n  list:   get(\"/\",     { response: z.array(Todo) }),\n  create: post(\"/\",    { body: TodoInput, response: Todo }),\n  update: patch(\"/:id\", { params: z.object({ id: z.string() }), body: TodoInput.partial(), response: Todo }),\n  remove: del(\"/:id\",  { params: z.object({ id: z.string() }) }),\n});\n","typescript",[684],{"type":16,"tag":41,"props":685,"children":686},{"__ignoreMap":7},[687,733,770,796,853,896],{"type":16,"tag":74,"props":688,"children":689},{"class":76,"line":77},[690,696,701,707,712,718,723,728],{"type":16,"tag":74,"props":691,"children":693},{"style":692},"--shiki-default:#F97583",[694],{"type":21,"value":695},"export",{"type":16,"tag":74,"props":697,"children":698},{"style":692},[699],{"type":21,"value":700}," const",{"type":16,"tag":74,"props":702,"children":704},{"style":703},"--shiki-default:#79B8FF",[705],{"type":21,"value":706}," todos",{"type":16,"tag":74,"props":708,"children":709},{"style":692},[710],{"type":21,"value":711}," =",{"type":16,"tag":74,"props":713,"children":715},{"style":714},"--shiki-default:#B392F0",[716],{"type":21,"value":717}," group",{"type":16,"tag":74,"props":719,"children":720},{"style":97},[721],{"type":21,"value":722},"(",{"type":16,"tag":74,"props":724,"children":725},{"style":112},[726],{"type":21,"value":727},"\"/todos\"",{"type":16,"tag":74,"props":729,"children":730},{"style":97},[731],{"type":21,"value":732},", {\n",{"type":16,"tag":74,"props":734,"children":735},{"class":76,"line":87},[736,741,746,750,755,760,765],{"type":16,"tag":74,"props":737,"children":738},{"style":97},[739],{"type":21,"value":740},"  list:   ",{"type":16,"tag":74,"props":742,"children":743},{"style":714},[744],{"type":21,"value":745},"get",{"type":16,"tag":74,"props":747,"children":748},{"style":97},[749],{"type":21,"value":722},{"type":16,"tag":74,"props":751,"children":752},{"style":112},[753],{"type":21,"value":754},"\"/\"",{"type":16,"tag":74,"props":756,"children":757},{"style":97},[758],{"type":21,"value":759},",     { response: z.",{"type":16,"tag":74,"props":761,"children":762},{"style":714},[763],{"type":21,"value":764},"array",{"type":16,"tag":74,"props":766,"children":767},{"style":97},[768],{"type":21,"value":769},"(Todo) }),\n",{"type":16,"tag":74,"props":771,"children":772},{"class":76,"line":103},[773,778,783,787,791],{"type":16,"tag":74,"props":774,"children":775},{"style":97},[776],{"type":21,"value":777},"  create: ",{"type":16,"tag":74,"props":779,"children":780},{"style":714},[781],{"type":21,"value":782},"post",{"type":16,"tag":74,"props":784,"children":785},{"style":97},[786],{"type":21,"value":722},{"type":16,"tag":74,"props":788,"children":789},{"style":112},[790],{"type":21,"value":754},{"type":16,"tag":74,"props":792,"children":793},{"style":97},[794],{"type":21,"value":795},",    { body: TodoInput, response: Todo }),\n",{"type":16,"tag":74,"props":797,"children":798},{"class":76,"line":118},[799,804,809,813,818,823,828,833,838,843,848],{"type":16,"tag":74,"props":800,"children":801},{"style":97},[802],{"type":21,"value":803},"  update: ",{"type":16,"tag":74,"props":805,"children":806},{"style":714},[807],{"type":21,"value":808},"patch",{"type":16,"tag":74,"props":810,"children":811},{"style":97},[812],{"type":21,"value":722},{"type":16,"tag":74,"props":814,"children":815},{"style":112},[816],{"type":21,"value":817},"\"/:id\"",{"type":16,"tag":74,"props":819,"children":820},{"style":97},[821],{"type":21,"value":822},", { params: z.",{"type":16,"tag":74,"props":824,"children":825},{"style":714},[826],{"type":21,"value":827},"object",{"type":16,"tag":74,"props":829,"children":830},{"style":97},[831],{"type":21,"value":832},"({ id: z.",{"type":16,"tag":74,"props":834,"children":835},{"style":714},[836],{"type":21,"value":837},"string",{"type":16,"tag":74,"props":839,"children":840},{"style":97},[841],{"type":21,"value":842},"() }), body: TodoInput.",{"type":16,"tag":74,"props":844,"children":845},{"style":714},[846],{"type":21,"value":847},"partial",{"type":16,"tag":74,"props":849,"children":850},{"style":97},[851],{"type":21,"value":852},"(), response: Todo }),\n",{"type":16,"tag":74,"props":854,"children":855},{"class":76,"line":131},[856,861,866,870,874,879,883,887,891],{"type":16,"tag":74,"props":857,"children":858},{"style":97},[859],{"type":21,"value":860},"  remove: ",{"type":16,"tag":74,"props":862,"children":863},{"style":714},[864],{"type":21,"value":865},"del",{"type":16,"tag":74,"props":867,"children":868},{"style":97},[869],{"type":21,"value":722},{"type":16,"tag":74,"props":871,"children":872},{"style":112},[873],{"type":21,"value":817},{"type":16,"tag":74,"props":875,"children":876},{"style":97},[877],{"type":21,"value":878},",  { params: z.",{"type":16,"tag":74,"props":880,"children":881},{"style":714},[882],{"type":21,"value":827},{"type":16,"tag":74,"props":884,"children":885},{"style":97},[886],{"type":21,"value":832},{"type":16,"tag":74,"props":888,"children":889},{"style":714},[890],{"type":21,"value":837},{"type":16,"tag":74,"props":892,"children":893},{"style":97},[894],{"type":21,"value":895},"() }) }),\n",{"type":16,"tag":74,"props":897,"children":898},{"class":76,"line":140},[899],{"type":16,"tag":74,"props":900,"children":901},{"style":97},[902],{"type":21,"value":903},"});\n",{"type":16,"tag":17,"props":905,"children":906},{},[907],{"type":21,"value":908},"Three things are generated or derived from that one file, and none of them can drift from the others.",{"type":16,"tag":17,"props":910,"children":911},{},[912],{"type":16,"tag":53,"props":913,"children":916},{"alt":914,"src":915},"One zod route contract fans out to three consumers: an API manifest JSON that Terraform reads to create gateway resources and methods, a typed client used by React Query hooks in the SPA, and a Lambda router that validates the request against the same schema","/articles/serverless-toolkit-contract.svg",[],{"type":16,"tag":17,"props":918,"children":919},{},[920,922,928],{"type":21,"value":921},"The first consumer is Terraform. A pre-commit hook writes ",{"type":16,"tag":41,"props":923,"children":925},{"className":924},[],[926],{"type":21,"value":927},"terraform/api-manifest.json",{"type":21,"value":929}," from the contracts, and each endpoint module reads its routes from it. The authorisation rules stay in HCL, because they are infrastructure:",{"type":16,"tag":64,"props":931,"children":933},{"className":185,"code":932,"language":187,"meta":7,"style":7},"module \"todos\" {\n  source = \"../modules/endpoint\"\n\n  slug   = \"todos\"\n  path   = \"/todos\"\n  routes = local.manifest.endpoints[\"/todos\"].routes\n\n  methods = {\n    GET    = { allow_unauthenticated = true }\n    POST   = { allowed_groups = [\"User\", \"Admin\"] }\n    PATCH  = { allowed_groups = [\"User\", \"Admin\"] }\n    DELETE = { allowed_groups = [\"Admin\"] }\n  }\n\n  dynamo_tables = {\n    DDB_TABLE_NAME = { arn = var.dynamodb_table.arn, name = var.dynamodb_table.name }\n  }\n}\n",[934],{"type":16,"tag":41,"props":935,"children":936},{"__ignoreMap":7},[937,945,953,960,968,976,984,991,999,1007,1015,1023,1031,1039,1046,1054,1063,1071],{"type":16,"tag":74,"props":938,"children":939},{"class":76,"line":77},[940],{"type":16,"tag":74,"props":941,"children":942},{},[943],{"type":21,"value":944},"module \"todos\" {\n",{"type":16,"tag":74,"props":946,"children":947},{"class":76,"line":87},[948],{"type":16,"tag":74,"props":949,"children":950},{},[951],{"type":21,"value":952},"  source = \"../modules/endpoint\"\n",{"type":16,"tag":74,"props":954,"children":955},{"class":76,"line":103},[956],{"type":16,"tag":74,"props":957,"children":958},{"emptyLinePlaceholder":221},[959],{"type":21,"value":224},{"type":16,"tag":74,"props":961,"children":962},{"class":76,"line":118},[963],{"type":16,"tag":74,"props":964,"children":965},{},[966],{"type":21,"value":967},"  slug   = \"todos\"\n",{"type":16,"tag":74,"props":969,"children":970},{"class":76,"line":131},[971],{"type":16,"tag":74,"props":972,"children":973},{},[974],{"type":21,"value":975},"  path   = \"/todos\"\n",{"type":16,"tag":74,"props":977,"children":978},{"class":76,"line":140},[979],{"type":16,"tag":74,"props":980,"children":981},{},[982],{"type":21,"value":983},"  routes = local.manifest.endpoints[\"/todos\"].routes\n",{"type":16,"tag":74,"props":985,"children":986},{"class":76,"line":243},[987],{"type":16,"tag":74,"props":988,"children":989},{"emptyLinePlaceholder":221},[990],{"type":21,"value":224},{"type":16,"tag":74,"props":992,"children":993},{"class":76,"line":316},[994],{"type":16,"tag":74,"props":995,"children":996},{},[997],{"type":21,"value":998},"  methods = {\n",{"type":16,"tag":74,"props":1000,"children":1001},{"class":76,"line":325},[1002],{"type":16,"tag":74,"props":1003,"children":1004},{},[1005],{"type":21,"value":1006},"    GET    = { allow_unauthenticated = true }\n",{"type":16,"tag":74,"props":1008,"children":1009},{"class":76,"line":334},[1010],{"type":16,"tag":74,"props":1011,"children":1012},{},[1013],{"type":21,"value":1014},"    POST   = { allowed_groups = [\"User\", \"Admin\"] }\n",{"type":16,"tag":74,"props":1016,"children":1017},{"class":76,"line":343},[1018],{"type":16,"tag":74,"props":1019,"children":1020},{},[1021],{"type":21,"value":1022},"    PATCH  = { allowed_groups = [\"User\", \"Admin\"] }\n",{"type":16,"tag":74,"props":1024,"children":1025},{"class":76,"line":352},[1026],{"type":16,"tag":74,"props":1027,"children":1028},{},[1029],{"type":21,"value":1030},"    DELETE = { allowed_groups = [\"Admin\"] }\n",{"type":16,"tag":74,"props":1032,"children":1033},{"class":76,"line":578},[1034],{"type":16,"tag":74,"props":1035,"children":1036},{},[1037],{"type":21,"value":1038},"  }\n",{"type":16,"tag":74,"props":1040,"children":1041},{"class":76,"line":587},[1042],{"type":16,"tag":74,"props":1043,"children":1044},{"emptyLinePlaceholder":221},[1045],{"type":21,"value":224},{"type":16,"tag":74,"props":1047,"children":1048},{"class":76,"line":595},[1049],{"type":16,"tag":74,"props":1050,"children":1051},{},[1052],{"type":21,"value":1053},"  dynamo_tables = {\n",{"type":16,"tag":74,"props":1055,"children":1057},{"class":76,"line":1056},16,[1058],{"type":16,"tag":74,"props":1059,"children":1060},{},[1061],{"type":21,"value":1062},"    DDB_TABLE_NAME = { arn = var.dynamodb_table.arn, name = var.dynamodb_table.name }\n",{"type":16,"tag":74,"props":1064,"children":1066},{"class":76,"line":1065},17,[1067],{"type":16,"tag":74,"props":1068,"children":1069},{},[1070],{"type":21,"value":1038},{"type":16,"tag":74,"props":1072,"children":1074},{"class":76,"line":1073},18,[1075],{"type":16,"tag":74,"props":1076,"children":1077},{},[1078],{"type":21,"value":215},{"type":16,"tag":17,"props":1080,"children":1081},{},[1082,1084,1090],{"type":21,"value":1083},"The second consumer is the frontend, which gets a typed client and uses it inside React Query hooks. The third is the handler itself, which is wired through a router that validates params, query and body against the same schema before your code runs, and turns thrown ",{"type":16,"tag":41,"props":1085,"children":1087},{"className":1086},[],[1088],{"type":21,"value":1089},"HttpError",{"type":21,"value":1091},"s into RFC 9457 problem details:",{"type":16,"tag":64,"props":1093,"children":1095},{"className":680,"code":1094,"language":682,"meta":7,"style":7},"export const lambda_handler = router(todos, {\n  list: async () => response(200, await listTodos()),\n  create: async ({ body }) => {\n    const todo: Todo = { id: randomUUID(), ...body, done: false };\n    await dynamodb.send(new PutCommand({ TableName: tableName, Item: todo }));\n    metrics.addMetric(\"TodosCreated\", MetricUnit.Count, 1);\n    return response(201, todo);\n  },\n}, { logger, tracer, metrics });\n",[1096],{"type":16,"tag":41,"props":1097,"children":1098},{"__ignoreMap":7},[1099,1129,1190,1231,1292,1329,1366,1392,1400],{"type":16,"tag":74,"props":1100,"children":1101},{"class":76,"line":77},[1102,1106,1110,1115,1119,1124],{"type":16,"tag":74,"props":1103,"children":1104},{"style":692},[1105],{"type":21,"value":695},{"type":16,"tag":74,"props":1107,"children":1108},{"style":692},[1109],{"type":21,"value":700},{"type":16,"tag":74,"props":1111,"children":1112},{"style":703},[1113],{"type":21,"value":1114}," lambda_handler",{"type":16,"tag":74,"props":1116,"children":1117},{"style":692},[1118],{"type":21,"value":711},{"type":16,"tag":74,"props":1120,"children":1121},{"style":714},[1122],{"type":21,"value":1123}," router",{"type":16,"tag":74,"props":1125,"children":1126},{"style":97},[1127],{"type":21,"value":1128},"(todos, {\n",{"type":16,"tag":74,"props":1130,"children":1131},{"class":76,"line":87},[1132,1137,1142,1147,1152,1157,1162,1166,1171,1175,1180,1185],{"type":16,"tag":74,"props":1133,"children":1134},{"style":714},[1135],{"type":21,"value":1136},"  list",{"type":16,"tag":74,"props":1138,"children":1139},{"style":97},[1140],{"type":21,"value":1141},": ",{"type":16,"tag":74,"props":1143,"children":1144},{"style":692},[1145],{"type":21,"value":1146},"async",{"type":16,"tag":74,"props":1148,"children":1149},{"style":97},[1150],{"type":21,"value":1151}," () ",{"type":16,"tag":74,"props":1153,"children":1154},{"style":692},[1155],{"type":21,"value":1156},"=>",{"type":16,"tag":74,"props":1158,"children":1159},{"style":714},[1160],{"type":21,"value":1161}," response",{"type":16,"tag":74,"props":1163,"children":1164},{"style":97},[1165],{"type":21,"value":722},{"type":16,"tag":74,"props":1167,"children":1168},{"style":703},[1169],{"type":21,"value":1170},"200",{"type":16,"tag":74,"props":1172,"children":1173},{"style":97},[1174],{"type":21,"value":411},{"type":16,"tag":74,"props":1176,"children":1177},{"style":692},[1178],{"type":21,"value":1179},"await",{"type":16,"tag":74,"props":1181,"children":1182},{"style":714},[1183],{"type":21,"value":1184}," listTodos",{"type":16,"tag":74,"props":1186,"children":1187},{"style":97},[1188],{"type":21,"value":1189},"()),\n",{"type":16,"tag":74,"props":1191,"children":1192},{"class":76,"line":103},[1193,1198,1202,1206,1211,1217,1222,1226],{"type":16,"tag":74,"props":1194,"children":1195},{"style":714},[1196],{"type":21,"value":1197},"  create",{"type":16,"tag":74,"props":1199,"children":1200},{"style":97},[1201],{"type":21,"value":1141},{"type":16,"tag":74,"props":1203,"children":1204},{"style":692},[1205],{"type":21,"value":1146},{"type":16,"tag":74,"props":1207,"children":1208},{"style":97},[1209],{"type":21,"value":1210}," ({ ",{"type":16,"tag":74,"props":1212,"children":1214},{"style":1213},"--shiki-default:#FFAB70",[1215],{"type":21,"value":1216},"body",{"type":16,"tag":74,"props":1218,"children":1219},{"style":97},[1220],{"type":21,"value":1221}," }) ",{"type":16,"tag":74,"props":1223,"children":1224},{"style":692},[1225],{"type":21,"value":1156},{"type":16,"tag":74,"props":1227,"children":1228},{"style":97},[1229],{"type":21,"value":1230}," {\n",{"type":16,"tag":74,"props":1232,"children":1233},{"class":76,"line":118},[1234,1239,1244,1248,1253,1257,1262,1267,1272,1277,1282,1287],{"type":16,"tag":74,"props":1235,"children":1236},{"style":692},[1237],{"type":21,"value":1238},"    const",{"type":16,"tag":74,"props":1240,"children":1241},{"style":703},[1242],{"type":21,"value":1243}," todo",{"type":16,"tag":74,"props":1245,"children":1246},{"style":692},[1247],{"type":21,"value":677},{"type":16,"tag":74,"props":1249,"children":1250},{"style":714},[1251],{"type":21,"value":1252}," Todo",{"type":16,"tag":74,"props":1254,"children":1255},{"style":692},[1256],{"type":21,"value":711},{"type":16,"tag":74,"props":1258,"children":1259},{"style":97},[1260],{"type":21,"value":1261}," { id: ",{"type":16,"tag":74,"props":1263,"children":1264},{"style":714},[1265],{"type":21,"value":1266},"randomUUID",{"type":16,"tag":74,"props":1268,"children":1269},{"style":97},[1270],{"type":21,"value":1271},"(), ",{"type":16,"tag":74,"props":1273,"children":1274},{"style":692},[1275],{"type":21,"value":1276},"...",{"type":16,"tag":74,"props":1278,"children":1279},{"style":97},[1280],{"type":21,"value":1281},"body, done: ",{"type":16,"tag":74,"props":1283,"children":1284},{"style":703},[1285],{"type":21,"value":1286},"false",{"type":16,"tag":74,"props":1288,"children":1289},{"style":97},[1290],{"type":21,"value":1291}," };\n",{"type":16,"tag":74,"props":1293,"children":1294},{"class":76,"line":131},[1295,1300,1305,1310,1314,1319,1324],{"type":16,"tag":74,"props":1296,"children":1297},{"style":692},[1298],{"type":21,"value":1299},"    await",{"type":16,"tag":74,"props":1301,"children":1302},{"style":97},[1303],{"type":21,"value":1304}," dynamodb.",{"type":16,"tag":74,"props":1306,"children":1307},{"style":714},[1308],{"type":21,"value":1309},"send",{"type":16,"tag":74,"props":1311,"children":1312},{"style":97},[1313],{"type":21,"value":722},{"type":16,"tag":74,"props":1315,"children":1316},{"style":692},[1317],{"type":21,"value":1318},"new",{"type":16,"tag":74,"props":1320,"children":1321},{"style":714},[1322],{"type":21,"value":1323}," PutCommand",{"type":16,"tag":74,"props":1325,"children":1326},{"style":97},[1327],{"type":21,"value":1328},"({ TableName: tableName, Item: todo }));\n",{"type":16,"tag":74,"props":1330,"children":1331},{"class":76,"line":140},[1332,1337,1342,1346,1351,1356,1361],{"type":16,"tag":74,"props":1333,"children":1334},{"style":97},[1335],{"type":21,"value":1336},"    metrics.",{"type":16,"tag":74,"props":1338,"children":1339},{"style":714},[1340],{"type":21,"value":1341},"addMetric",{"type":16,"tag":74,"props":1343,"children":1344},{"style":97},[1345],{"type":21,"value":722},{"type":16,"tag":74,"props":1347,"children":1348},{"style":112},[1349],{"type":21,"value":1350},"\"TodosCreated\"",{"type":16,"tag":74,"props":1352,"children":1353},{"style":97},[1354],{"type":21,"value":1355},", MetricUnit.Count, ",{"type":16,"tag":74,"props":1357,"children":1358},{"style":703},[1359],{"type":21,"value":1360},"1",{"type":16,"tag":74,"props":1362,"children":1363},{"style":97},[1364],{"type":21,"value":1365},");\n",{"type":16,"tag":74,"props":1367,"children":1368},{"class":76,"line":243},[1369,1374,1378,1382,1387],{"type":16,"tag":74,"props":1370,"children":1371},{"style":692},[1372],{"type":21,"value":1373},"    return",{"type":16,"tag":74,"props":1375,"children":1376},{"style":714},[1377],{"type":21,"value":1161},{"type":16,"tag":74,"props":1379,"children":1380},{"style":97},[1381],{"type":21,"value":722},{"type":16,"tag":74,"props":1383,"children":1384},{"style":703},[1385],{"type":21,"value":1386},"201",{"type":16,"tag":74,"props":1388,"children":1389},{"style":97},[1390],{"type":21,"value":1391},", todo);\n",{"type":16,"tag":74,"props":1393,"children":1394},{"class":76,"line":316},[1395],{"type":16,"tag":74,"props":1396,"children":1397},{"style":97},[1398],{"type":21,"value":1399},"  },\n",{"type":16,"tag":74,"props":1401,"children":1402},{"class":76,"line":325},[1403],{"type":16,"tag":74,"props":1404,"children":1405},{"style":97},[1406],{"type":21,"value":1407},"}, { logger, tracer, metrics });\n",{"type":16,"tag":17,"props":1409,"children":1410},{},[1411,1413,1419,1421,1427,1429,1435],{"type":21,"value":1412},"Gateway-level errors are rewritten to the same ",{"type":16,"tag":41,"props":1414,"children":1416},{"className":1415},[],[1417],{"type":21,"value":1418},"application/problem+json",{"type":21,"value":1420}," shape with VTL response templates, so a 401 from the authorizer looks identical to a 401 from a handler. Streaming responses are a first-class case in the contract, marked ",{"type":16,"tag":41,"props":1422,"children":1424},{"className":1423},[],[1425],{"type":21,"value":1426},"transfer: \"stream\"",{"type":21,"value":1428},", and the endpoint module switches to the streaming invoke ARN and a ",{"type":16,"tag":41,"props":1430,"children":1432},{"className":1431},[],[1433],{"type":21,"value":1434},"{proxy+}",{"type":21,"value":1436}," resource when it sees one. The retrieval endpoint uses it to stream Bedrock tokens to the browser.",{"type":16,"tag":17,"props":1438,"children":1439},{},[1440,1442,1448,1450,1456],{"type":21,"value":1441},"Adding a path is one command. ",{"type":16,"tag":41,"props":1443,"children":1445},{"className":1444},[],[1446],{"type":21,"value":1447},"pnpm api:create-path",{"type":21,"value":1449}," asks for a path and whether it buffers or streams, then writes the endpoint ",{"type":16,"tag":41,"props":1451,"children":1453},{"className":1452},[],[1454],{"type":21,"value":1455},".tf",{"type":21,"value":1457}," from a template, creates the function package, writes a starter contract, regenerates the manifest and the OpenAPI doc, and applies. You are editing business logic within a minute.",{"type":16,"tag":29,"props":1459,"children":1461},{"id":1460},"idea-4-a-sandbox-per-branch",[1462],{"type":21,"value":1463},"Idea 4: a sandbox per branch",{"type":16,"tag":17,"props":1465,"children":1466},{},[1467,1473,1474,1479,1481,1486],{"type":16,"tag":41,"props":1468,"children":1470},{"className":1469},[],[1471],{"type":21,"value":1472},"dev",{"type":21,"value":411},{"type":16,"tag":41,"props":1475,"children":1477},{"className":1476},[],[1478],{"type":21,"value":409},{"type":21,"value":1480}," and ",{"type":16,"tag":41,"props":1482,"children":1484},{"className":1483},[],[1485],{"type":21,"value":417},{"type":21,"value":1487}," are fixed environments. Anything else is a sandbox, and the environment name is a short hash of the git branch:",{"type":16,"tag":64,"props":1489,"children":1491},{"className":185,"code":1490,"language":187,"meta":7,"style":7},"locals {\n  is_sandbox = !contains([\"dev\", \"test\", \"prod\"], var.environment)\n}\n",[1492],{"type":16,"tag":41,"props":1493,"children":1494},{"__ignoreMap":7},[1495,1503,1511],{"type":16,"tag":74,"props":1496,"children":1497},{"class":76,"line":77},[1498],{"type":16,"tag":74,"props":1499,"children":1500},{},[1501],{"type":21,"value":1502},"locals {\n",{"type":16,"tag":74,"props":1504,"children":1505},{"class":76,"line":87},[1506],{"type":16,"tag":74,"props":1507,"children":1508},{},[1509],{"type":21,"value":1510},"  is_sandbox = !contains([\"dev\", \"test\", \"prod\"], var.environment)\n",{"type":16,"tag":74,"props":1512,"children":1513},{"class":76,"line":103},[1514],{"type":16,"tag":74,"props":1515,"children":1516},{},[1517],{"type":21,"value":215},{"type":16,"tag":17,"props":1519,"children":1520},{},[1521],{"type":16,"tag":53,"props":1522,"children":1525},{"alt":1523,"src":1524},"Environments: dev, test and prod are deployed from main through plan and apply stages, while each feature branch gets its own sandbox stack that shares dev's Bedrock knowledge base through remote state, is owned by whoever created the branch, and is nuked on Friday evening if forgotten","/articles/serverless-toolkit-sandboxes.svg",[],{"type":16,"tag":17,"props":1527,"children":1528},{},[1529,1535],{"type":16,"tag":41,"props":1530,"children":1532},{"className":1531},[],[1533],{"type":21,"value":1534},"pnpm sandbox:create",{"type":21,"value":1536}," applies a full stack for your branch. Three details make that affordable rather than reckless.",{"type":16,"tag":17,"props":1538,"children":1539},{},[1540,1542,1548,1550,1556],{"type":21,"value":1541},"Sandboxes do not get their own Bedrock knowledge base. Indexing takes a while and costs real money, so a sandbox reads dev's through ",{"type":16,"tag":41,"props":1543,"children":1545},{"className":1544},[],[1546],{"type":21,"value":1547},"terraform_remote_state",{"type":21,"value":1549},". They also skip alarms and, unless you pass ",{"type":16,"tag":41,"props":1551,"children":1553},{"className":1552},[],[1554],{"type":21,"value":1555},"--with-monitoring",{"type":21,"value":1557},", the monitoring SPA.",{"type":16,"tag":17,"props":1559,"children":1560},{},[1561],{"type":21,"value":1562},"The sandbox scripts read the owner out of Terraform state before an apply or destroy and stop if it is not you. Branch names collide more often than you would think.",{"type":16,"tag":17,"props":1564,"children":1565},{},[1566,1568,1574],{"type":21,"value":1567},"And forgotten sandboxes are nuked. A Step Functions state machine in dev runs ",{"type":16,"tag":41,"props":1569,"children":1571},{"className":1570},[],[1572],{"type":21,"value":1573},"aws-nuke",{"type":21,"value":1575}," in a Lambda on Friday evenings, looping until a pass completes clean, with a wait state for the Lambda@Edge replicas that refuse to delete for an hour after their distribution is gone. A git post-checkout hook nags you when you switch to a branch that still has a stack.",{"type":16,"tag":29,"props":1577,"children":1579},{"id":1578},"local-development",[1580],{"type":21,"value":1581},"Local development",{"type":16,"tag":17,"props":1583,"children":1584},{},[1585,1591,1593,1599,1601,1607],{"type":16,"tag":41,"props":1586,"children":1588},{"className":1587},[],[1589],{"type":21,"value":1590},"pnpm api:mock",{"type":21,"value":1592}," runs SAM's local API in Docker next to an esbuild watch on every function, repoints the generated Amplify config at localhost and restores it on exit. The catch is CORS. The deployed API answers preflight with a gateway ",{"type":16,"tag":41,"props":1594,"children":1596},{"className":1595},[],[1597],{"type":21,"value":1598},"MOCK",{"type":21,"value":1600}," integration, which SAM cannot emulate, so a ",{"type":16,"tag":41,"props":1602,"children":1604},{"className":1603},[],[1605],{"type":21,"value":1606},"local_development",{"type":21,"value":1608}," flag in Terraform swaps in a tiny Lambda that returns the same headers. It is the one place local and deployed differ, and the flag is the whole diff.",{"type":16,"tag":29,"props":1610,"children":1612},{"id":1611},"ci",[1613],{"type":21,"value":1614},"CI",{"type":16,"tag":17,"props":1616,"children":1617},{},[1618,1620,1627],{"type":21,"value":1619},"Deploys follow the same rule as the ",{"type":16,"tag":1621,"props":1622,"children":1624},"a",{"href":1623},"/articles/terraform-when-the-agent-does-the-typing",[1625],{"type":21,"value":1626},"Terraform template",{"type":21,"value":1628},": plan under a read-only role, upload the plan, apply the saved artifact behind a GitHub environment gate, never re-plan at apply time. The frontend deploy then downloads the outputs artifact from that run, regenerates the config from it without touching state, builds the SPA, syncs to S3 and invalidates CloudFront. Terraform runs once per deploy, in one job.",{"type":16,"tag":29,"props":1630,"children":1632},{"id":1631},"rough-edges",[1633],{"type":21,"value":1634},"Rough edges",{"type":16,"tag":17,"props":1636,"children":1637},{},[1638],{"type":21,"value":1639},"The community Terraform modules are pinned to commit SHAs and bumped by hand. The Lambda layer ARNs are too, with a comment admitting there is no way to automate it. There is a complete Aurora module in the repo that nothing references, kept for the projects that need a relational store. Non-prod Cognito seeds three test users with a password that is in the repo, which is fine for a template and the first thing to change in a fork. And the release tagging in the deploy workflow increments a patch version with a retry loop to survive two deploys racing, which works and which I would still rather not have written.",{"type":16,"tag":29,"props":1641,"children":1643},{"id":1642},"steal-these",[1644],{"type":21,"value":1645},"Steal these",{"type":16,"tag":17,"props":1647,"children":1648},{},[1649],{"type":21,"value":1650},"Name your Terraform outputs so a script can nest them, and let the frontend read the result. Make each function a workspace package and hash the shared code into its deployment trigger. Write the API once as a contract and derive the gateway, the client and the validator from it. Give every branch a stack, share the expensive parts with dev, and delete the rest on a schedule. None of it needs a framework. It needs about four hundred lines of Python and a naming convention.",{"type":16,"tag":1652,"props":1653,"children":1654},"style",{},[1655],{"type":21,"value":1656},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":7,"searchDepth":87,"depth":87,"links":1658},[1659,1660,1661,1662,1663,1664,1665,1666,1667],{"id":31,"depth":87,"text":34},{"id":153,"depth":87,"text":156},{"id":435,"depth":87,"text":438},{"id":661,"depth":87,"text":664},{"id":1460,"depth":87,"text":1463},{"id":1578,"depth":87,"text":1581},{"id":1611,"depth":87,"text":1614},{"id":1631,"depth":87,"text":1634},{"id":1642,"depth":87,"text":1645},"markdown","content:articles:serverless-on-aws-without-a-serverless-framework.md","content","articles/serverless-on-aws-without-a-serverless-framework.md","articles/serverless-on-aws-without-a-serverless-framework","md",1789325964860]