Build a Zero-Server CRUD API with AWS Lambda Function URLs and DynamoDB

Cloud Provider: aws • Difficulty: intermediate Estimated Time: 60-120 minutes

What you’ll build

A small CRUD API on AWS with no servers to manage.

The API will:

  • expose an HTTPS endpoint using a Lambda Function URL
  • store and retrieve records in DynamoDB
  • support basic create, list, get, update, and delete operations
  • stay comfortably inside free-tier limits for learning and demo use

Why this recipe matters

This is one of the cleanest ways to build something real on AWS without getting dragged into instance management, networking complexity, or a giant bill.

It is also a good example of the FTL philosophy: build something useful, keep the moving parts small, and let the platform handle the boring infrastructure work.

Why this fits the free tier

AWS Lambda includes 1 million free requests per month and 400,000 GB-seconds of compute time in the free tier. DynamoDB also offers a free tier that AWS positions as enough for personal apps, prototypes, and learning workloads. citeturn36search2turn36search57

This recipe uses a Lambda Function URL instead of API Gateway so the deployment path stays simple and the resource count stays low. citeturn36search2

What you need before you start

Accounts and access

  • An AWS account
  • Access to the AWS Console
  • Permission to create Lambda, DynamoDB, IAM, and CloudWatch resources

Local software

  • Node.js 20 or newer
  • Git
  • A terminal
  • Optional: AWS CLI configured locally

Assumptions

This recipe assumes:

  • you are comfortable using the AWS Console
  • you want a lightweight backend API, not a full frontend stack
  • you are fine with a single Lambda handling multiple routes for the tutorial

High-level architecture

Client
  |
  | HTTPS
  v
Lambda Function URL
  |
  v
AWS Lambda function
  |
  v
DynamoDB table

Step 1 - Create the DynamoDB table

In the AWS Console, go to DynamoDB and create a new table.

Use these values:

  • Table name: ftl_items
  • Partition key: id
  • Key type: String

Keep the defaults otherwise.

For a simple learning workload, the defaults are fine.

Step 2 - Create the Lambda function

In the AWS Console, go to Lambda and create a new function.

Use these values:

  • Function name: ftl-crud-api
  • Runtime: Node.js 20.x
  • Architecture: x86_64 or arm64

Choose “Create function”.

Step 3 - Add the Lambda code

Replace the default function code with this:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  PutCommand,
  GetCommand,
  ScanCommand,
  DeleteCommand,
  UpdateCommand
} from "@aws-sdk/lib-dynamodb";
import crypto from "node:crypto";

const client = new DynamoDBClient({});
const ddb = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.TABLE_NAME;

export const handler = async (event) => {
  try {
    const method = event.requestContext?.http?.method || "GET";
    const path = event.rawPath || "/";
    const id = event.queryStringParameters?.id;

    if (method === "GET" && path === "/") {
      const result = await ddb.send(new ScanCommand({
        TableName: TABLE_NAME
      }));
      return json(200, result.Items || []);
    }

    if (method === "GET" && id) {
      const result = await ddb.send(new GetCommand({
        TableName: TABLE_NAME,
        Key: { id }
      }));

      if (!result.Item) {
        return json(404, { error: "Item not found" });
      }

      return json(200, result.Item);
    }

    if (method === "POST") {
      const body = JSON.parse(event.body || "{}");
      const item = {
        id: crypto.randomUUID(),
        title: body.title || "Untitled",
        provider: body.provider || "aws",
        createdAt: new Date().toISOString()
      };

      await ddb.send(new PutCommand({
        TableName: TABLE_NAME,
        Item: item
      }));

      return json(201, item);
    }

    if (method === "PUT" && id) {
      const body = JSON.parse(event.body || "{}");

      await ddb.send(new UpdateCommand({
        TableName: TABLE_NAME,
        Key: { id },
        UpdateExpression: "SET title = :title, provider = :provider",
        ExpressionAttributeValues: {
          ":title": body.title || "Untitled",
          ":provider": body.provider || "aws"
        },
        ReturnValues: "ALL_NEW"
      }));

      const updated = await ddb.send(new GetCommand({
        TableName: TABLE_NAME,
        Key: { id }
      }));

      return json(200, updated.Item);
    }

    if (method === "DELETE" && id) {
      await ddb.send(new DeleteCommand({
        TableName: TABLE_NAME,
        Key: { id }
      }));

      return json(200, { deleted: id });
    }

    return json(405, { error: "Method not allowed" });
  } catch (error) {
    console.error(error);
    return json(500, { error: "Internal server error" });
  }
};

