Skip to main content

Command Palette

Search for a command to run...

Building a Production-Grade Serverless Task Manager on AWS: Lambda, DynamoDB Single-Table Design, and Event-Driven Notifications

Published
10 min read
Building a Production-Grade Serverless Task Manager on AWS: Lambda, DynamoDB Single-Table Design, and Event-Driven Notifications
P
AWS | Terraform | Docker — documenting my journey to production-ready systems.

Introduction

Production-grade serverless means more than deploying a Lambda function. It means RBAC-enforced APIs, event-driven notifications that fire asynchronously without blocking the request path, infrastructure reproducible from a single Terraform command, and a CI pipeline that catches security issues before they reach production.

This article documents the complete implementation of a serverless task management system on AWS: 15+ Lambda functions in TypeScript, DynamoDB single-table design, Cognito-based role-based access control, event-driven email notifications via DynamoDB Streams and SES, React 19 frontend on Amplify, and GitHub Actions CI/CD with infrastructure security scanning.

Repository: github.com/celetrialprince166/Serverless-task-management-app


Architecture Overview

The system is structured across six distinct layers, each with a defined responsibility boundary:

Serverless Task Management System — AWS Architecture

LayerComponentsPurpose
FrontendReact 19, Vite, Tailwind, AmplifySPA with role-based UI rendering
CDN / HostingCloudFront, AmplifyGlobal delivery, SSL termination
APIAPI Gateway (REST)Request routing, throttling, CORS
AuthCognito User Pools + TriggersJWT authentication, RBAC
ComputeLambda (Node.js 20.x, TypeScript)15+ microservice handler functions
Data / NotificationsDynamoDB, Streams, SNS, SESPersistence + async event pipeline

Data flows: HTTPS → CloudFront → Amplify frontend → API Gateway → Cognito JWT authorizer → Lambda handler → DynamoDB. Notifications fire asynchronously: DynamoDB Stream → stream processor Lambda → SNS topic → email formatter Lambda → SES.


Infrastructure as Code: Modular Terraform

All AWS resources are defined in Terraform using a modular structure that separates concerns and enables environment-specific configuration without code duplication.

terraform/
├── modules/
│   ├── lambda/        # Function packaging, IAM role, environment variables
│   ├── api-gateway/   # REST API, resources, methods, integrations
│   ├── cognito/       # User Pool, client, triggers
│   ├── dynamodb/      # Table, GSIs, stream configuration
│   ├── amplify/       # Frontend hosting, build config
│   ├── notifications/ # SNS topic, SES, Lambda subscriptions
│   └── iam/           # Cross-module IAM policies
└── environments/
    └── dev/           # Backend config, tfvars

Remote state is managed in S3 with DynamoDB locking for safe concurrent operations:

cd terraform/scripts
./bootstrap-backend.sh
# Creates: s3://taskmanager-terraform-state + DynamoDB table taskmanager-lock

Lambda module — least-privilege IAM per function:

module "lambda_tasks_create" {
  source        = "../../modules/lambda"
  function_name = "${var.project_name}-tasks-create"
  handler       = "handlers/tasks/create.handler"
  runtime       = "nodejs20.x"
  source_dir    = "${path.root}/../../backend/dist"

  environment_variables = {
    DYNAMODB_TABLE    = module.dynamodb.table_name
    COGNITO_USER_POOL = module.cognito.user_pool_id
    AWS_NODEJS_CONNECTION_REUSE_ENABLED = "1"
  }

  iam_policy_statements = [
    {
      Effect   = "Allow"
      Action   = ["dynamodb:PutItem", "dynamodb:GetItem"]
      Resource = module.dynamodb.table_arn
    }
  ]
}

Each Lambda function has a dedicated IAM policy scoped to only what it needs. The create task handler can write to DynamoDB — nothing else. Least-privilege is enforced at the function level, not the service level.


DynamoDB Single-Table Design

