DEV Community

Cover image for I Built a Fully Serverless Task Manager on AWS — Here's What the Docs Don't Tell You
Prince Ayiku
Prince Ayiku

Posted on • Originally published at princeayiku.hashnode.dev

I Built a Fully Serverless Task Manager on AWS — Here's What the Docs Don't Tell You

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)

Architecture diagram


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", ... }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

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" = "'*'"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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", ... }));
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Single-table DynamoDB requires upfront access pattern design — change your mind later and you're adding indexes
  2. Cognito post-confirmation triggers are NOT idempotent by default — guard your DynamoDB writes with attribute_not_exists
  3. CORS in API Gateway has three independent configuration points — miss the Gateway Responses and you'll see CORS errors that are actually 401s
  4. GitHub Actions OIDC requires id-token: write — the error message won't tell you this
  5. Amplify monorepo requires appRoot — without it, every Amplify build fails silently
  6. 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)