I spent weeks building a fully serverless task management system on AWS — Lambda, DynamoDB, Cognito, SNS, SES, Amplify, the whole stack — provisioned entirely with Terraform and wired into a GitHub Actions CI/CD pipeline.
Here's what I learned. Not the happy path. The real stuff.
Repo: github.com/celetrialprince166/Serverless-task-management-app
What I Built
A role-based task management app where:
- Admins can create, assign, update, and delete tasks
- Members can view their assigned tasks and update status
- Email notifications fire automatically when tasks are assigned or status changes
- Everything runs serverless on AWS — zero servers to manage
The stack:
| Layer | What |
|---|---|
| Frontend | React 19 + Vite + Tailwind → AWS Amplify |
| API | API Gateway REST + Cognito JWT auth |
| Compute | 15+ Lambda functions, Node.js 20, TypeScript |
| Database | DynamoDB (single-table design) |
| Notifications | DynamoDB Streams → SNS → SES |
| IaC | Terraform (modular) |
| CI/CD | GitHub Actions (Checkov + npm audit + terraform validate) |
Gotcha #1: DynamoDB Single-Table Design Is Not Optional
I started with multiple DynamoDB tables — one for tasks, one for users, one for assignments. Classic relational thinking.
The problem: DynamoDB has no JOIN. To get a task with its assignees, I needed three separate GetItem calls. Three round-trips. Three places for something to fail.
The fix: single-table design using composite primary keys.
// Task item
{ PK: "TASK#01HXYZ", SK: "METADATA", status: "IN_PROGRESS", ... }
// Assignment — same table, different SK prefix
{ PK: "TASK#01HXYZ", SK: "ASSIGN#USER#01HJKL", ... }
Now query(PK = "TASK#01HXYZ") returns the task AND all its assignments in one call. The begins_with("ASSIGN#") filter separates them client-side.
The trade-off: you must define ALL access patterns before building the schema. Change your access patterns later and you're adding GSIs or doing table scans.
I used PAY_PER_REQUEST billing — scales to zero when nothing's happening, which is perfect for a project portfolio app.
Gotcha #2: The Cognito Post-Confirmation Trigger Fires More Than Once
I added a post-confirmation Lambda trigger to create the user record in DynamoDB after signup.
What I didn't know: this trigger fires on sign-in events too, not just the initial signup confirmation. Specifically, when email verification or MFA is involved, the trigger re-fires on subsequent authentications.
Without an idempotency guard, every sign-in overwrites the user's DynamoDB record — silently resetting any role I'd manually set to ADMIN back to MEMBER.
Fix: one line of Terraform logic in the PutItemCommand:
ConditionExpression: "attribute_not_exists(PK)"
// Only writes if the item doesn't exist yet — idempotent
Now the first sign-in creates the profile. Every subsequent one does nothing.
Gotcha #3: CORS Is a Three-Layer Problem in API Gateway
Browser CORS error. Classic. Except in serverless, there are three places to fix it and you need all three:
Layer 1 — API Gateway: add OPTIONS method to every resource and configure Gateway Responses for 4xx/5xx
Layer 2 — Lambda: every handler response must include CORS headers
headers: {
"Access-Control-Allow-Origin": process.env.ALLOWED_ORIGIN || "*",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
}
Layer 3 — The one nobody mentions: Gateway Responses for auth errors. When Cognito rejects a JWT, API Gateway returns a 401 — but that error comes from the authorizer, not from Lambda. So your Lambda CORS headers don't run. You get a CORS error that's actually a 401.
Fix it in Terraform:
resource "aws_api_gateway_gateway_response" "cors_4xx" {
rest_api_id = var.rest_api_id
response_type = "DEFAULT_4XX"
response_parameters = {
"gatewayresponse.header.Access-Control-Allow-Origin" = "'*'"
}
}
Gotcha #4: GitHub Actions OIDC Has a Silent Permission Requirement
I switched from static AWS access keys to OIDC federation for the CI pipeline. The configure-aws-credentials action just said: "Credentials could not be loaded."
No mention of permissions. No useful error.
The fix is one block:
permissions:
id-token: write # This is required. Without it, OIDC JWT request fails silently.
contents: read
GitHub's documentation mentions this, but it's buried. The action's error message gives you zero hint that this is the problem.
Gotcha #5: Amplify Monorepo Needs appRoot
My repo has both backend/ and frontend/. Amplify's default build config looks for package.json in the repository root. It doesn't find one. Build fails with a vague error.
Fix in amplify.yml:
version: 1
applications:
- frontend:
phases:
preBuild:
commands: [npm ci]
build:
commands: [npm run build]
artifacts:
baseDirectory: dist
files: ["**/*"]
appRoot: frontend # ← This is the important line
Without appRoot, Amplify tries to build from the repo root and fails every time.
Gotcha #6: Notifications Must Be Decoupled — Or You'll Regret It
My first instinct was to have the task Lambda call SES directly after writing to DynamoDB. Simple. Direct.
The problem: SES latency adds to every task write response time. If SES is down, task writes fail. If I want to add Slack notifications later, I edit the task Lambda.
The right pattern is DynamoDB Streams → SNS → email formatter Lambda:
Task write Lambda → DynamoDB → Stream → SNS → Email Lambda → SES
Now:
- Task writes are fast — no SES dependency
- SES failures don't break task creation
- Adding Slack = adding one SNS subscriber. Zero changes to existing code.
The stream processor detects events by checking the item's SK prefix:
// New assignment? SK starts with "ASSIGN#"
if (newImage.SK?.startsWith("ASSIGN#") && record.eventName === "INSERT") {
await sns.send(new PublishCommand({ type: "TASK_ASSIGNED", ... }));
}
// Status changed? SK is "METADATA" and status field differs
if (newImage.SK === "METADATA" && oldImage.status !== newImage.status) {
await sns.send(new PublishCommand({ type: "STATUS_CHANGED", ... }));
}
What I'd Do Differently
Use provisioned concurrency for critical Lambda functions. Cold starts on the first request hit up to 800ms on my heavier handlers. For a real production app, provisioned concurrency keeps warm instances ready.
Add a WebSocket API for real-time task updates. Right now the frontend polls. API Gateway WebSockets would let me push status changes to connected clients instantly.
Design GSIs more carefully upfront. I added a second GSI midway through because I hadn't thought through the "list tasks assigned to user" access pattern. You can't backfill existing items to a new GSI's index — only new writes get indexed.
Key Takeaways
- Single-table DynamoDB requires upfront access pattern design — change your mind later and you're adding indexes
-
Cognito post-confirmation triggers are NOT idempotent by default — guard your DynamoDB writes with
attribute_not_exists - CORS in API Gateway has three independent configuration points — miss the Gateway Responses and you'll see CORS errors that are actually 401s
-
GitHub Actions OIDC requires
id-token: write— the error message won't tell you this -
Amplify monorepo requires
appRoot— without it, every Amplify build fails silently - Decouple notifications from writes — Streams → SNS → Lambda is the right pattern, always
Full technical deep-dive with complete Terraform code and all 15+ Lambda handlers: My Hashnode blog
What does your serverless notification architecture look like? Still doing direct Lambda → SES, or have you moved to a queue/stream pattern? Drop it in the comments. 👇

Top comments (0)