The most consequential architectural decision in this project is the DynamoDB table design. The application uses a single table for all entities — tasks, users, and assignments — with a composite primary key pattern that serves all required query patterns from one table.

Access Patterns

Access PatternPKSKIndex
Get task by IDTASK#{taskId}METADATAPrimary
List tasks by statusstatusTASK#{taskId}GSI: status-index
Get assignments for taskTASK#{taskId}ASSIGN#{userId}Primary (begins_with)
Get tasks assigned to userUSER#{userId}TASK#{taskId}GSI: user-tasks-index
Get user profileUSER#{userId}PROFILEPrimary

Why single-table? DynamoDB has no JOIN equivalent. Multi-table designs require multiple network round-trips to compose related data. Single-table design collapses these into one query() call using composite sort key prefixes.

The trade-off: access patterns must be defined upfront. Changes after deployment may require index additions.

// Task item — primary entity
{
  PK: "TASK#01HXYZ...",
  SK: "METADATA",
  taskId: "01HXYZ...",
  title: "Implement user auth",
  status: "IN_PROGRESS",
  priority: "HIGH",
  createdBy: "USER#01HABC...",
  dueDate: "2024-12-31",
  GSI1PK: "IN_PROGRESS",       // Enables status-based queries
  GSI1SK: "TASK#01HXYZ..."
}

// Assignment item — same table, different key pattern
{
  PK: "TASK#01HXYZ...",
  SK: "ASSIGN#USER#01HJKL...", // begins_with("ASSIGN#") returns all assignees
  taskId: "01HXYZ...",
  userId: "01HJKL...",
  assignedAt: "2024-01-15T10:30:00Z"
}

Terraform DynamoDB configuration:

resource "aws_dynamodb_table" "main" {
  name         = "${var.project_name}-${var.environment}"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "PK"
  range_key    = "SK"

  attribute { name = "PK";     type = "S" }
  attribute { name = "SK";     type = "S" }
  attribute { name = "GSI1PK"; type = "S" }
  attribute { name = "GSI1SK"; type = "S" }

  global_secondary_index {
    name            = "status-index"
    hash_key        = "GSI1PK"
    range_key       = "GSI1SK"
    projection_type = "ALL"
  }

  stream_enabled   = true
  stream_view_type = "NEW_AND_OLD_IMAGES"  # Required for notification pipeline

  server_side_encryption { enabled = true }
  point_in_time_recovery { enabled = true }
}

PAY_PER_REQUEST billing eliminates capacity planning and scales to zero between usage periods — appropriate for bursty, unpredictable task management workloads.


Cognito RBAC: Admin and Member Roles

Authentication is JWT-based via Cognito User Pools. Roles are stored in Cognito groups (ADMIN, MEMBER) and enforced in Lambda middleware. Admin users have full CRUD access; Members can view assigned tasks and update task status only.

Post-Confirmation Trigger — Idempotency is Critical

The post-confirmation Lambda trigger creates the user record in DynamoDB when signup completes:

// handlers/auth/post-confirmation.ts
export const handler = async (event: PostConfirmationTriggerEvent) => {
  const { userName, request: { userAttributes } } = event;

  await dynamoClient.send(new PutItemCommand({
    TableName: process.env.DYNAMODB_TABLE!,
    Item: marshall({
      PK: `USER#${userName}`,
      SK: "PROFILE",
      userId: userName,
      email: userAttributes.email,
      role: "MEMBER",
      createdAt: new Date().toISOString(),
    }),
    ConditionExpression: "attribute_not_exists(PK)",  // Idempotency guard
  }));

  await cognitoClient.send(new AdminAddUserToGroupCommand({
    UserPoolId: event.userPoolId,
    Username: userName,
    GroupName: "MEMBER",
  }));

  return event;
};

