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.
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.
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.
What it deploys
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 Monitoring Cognito group, which reads metrics directly using credentials from the identity pool.
The monorepo layout is what you would expect, with one addition that matters and gets its own section below:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
# API lambda functions are workspace packages too, so they can import shared packages
- "apps/web/functions/*"
Idea 1: the frontend reads Terraform outputs
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 amplify/ directory. The config it needs is generated from Terraform.
The trick is a naming convention on outputs. Anything prefixed Amplify_ is folded into a nested object by splitting on underscores:
output "Amplify_Auth_Cognito_userPoolId" {
value = module.auth.user_pool_id
}
output "Amplify_API_REST_main-api_endpoint" {
value = module.api.invoke_url
}
def parse_terraform_output(output_json):
"""Fold 'terraform output -json' into the nested shape Amplify.configure wants."""
amplify_config = {}
for key, payload in output_json.items():
if not key.startswith("Amplify"):
continue
parts = key.split("_")[1:] # drop the Amplify prefix
level = amplify_config
for part in parts[:-1]:
level = level.setdefault(part, {})
level[parts[-1]] = payload["value"]
return amplify_config
That writes packages/auth/src/amplify-config.json, which the auth provider imports and hands to Amplify.configure(). Running pnpm checkout --env dev re-inits Terraform against the dev state bucket, runs terraform output -json, regenerates the file and sets the environment name in .env.local. Point the same command at test, prod or your sandbox and the frontend follows.
The state bucket per environment lives in a plain config.ini 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.
Idea 2: Lambdas are workspace packages and Terraform builds them
Each function under apps/web/functions/ has its own package.json 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.
Terraform packages it using the community Lambda module's source_path.commands. The commands install the workspace, build with esbuild and zip the output. The odd-looking part is the lock directory:
source_path = [{
path = "${path.root}/../apps/web/functions/${var.slug}"
commands = [
"set -e",
"LOCK=../../../../.pnpm-install.lock",
"until mkdir \"$LOCK\" 2>/dev/null; do sleep 1; done; trap 'rmdir \"$LOCK\"' EXIT",
"pnpm install --frozen-lockfile",
"rmdir \"$LOCK\"; trap - EXIT",
"pnpm run build",
":zip dist",
]
patterns = ["!node_modules/.*", "!dist/.*", "!.*\\.js$"]
}]
hash_extra = local.shared_packages_hash
Terraform builds every function's package in parallel, and parallel pnpm install calls in one workspace corrupt each other, so the first one to mkdir the lock wins and the rest wait. It is a one-line mutex and it has never failed.
The hash_extra 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 node_modules. So a change to a shared package would never redeploy the functions that import it. hash_extra is a hash of every file under packages/*/src plus the lockfile. Change the contracts package and every function redeploys. Change one handler and only that function does.
Idea 3: one contract, three consumers
The API is defined once, as zod schemas in packages/api-contracts:
export const todos = group("/todos", {
list: get("/", { response: z.array(Todo) }),
create: post("/", { body: TodoInput, response: Todo }),
update: patch("/:id", { params: z.object({ id: z.string() }), body: TodoInput.partial(), response: Todo }),
remove: del("/:id", { params: z.object({ id: z.string() }) }),
});
Three things are generated or derived from that one file, and none of them can drift from the others.
The first consumer is Terraform. A pre-commit hook writes terraform/api-manifest.json from the contracts, and each endpoint module reads its routes from it. The authorisation rules stay in HCL, because they are infrastructure:
module "todos" {
source = "../modules/endpoint"
slug = "todos"
path = "/todos"
routes = local.manifest.endpoints["/todos"].routes
methods = {
GET = { allow_unauthenticated = true }
POST = { allowed_groups = ["User", "Admin"] }
PATCH = { allowed_groups = ["User", "Admin"] }
DELETE = { allowed_groups = ["Admin"] }
}
dynamo_tables = {
DDB_TABLE_NAME = { arn = var.dynamodb_table.arn, name = var.dynamodb_table.name }
}
}
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 HttpErrors into RFC 9457 problem details:
export const lambda_handler = router(todos, {
list: async () => response(200, await listTodos()),
create: async ({ body }) => {
const todo: Todo = { id: randomUUID(), ...body, done: false };
await dynamodb.send(new PutCommand({ TableName: tableName, Item: todo }));
metrics.addMetric("TodosCreated", MetricUnit.Count, 1);
return response(201, todo);
},
}, { logger, tracer, metrics });
Gateway-level errors are rewritten to the same application/problem+json 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 transfer: "stream", and the endpoint module switches to the streaming invoke ARN and a {proxy+} resource when it sees one. The retrieval endpoint uses it to stream Bedrock tokens to the browser.
Adding a path is one command. pnpm api:create-path asks for a path and whether it buffers or streams, then writes the endpoint .tf 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.
Idea 4: a sandbox per branch
dev, test and prod are fixed environments. Anything else is a sandbox, and the environment name is a short hash of the git branch:
locals {
is_sandbox = !contains(["dev", "test", "prod"], var.environment)
}
pnpm sandbox:create applies a full stack for your branch. Three details make that affordable rather than reckless.
Sandboxes do not get their own Bedrock knowledge base. Indexing takes a while and costs real money, so a sandbox reads dev's through terraform_remote_state. They also skip alarms and, unless you pass --with-monitoring, the monitoring SPA.
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.
And forgotten sandboxes are nuked. A Step Functions state machine in dev runs aws-nuke 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.
Local development
pnpm api:mock 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 MOCK integration, which SAM cannot emulate, so a local_development 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.
CI
Deploys follow the same rule as the Terraform template: 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.
Rough edges
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.
Steal these
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.