function json(statusCode, body) {
  return {
    statusCode,
    headers: {
      "content-type": "application/json",
      "access-control-allow-origin": "*"
    },
    body: JSON.stringify(body)
  };
}

Deploy the function.

Step 4 - Add the environment variable

In the Lambda configuration, add this environment variable:

TABLE_NAME=ftl_items

Save the configuration.

Step 5 - Add IAM permissions for DynamoDB

Your Lambda function needs permission to access the DynamoDB table.

Go to the function’s execution role in IAM and attach a policy that allows:

  • dynamodb:GetItem
  • dynamodb:PutItem
  • dynamodb:Scan
  • dynamodb:UpdateItem
  • dynamodb:DeleteItem

A minimal inline policy looks like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:Scan",
        "dynamodb:UpdateItem",
        "dynamodb:DeleteItem"
      ],
      "Resource": "arn:aws:dynamodb:YOUR_REGION:YOUR_ACCOUNT_ID:table/ftl_items"
    }
  ]
}

Replace YOUR_REGION and YOUR_ACCOUNT_ID with your actual values.

Step 6 - Create the Lambda Function URL

In the Lambda console, open your function and create a Function URL.

Use these settings:

  • Auth type: NONE for this tutorial
  • CORS: enabled if you want to call it from frontend code later

Save it and copy the generated HTTPS URL.

That URL is your API endpoint.

Step 7 - Test listing all items

Call the URL with a GET request:

curl "YOUR_FUNCTION_URL"

At first, you should get an empty array.

Step 8 - Test creating an item

Create a record with POST:

curl -X POST "YOUR_FUNCTION_URL"   -H "content-type: application/json"   -d '{"title":"Hello from FTL","provider":"aws"}'

You should get a JSON object back with a generated id.

Step 9 - Test listing again

Run the GET request again:

curl "YOUR_FUNCTION_URL"

Now the created item should appear in the results.

Step 10 - Test getting a single item by id

Use the id returned from the POST request:

curl "YOUR_FUNCTION_URL?id=YOUR_ITEM_ID"

You should get only that item back.

Step 11 - Test updating an item

Update the stored values:

curl -X PUT "YOUR_FUNCTION_URL?id=YOUR_ITEM_ID"   -H "content-type: application/json"   -d '{"title":"Updated by FTL","provider":"aws-lambda"}'

You should get the updated record back.

Step 12 - Test deleting an item

Delete the record:

curl -X DELETE "YOUR_FUNCTION_URL?id=YOUR_ITEM_ID"

Then verify it is gone:

curl "YOUR_FUNCTION_URL"

Step 13 - Verify data in DynamoDB

Open the ftl_items table in DynamoDB and inspect the stored items.

This is a good sanity check that the API and table are wired correctly.

Step 14 - Review CloudWatch logs

If something fails, open CloudWatch Logs for the Lambda function.

That is where you will see:

  • runtime errors
  • permission errors
  • malformed JSON bodies
  • missing environment variables

Step 15 - Add basic CORS support if needed

If you want to call the API from a frontend later, make sure your function returns:

access-control-allow-origin: *

You may also want to add support for OPTIONS requests depending on how your frontend calls the API.

Step 16 - Understand the cost model

Lambda includes 1 million free requests and 400,000 GB-seconds in the free tier, and DynamoDB’s free tier is designed for hobby and prototype workloads. That makes this pattern a strong fit for demos, internal tools, or lightweight personal projects. citeturn36search2turn36search57

Step 17 - Lock down auth later if needed

For a public demo, Auth type: NONE is fine.

For anything more serious, you should eventually add one of these:

  • IAM-based access
  • custom JWT verification in Lambda
  • a frontend auth layer

Keep the first version simple. Lock it down when the use case requires it.

Step 18 - Optional improvements

Once the basic API works, consider adding:

  • pagination instead of Scan
  • validation of incoming payloads
  • route-specific path handling
  • timestamps for updates
  • a custom domain in front of the function URL

Step 19 - Validation checklist

  • Lambda function deploys successfully
  • Function URL responds publicly over HTTPS
  • POST creates an item
  • GET lists items
  • GET by id returns a single item
  • PUT updates an item
  • DELETE removes an item
  • DynamoDB shows the expected records
  • CloudWatch logs are clean or explain failures clearly

Step 20 - What to do next

Once this works, you have a real serverless backend pattern you can reuse.

Good follow-on ideas:

  1. connect a small frontend to this API
  2. add authentication
  3. add validation and error handling
  4. compare this AWS pattern to a similar Azure or GCP serverless recipe

Source links