The ConditionExpression is not optional. The post-confirmation trigger fires on both the initial signup confirmation AND on subsequent sign-in confirmations when MFA or email verification is involved. Without attribute_not_exists(PK), every sign-in would overwrite the user's DynamoDB profile — silently resetting any admin-assigned role changes back to MEMBER.

RBAC Middleware

// middleware/auth.ts
export const requireRole = (requiredRole: "ADMIN" | "MEMBER") => {
  return (event: APIGatewayProxyEvent): void => {
    const claims = event.requestContext.authorizer?.claims;
    if (!claims) throw new UnauthorizedError("No auth claims");

    const groups: string[] = JSON.parse(claims["cognito:groups"] || "[]");
    const userRole = groups.includes("ADMIN") ? "ADMIN" : "MEMBER";

    if (requiredRole === "ADMIN" && userRole !== "ADMIN") {
      throw new ForbiddenError("Admin access required");
    }
  };
};

Event-Driven Notifications: DynamoDB Streams → SNS → SES

Notifications are fully decoupled from the write path. When a task is assigned, the Lambda handler writes to DynamoDB and returns immediately. The notification pipeline runs asynchronously via DynamoDB Streams.

DynamoDB Table (NEW_AND_OLD_IMAGES stream)
    ↓
Stream Processor Lambda
    ↓
SNS Topic (task-notifications)
    ↓
Email Formatter Lambda (SNS subscriber)
    ↓
SES → recipient email

Stream processor — detects assignment and status change events:

export const handler = async (event: DynamoDBStreamEvent) => {
  for (const record of event.Records) {
    if (record.eventName !== "MODIFY" && record.eventName !== "INSERT") continue;

    const newImage = unmarshall(record.dynamodb?.NewImage || {});
    const oldImage = unmarshall(record.dynamodb?.OldImage || {});

    // New assignment — SK starts with ASSIGN#
    if (newImage.SK?.startsWith("ASSIGN#") && record.eventName === "INSERT") {
      await sns.send(new PublishCommand({
        TopicArn: process.env.SNS_TOPIC_ARN!,
        Message: JSON.stringify({
          type: "TASK_ASSIGNED",
          taskId: newImage.taskId,
          userId: newImage.userId,
        }),
      }));
    }

    // Status change — same SK=METADATA, different status field
    if (newImage.SK === "METADATA" && oldImage.status !== newImage.status) {
      await sns.send(new PublishCommand({
        TopicArn: process.env.SNS_TOPIC_ARN!,
        Message: JSON.stringify({
          type: "STATUS_CHANGED",
          taskId: newImage.taskId,
          fromStatus: oldImage.status,
          toStatus: newImage.status,
        }),
      }));
    }
  }
};

Why SNS as intermediary — not direct SES from the stream processor?

The task write Lambda and stream processor have no SES dependency. Adding a new notification channel (Slack, webhook, mobile push) means adding a new SNS subscriber — zero changes to the stream processor or task handlers. SNS also provides retry semantics and dead-letter queue support if the email formatter Lambda fails.


Lambda Cold Start Optimization

Cold start latency is proportional to bundle size. The project uses esbuild rather than tsc for bundling:

{
  "scripts": {
    "build": "esbuild src/handlers/**/*.ts --bundle --platform=node --target=node20 --outdir=dist --external:@aws-sdk/*"
  }
}

