By the end of this lab you will have a live, public REST API that creates, lists, reads and deletes notes, and you will not have launched, patched or paid for a single server.

When I teach cloud, the moment that clicks for most learners is not a slide about "no servers to manage." It is the first time they paste a URL into a terminal and get JSON back from code they wrote twenty minutes earlier. This post is built to get you to that moment.

We will use three AWS services that together form the classic serverless starter stack:
• Amazon DynamoDB stores the notes.
• AWS Lambda runs a small Python function that holds the logic.
• Amazon API Gateway gives that function a public HTTPS address.

Please Note : Each step ends with a checkpoint: a quick test that proves the step worked before you move on. If a checkpoint fails, stop and fix it there. Debugging one step is easy; debugging four stacked steps is miserable.

So, what exactly are we building ?

architecture

A request travels in a straight line: a client calls API Gateway, API Gateway invokes Lambda, and Lambda reads or writes DynamoDB. An IAM role sits beside Lambda and decides exactly what it is allowed to touch.

_Remember: _Lambda is the only piece holding your code; the other three are fully managed.
The API will expose four routes:

routes

Before you start:

  • An AWS account you can sign in to with permissions to create IAM roles, Lambda functions, DynamoDB tables and APIs.
  • One AWS Region picked and used for everything in this lab. Resources in different Regions cannot see each other, and this is the most common beginner mistake.
  • curl in a terminal, or any API client such as Postman.
  • Time Commitment: About 45 minutes.

Everything here uses the AWS Management Console, so you can follow along without installing anything. Once the concepts land, we will rebuild this with infrastructure as code later in the series

Step 1: Create the DynamoDB table

DynamoDB is a key-value and document database. You do not define columns up front; you only define the key that uniquely identifies each item.

  1. Open the DynamoDB console and choose Create table.
  2. Table name: Notes.
  3. Partition key: noteId, type String.
  4. Leave the sort key empty.
  5. Under Table settings, choose Customize settings, then set capacity mode to On-demand. On-demand bills per request, so an idle lab table costs next to nothing.
  6. Choose Create table and wait for the status to show Active. Why a random string ID instead of 1, 2, 3? DynamoDB spreads data across partitions using the partition key. Random, high-variety keys spread load evenly, while sequential numbers are a habit carried over from relational databases that does not help here. Checkpoint 1 Open the table, choose Explore table items, then Create item. Add noteId = test-1 and a new String attribute text = hello dynamo. Save it.
  • Pass: the item appears in the list.
  • Fail: the table is still in Creating status, or you are looking at a different Region than the one you chose.

Delete test-1 once you see it. You will create real notes through the API shortly.

Step 2: Write the Lambda function

Lambda runs your code only when something invokes it. One function will handle all four routes by checking which route triggered it.
2a. Create the function

  1. Open the Lambda console and choose Create function.
  2. Choose Author from scratch. Name it notes-api.
  3. Runtime: the latest Python 3.x version offered.
  4. Under Permissions, keep Create a new role with basic Lambda permissions. This role can write logs to CloudWatch, but it cannot touch DynamoDB yet.
  5. Choose Create function. 2b. Add the code Replace everything in lambda_function.py with this, then choose Deploy:
import json import os import uuid from datetime import datetime, timezone import boto3 TABLE_NAME = os.environ["TABLE_NAME"] table = boto3.resource("dynamodb").Table(TABLE_NAME) def response(status, body): return { "statusCode": status, "headers": {"Content-Type": "application/json"}, "body": json.dumps(body), } def lambda_handler(event, context): route = event.get("routeKey") if route == "POST /notes": data = json.loads(event.get("body") or "{}") if not data.get("text"): return response(400, {"error": "text is required"}) item = { "noteId": str(uuid.uuid4()), "text": data["text"], "createdAt": datetime.now(timezone.utc).isoformat(), } table.put_item(Item=item) return response(201, item) if route == "GET /notes": items = table.scan().get("Items", []) return response(200, items) if route == "GET /notes/{id}": note_id = event["pathParameters"]["id"] item = table.get_item(Key={"noteId": note_id}).get("Item") if not item: return response(404, {"error": "note not found"}) return response(200, item) if route == "DELETE /notes/{id}": note_id = event["pathParameters"]["id"] table.delete_item(Key={"noteId": note_id}) return response(200, {"deleted": note_id}) return response(404, {"error": f"unsupported route: {route}"}) 

Three details worth noticing:

• The table client is created outside the handler. Lambda reuses the same environment across warm invocations, so the connection is set up once, not on every request.
• The table name comes from an environment variable, not hard-coded. The same code can then point at a dev or prod table.
• scan reads the whole table. It is fine for a lab, but it gets slow and expensive as data grows. We will replace it with a proper query pattern in a later post.
2c. Set the environment variable
Go to Configuration → Environment variables → Edit and add TABLE_NAME = Notes.
**_2d. Give the function permission to use the table
_**
Go to Configuration → Permissions and select the role name to open it in IAM. Choose Add permissions → Create inline policy, switch to the JSON editor and paste the following, replacing the Region and account ID with yours:

{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:Scan", "dynamodb:DeleteItem" ], "Resource": "arn:aws:dynamodb:REGION:ACCOUNT_ID:table/Notes" } ] } 

Name it notes-table-access and create it. Notice we granted four actions on one table, not dynamodb:* on everything. That is least privilege, and it is a habit worth building from your very first function.

Checkpoint 2

Back in Lambda, open the Test tab and create a test event named create-note with this JSON:

{ "routeKey": "POST /notes", "body": "{\"text\": \"my first serverless note\"}" } 

Choose Test.
• Pass: status code 201 and a body containing a generated noteId. The note also appears in the DynamoDB table.
• Fail with AccessDeniedException: the inline policy is missing, or the table ARN has the wrong Region or account ID.
• Fail with KeyError: 'TABLE_NAME': the environment variable was not saved.

## Step 3: Put API Gateway in front

Right now only you can invoke the function, from inside the console. API Gateway gives it a public HTTPS URL and turns each incoming request into the event your code reads.

We will use an HTTP API rather than a REST API. HTTP APIs are simpler, cheaper and faster for straightforward Lambda backends like this one. The routeKey field our code checks is part of the event format HTTP APIs send.

  1. Open the API Gateway console, choose Create API, and under HTTP API choose Build.
  2. Under Integrations, add Lambda and select notes-api. Name the API notes-http-api. Choose Next.
  3. Configure the routes. Add all four, each pointing at the notes-api integration: ◦ POST /notes ◦ GET /notes ◦ GET /notes/{id} ◦ DELETE /notes/{id}
  4. Choose Next. Keep the $default stage with Auto-deploy on.
  5. Review and choose Create. The {id} in curly braces is a path parameter. Whatever the caller puts in that position arrives in your code as event["pathParameters"]["id"]. When you attach the integration through the console, API Gateway also adds a resource-based policy to the Lambda function that allows the API to invoke it. You can see it under the function's Configuration → Permissions → Resource-based policy statements.

Checkpoint 3

On the API's details page, copy the Invoke URL. It looks like https://abc123.execute-api.REGION.amazonaws.com. Paste it into a browser with /notes on the end.
• Pass: you see a JSON array containing the note you created in Checkpoint 2.
• Fail with {"message":"Not Found"}: the path does not match a route. Check spelling and that you added /notes.
• Fail with {"message":"Internal Server Error"}: the request reached Lambda and the code failed. Jump to troubleshooting below.

Step 4: Test every route end to end

A browser can only send GET requests easily, so switch to the terminal. Save your URL in a variable first:

API="https://abc123.execute-api.REGION.amazonaws.com" 

Create two notes:

curl -X POST "$API/notes" \ -H "Content-Type: application/json" \ -d '{"text": "Buy milk"}' curl -X POST "$API/notes" \ -H "Content-Type: application/json" \ -d '{"text": "Finish the serverless lab"}' 

List them, then copy one noteId from the output:
curl "$API/notes"
Fetch and delete that note, then confirm it is gone:

NOTE_ID="paste-a-noteId-here" curl "$API/notes/$NOTE_ID" curl -X DELETE "$API/notes/$NOTE_ID" curl "$API/notes/$NOTE_ID" 

Finally, test the validation path by sending an empty note:

curl -X POST "$API/notes" -H "Content-Type: application/json" -d '{}' 

Checkpoint 4
• Pass: both POSTs return 201, the list shows your notes, the first GET by ID returns the note, DELETE returns {"deleted": ...}, the last GET returns 404, and the empty POST returns 400 with text is required.
If all of that works, congratulations. You have built and deployed a serverless API, and you never chose an instance type, opened an SSH session or installed a web server.

When something breaks
Your best friend is CloudWatch Logs. In the Lambda console, open Monitor → View CloudWatch logs, then the newest log stream. Every Python traceback lands there.

Troubleshoot

What this costs
At lab scale, very little. All three services bill per request rather than per hour, and AWS offers free usage for Lambda and DynamoDB at low volumes. Free tier terms have changed for newer accounts, so check the current pricing pages and set an AWS Budgets alert before you build anything bigger. The real lesson is the pricing model: when nobody calls your API, you are not paying for idle servers.
Clean up
Delete resources in this order so nothing is left orphaned:

  1. The notes-http-api API in API Gateway.
  2. The notes-api function in Lambda.
  3. The Notes table in DynamoDB.
  4. The function's IAM role (its name starts with notes-api-role).
  5. The /aws/lambda/notes-api log group in CloudWatch.

What's next

You now know the core serverless loop: an event arrives, a function runs, state lives in a managed service. In the next posts we will build on this exact API: replacing scan with efficient queries, adding authentication so not just anyone can delete your notes, and defining the whole stack as code so you can deploy it with one command.
If you get stuck at any checkpoint, drop a comment with the checkpoint number and the exact error message. That is usually all it takes to find the problem.