--external:@aws-sdk/* excludes the AWS SDK from the bundle — it is provided by the Node.js 20 Lambda runtime, so bundling it adds ~12MB to each function package with no benefit. Combined with connection reuse:

// lib/dynamodb.ts — module-level client, initialized once per Lambda container
const client = new DynamoDBClient({ region: process.env.AWS_REGION, maxAttempts: 3 });
export const dynamo = DynamoDBDocumentClient.from(client);

The environment variable AWS_NODEJS_CONNECTION_REUSE_ENABLED=1 (set in Terraform) instructs the SDK HTTP agent to reuse TCP connections across invocations within the same warm container — eliminating the TCP handshake cost on every DynamoDB call.


CORS in Serverless: Three Layers

CORS must be configured at three distinct points. Missing any one produces a browser CORS error that hides the actual HTTP status code.

Layer 1 — API Gateway OPTIONS method and Gateway Responses:

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" = "'https://yourapp.amplifyapp.com'"
  }
}

Layer 2 — Lambda response headers (every handler):

headers: {
  "Access-Control-Allow-Origin": process.env.ALLOWED_ORIGIN || "*",
  "Access-Control-Allow-Headers": "Content-Type,Authorization",
  "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,PATCH,OPTIONS",
}

Layer 3 — Gateway Responses for auth errors: A failed Cognito JWT validation returns a 401 from API Gateway, not from Lambda. API Gateway error responses need their own CORS headers configured in aws_api_gateway_gateway_response — otherwise a 401 shows as a CORS error in the browser, hiding the actual authentication failure.


CI/CD Pipeline

Two GitHub Actions workflows:

ci.yml — lint, test, security scan on every push:

├── lint-and-test: tsc --noEmit, Jest with coverage, Codecov upload
├── security-scan: npm audit --audit-level=high, Checkov (Terraform IaC)
└── terraform-validate: fmt check, init, validate

terraform-plan.yml — posts plan output as PR comment when Terraform files change.

The OIDC permission requirement — the most common silent failure in new GitHub Actions OIDC setups:

permissions:
  id-token: write   # Without this, configure-aws-credentials fails silently
  contents: read

Without id-token: write, the action fails with "Credentials could not be loaded" — the error message does not mention the permissions block, making this hard to diagnose the first time.


Amplify Monorepo Configuration

The repository contains both backend/ and frontend/. Amplify's default build assumes the repository root is the project root. Setting appRoot: frontend in amplify.yml directs Amplify to build only the frontend subdirectory:

version: 1
applications:
  - frontend:
      phases:
        preBuild:
          commands: [npm ci]
        build:
          commands: [npm run build]
      artifacts:
        baseDirectory: dist
        files: ["**/*"]
      cache:
        paths: [node_modules/**/*]
    appRoot: frontend  # Without this, Amplify builds from repo root and fails

Production Considerations

DynamoDB capacity planning: PAY_PER_REQUEST is appropriate for unpredictable workloads. For production systems with predictable traffic patterns, provisioned capacity with auto-scaling provides lower per-request cost at volume.

Jaeger vs. X-Ray tracing: X-Ray integrates natively with Lambda, API Gateway, and DynamoDB with minimal configuration. For more complex microservice tracing requirements, OpenTelemetry provides richer instrumentation but requires explicit SDK setup in each function.

Single-table design limitations: This design requires upfront access pattern analysis. If you later need to query tasks by assignee AND by status (multi-condition queries), you need an additional GSI with a composite key. Design the GSI set before the first production write — adding GSIs after deployment does not backfill existing items to the new index projection.

Lambda function isolation: Each handler is a separate deployment package. This means a bug in the tasks-delete handler cannot crash the tasks-create handler — isolation at the function boundary is a core serverless reliability property.


Conclusion

The serverless architecture documented here provides a production-grade task management system with zero server management, automatic scaling to zero, and built-in high availability across AWS availability zones.

Key architectural decisions and their rationale:

  1. Single-table DynamoDB design — all access patterns served with single query() calls; requires upfront schema design
  2. Post-confirmation trigger idempotencyConditionExpression prevents role resets on subsequent auth events
  3. Streams → SNS → SES decoupling — write path has no email dependency; new notification channels are SNS subscribers
  4. esbuild + external AWS SDK — minimizes cold start bundle size without losing TypeScript type safety
  5. CORS at three layers — method, Lambda headers, and Gateway Responses for authorizer error cases
  6. OIDC for CI/CD AWS auth — no long-lived access keys in GitHub Secrets

References