Build a Bookmark Manager REST API

Build a type-safe REST API with Express 5, TypeScript, SQLite, and middleware using professional Git workflow.

Introduction

30 Second Summary

Every time you save a bookmark in your browser, something stores that URL, gives it a name, and finds it again later. Behind the scenes, that's a server receiving your request, talking to a database, and sending back a response.

In this project, you will build a Bookmark Manager API from scratch using TypeScript, Node.js, and Express. You will persist data in a SQLite database, add typed middleware for logging, validation, and errors, and ship it via a professional Git workflow. This is Part 1 of a 5-project backend engineering series.

What You'll Build

You will fire off curl commands, watch each HTTP request pass through typed middleware, hit a route handler with full IntelliSense, talk to a SQLite database, and return structured JSON responses with correct status codes.

By the end of this project, you'll have:

  • A type-safe REST API with full CRUD operations where you create, read, update, and delete bookmarks from the terminal and watch each request flow through your server.
  • A middleware layer with typed request handlers that validates input, logs every request with timing, and returns structured error responses instead of crashes.
  • A clean Git history with conventional commits, a feature branch, and a merged pull request that demonstrates the workflow used at production companies.
  • Secret Mission: Add search and sorting via query parameters with SQL injection prevention.

Are there any prerequisites?

You should be comfortable with basic JavaScript or TypeScript (variables, functions, arrays, and objects) and familiar with running terminal commands. No backend, database, or authentication experience is needed.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Project

Every backend project starts with two things: a working server and a Git history that tracks how it was built. Your GitHub repo will tell the story of this API from the very first file.

In this step, you'll create your project folder, initialize Git, install Express, better-sqlite3, and TypeScript with strict type checking, then create a minimal typed server that responds to requests.

In this step, get ready to:
  • Initialize a Git-tracked Node.js project with Express, better-sqlite3, TypeScript, and ESLint.
  • Configure a strict tsconfig.json and create a type-checked Express server.
  • Push your project to GitHub with two meaningful commits.
Create the project, Git repo, and install dependencies
  • Move to your Desktop and create your project folder by running these commands:
cd ~/Desktop
mkdir bookmark-api
cd bookmark-api
  • Initialize a Git repository, set up npm, and install your dependencies by running:
git init
npm init -y
npm install express@5.2.1 better-sqlite3@12.11.1
npm install --save-dev typescript@5.8 @types/express @types/node @types/better-sqlite3

You should see output ending with something like added 6 packages. This confirms Express, better-sqlite3, TypeScript, and all type definitions are installed.

Seeing a compilation error from better-sqlite3?

Since Node.js v23 no longer has prebuilt binaries for better-sqlite3, the package compiles from source. This requires Xcode Command Line Tools.

If you see a build error, confirm Xcode Command Line Tools are installed by running:

xcode-select --install

If they are already installed, you will see a message saying so. Then retry npm install better-sqlite3@12.11.1.

Help me fix this compilation error

Now you need three config files: a .gitignore to keep generated files out of Git, a tsconfig.json for TypeScript strict mode, and an updated package.json with TypeScript scripts.

  • In your editor, create a new file called .gitignore in the bookmark-api folder with this content:
node_modules/
bookmarks.db

What does .gitignore do?

Any file or folder listed here becomes invisible to Git. You are ignoring node_modules/ because it is large and reproducible (anyone can regenerate it with npm install).

You are ignoring bookmarks.db because the database file contains machine-specific test data that would create noisy diffs.

  • Create a new file called tsconfig.json in the bookmark-api root folder with this content:
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true
  }
}

What does tsconfig.json do?

This file tells TypeScript how to check your code. The key settings:

  • "strict": true enables all strict type-checking options including strictNullChecks and noImplicitAny.
  • "noEmit": true means TypeScript only checks your code. Node.js runs it directly by stripping types at runtime.
  • "module": "nodenext" tells TypeScript you are using Node.js ESM resolution, matching the "type": "module" in your package.json.
  • Save tsconfig.json.
  • Save .gitignore.
  • Open package.json and replace its entire contents with:
{
  "name": "bookmark-api",
  "type": "module",
  "scripts": {
    "start": "node src/index.ts",
    "dev": "node --watch src/index.ts",
    "typecheck": "npx tsc"
  },
  "dependencies": {
    "express": "5.2.1",
    "better-sqlite3": "12.11.1"
  },
  "devDependencies": {
    "typescript": "5.8",
    "@types/express": "*",
    "@types/node": "*",
    "@types/better-sqlite3": "*"
  }
}

What does this configure?

  • "type": "module" tells Node.js to use ES module import/export syntax.
  • The dev script uses node --watch src/index.ts to run your TypeScript file directly. Node.js strips the types at runtime and restarts on every save.
  • The typecheck script runs npx tsc to check for type errors without emitting files. Node runs the code but does not type-check it, so this script catches mistakes the runtime would miss.
  • Save package.json.
  • Make your first commit by running:
git add .
git commit -m "chore: initialize project with express and better-sqlite3"

You should see output showing files changed and the commit message confirmed.

What is a conventional commit?

The chore: prefix is part of the conventional commits standard. It signals that this commit handles project setup rather than adding a user-facing feature.

Other prefixes you will use in this project include feat: for new features. Hiring managers scanning your GitHub profile instantly recognize this pattern as professional-level Git usage.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both .gitignore and package.json.

ⓧ I'd like to double check the full code

Here is what .gitignore should look like:

node_modules/
bookmarks.db

Here is what package.json should look like:

{
  "name": "bookmark-api",
  "type": "module",
  "scripts": {
    "start": "node src/index.ts",
    "dev": "node --watch src/index.ts",
    "typecheck": "npx tsc"
  },
  "dependencies": {
    "express": "5.2.1",
    "better-sqlite3": "12.11.1"
  },
  "devDependencies": {
    "typescript": "5.8",
    "@types/express": "*",
    "@types/node": "*",
    "@types/better-sqlite3": "*"
  }
}
Create the entry point and push to GitHub

Your project needs an entry point: the file Node.js runs when you start the server. This file will create an Express app and listen for incoming requests.

  • Create a src folder inside bookmark-api by running:
mkdir src
  • In your editor, create a new file called index.ts inside the src folder with this content:
import express, { type Request, type Response } from 'express';

const app = express();
const port: number = 3000;

// Health check endpoint
app.get('/', (req: Request, res: Response) => {
  res.json({ message: 'Bookmark API is running' });
});

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

What does this code do?

  • import express, { type Request, type Response } loads Express and imports the Request and Response types. The type keyword tells TypeScript these are type-only imports that get stripped at runtime.
  • const port: number = 3000 annotates the port as a number. TypeScript would infer this, but the explicit type communicates intent.
  • (req: Request, res: Response) gives you full IntelliSense on both objects. Your editor will autocomplete methods like res.json() and flag typos.
  • Save src/index.ts.
  • Commit this new file by running:
git add .
git commit -m "feat: add express server with health check endpoint"

You should see output confirming 1 file changed.

Now push both commits to GitHub. You need a GitHub repository to push to.

  • Go to github.com and create a new repository.
  • Set the repository name to bookmark-api.
  • Leave it set to Public.
  • Do NOT check any of the initialization options (no README, no .gitignore, no license).
  • Click Create repository.

Why skip the initialization options?

You already created a Git repo locally with its own commits. Adding a README or .gitignore on GitHub would create a conflicting initial commit that blocks your push.

  • Back in your terminal, connect your local repo to GitHub and push by running:
git branch -M main
git remote add origin https://github.com/[[YOUR_USERNAME="your GitHub username"]]/bookmark-api.git
git push -u origin main

You should see output showing your commits being pushed, ending with a line like main -> main.

Push rejected or authentication error?

If you see a permission denied or authentication error, GitHub needs you to authenticate. Make sure you are signed in via git credential manager or an SSH key linked to your GitHub account.

If you see "remote already exists", you may have already run the git remote add command. Run git remote -v to check, then try git push -u origin main again.

Help me fix Git authentication

✔️ Awesome, I've got everything!

Great. Confirm you have saved src/index.ts and your push succeeded.

ⓧ I'd like to double check the full code

Here is what src/index.ts should look like:

import express, { type Request, type Response } from 'express';

const app = express();
const port: number = 3000;

// Health check endpoint
app.get('/', (req: Request, res: Response) => {
  res.json({ message: 'Bookmark API is running' });
});

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});
Verify the server runs

Everything is installed, committed, and pushed. Time to see your server respond to a real request.

  • Start your server in watch mode by running:
npm run dev

You should see this output in your terminal:

Bookmark API running at http://localhost:3000

What is watch mode?

The --watch flag tells Node.js to monitor your files. Whenever you save a change, the server restarts automatically. No need to stop and start it manually each time.

Your server is now running and occupying this terminal window. You need a second terminal to send it a request.

  • Open a new terminal window or tab (Cmd+T on macOS or Ctrl+Shift+T on Windows/Linux).

Before you run the next command, what do you expect to get back from your server?

  • Send a request to your server by running:
curl http://localhost:3000

You should see:

{"message":"Bookmark API is running"}

Your Express server received an HTTP GET request and responded with JSON. That is the request/response cycle in action.

  • Verify your Git history by running:
git log --oneline

You should see two commits listed, one for each meaningful change you made.

Don't see the expected JSON response?

  • Make sure your server is still running in the first terminal window. Look for the "Bookmark API running" message.
  • Check that src/index.ts uses port 3000 and the curl command targets localhost:3000 (not a different port).
  • If you see "Connection refused", the server may have crashed. Switch to the first terminal to check for error messages.

Help me debug my Express server

Your environment is ready and your server responds to requests. Next up, you will build real API endpoints that create and retrieve bookmarks.

Build Your First API Endpoints

Your server is running, but it only responds to a single health check at the root path. To build an actual bookmark manager, you need routes that can receive data from the outside world and send it back.

In this step, you'll create your first real API endpoints that handle HTTP requests. A POST request will let you save a bookmark, and a GET request will let you retrieve all bookmarks you've saved.

In this step, get ready to:
  • Create a bookmarks router with GET and POST routes.
  • Mount the router in your Express app and enable JSON parsing.
  • Test your endpoints with curl and commit to Git.
Create a bookmarks router with GET and POST

Express lets you organize related routes into a Router module. Instead of putting every route inside src/index.ts, you'll create a separate file that handles all bookmark-related requests.

  • Create the routes directory by running this command in your terminal:
mkdir src/routes
  • Confirm the directory was created by running:
ls src

You should see both index.ts and routes listed.

  • Create a new file called src/routes/bookmarks.ts in your code editor and paste this code:
import express, { type Request, type Response } from 'express';

// Define the shape of a bookmark object
interface Bookmark {
  id: number;
  url: string;
  title: string;
  description: string;
}

// Create a router to handle bookmark-related routes
const router = express.Router();
// In-memory storage for bookmarks
const bookmarks: Bookmark[] = [];
let nextId: number = 1;

// GET / returns all bookmarks
router.get('/', (req: Request, res: Response) => {
  res.json(bookmarks);
});

// POST / creates a new bookmark from the request body
router.post('/', (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: Bookmark = { id: nextId++, url, title, description: description || '' };
  bookmarks.push(bookmark);
  res.status(201).json(bookmark);
});

export default router;

What does this code do?

This file sets up a mini-app (a Router) that knows how to handle two types of requests:

  • interface Bookmark defines the exact shape of a bookmark object. TypeScript will catch you if you try to assign the wrong type to any field.
  • const bookmarks: Bookmark[] declares a typed array. TypeScript knows every item in this array must be a full Bookmark object.
  • GET / responds with the full array of bookmarks as JSON.
  • POST / uses req.body as { ... } to assert the shape of the incoming data. It assigns an auto-incrementing ID and responds with status 201 (Created).
  • Save src/routes/bookmarks.ts.

Why status 201 instead of 200?

HTTP status codes tell the client exactly what happened. 200 means "OK, here's what you asked for." 201 means "OK, and I created something new for you." Using the right code makes your API predictable for anyone consuming it.

Mount the router and enable JSON parsing

Your router exists, but your Express app doesn't know about it yet. You need to tell the app to send any requests starting with /bookmarks to your new router.

  • In src/index.ts, replace the entire file contents with this updated code:
import express, { type Request, type Response } from 'express';
import bookmarksRouter from './routes/bookmarks.ts';

const app = express();
const port: number = 3000;

// Parse incoming JSON request bodies
app.use(express.json());

// Mount the bookmarks router at /bookmarks
app.use('/bookmarks', bookmarksRouter);

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

What changed here?

  • app.use(express.json()) is middleware that parses incoming JSON request bodies. Without it, req.body would be undefined when someone POSTs data.
  • app.use('/bookmarks', bookmarksRouter) mounts your router. Any request to /bookmarks now gets handled by the routes in bookmarks.ts.
  • The old health check route (GET /) is removed. Your API now speaks through /bookmarks instead.
  • Save src/index.ts.

Your terminal running npm run dev should show the server restarting automatically thanks to --watch. You should see the "Bookmark API running" message print again.

Server crashed or shows an error?

  • Check that the import path is exactly './routes/bookmarks.ts' with the .ts extension. Node.js with rewriteRelativeImportExtensions in your tsconfig handles this.
  • Confirm your file is saved at src/routes/bookmarks.ts (not routes/bookmarks.ts at the project root).
  • Make sure bookmarks.ts has export default router at the bottom.

Still stuck? Why is my Express server crashing after adding a router import?

Test your endpoints and commit

Your API is wired up. Time to send real HTTP requests and see the request/response cycle in action.

  • Open a second terminal window (keep the server running in the first one).
  • Before you run the next command, predict: what do you think the response will look like when you POST a bookmark with a URL and title?
  • Create your first bookmark by running this command:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON response with your bookmark, including a generated id of 1:

{"id":1,"url":"https://expressjs.com","title":"Express Docs","description":""}
  • Now retrieve all bookmarks by running:
curl http://localhost:3000/bookmarks

You should see an array containing the bookmark you just created.

What just happened?

You just completed a full request/response cycle. Your POST request sent JSON data to the server, the route handler parsed it, assigned an ID, stored it in the array, and sent back a 201 response. Your GET request then retrieved that stored data and returned it as a 200 response.

🙋‍♀️ Getting an empty object or undefined fields?

  • Make sure you included app.use(express.json()) in src/index.ts. Without it, the server cannot read the JSON body you sent.
  • Check that your curl command includes -H "Content-Type: application/json" so the server knows the body is JSON.

Need more help? Why is req.body undefined in my Express POST route?

  • Commit and push your work by running these commands:
git add .
git commit -m "feat: add bookmarks router with GET and POST endpoints"
git push
  • Verify your commit history by running:
git log --oneline

You should see three commits. Your newest one says feat: add bookmarks router with GET and POST endpoints at the top.

What is a conventional commit?

The feat: prefix follows a pattern called conventional commits. It tells anyone reading the Git history that this commit adds a new feature. Other prefixes like fix: and chore: signal bug fixes and housekeeping.

✔️ Awesome, I've got everything!

Great. Double check both files are saved and your git log shows the new commit.

ⓧ I'd like to double check the full code

Here is what each file should look like at this point:

src/routes/bookmarks.ts

import express, { type Request, type Response } from 'express';

interface Bookmark {
  id: number;
  url: string;
  title: string;
  description: string;
}

const router = express.Router();
const bookmarks: Bookmark[] = [];
let nextId: number = 1;

router.get('/', (req: Request, res: Response) => {
  res.json(bookmarks);
});

router.post('/', (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: Bookmark = { id: nextId++, url, title, description: description || '' };
  bookmarks.push(bookmark);
  res.status(201).json(bookmark);
});

export default router;

src/index.ts

import express, { type Request, type Response } from 'express';
import bookmarksRouter from './routes/bookmarks.ts';

const app = express();
const port: number = 3000;

app.use(express.json());

app.use('/bookmarks', bookmarksRouter);

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

You now have a working API that can create and retrieve bookmarks. Next up, you'll add update and delete operations to complete the full set of CRUD actions. But there's a catch waiting for you at the end.

Complete CRUD and Hit the Wall

Your API can already create and list bookmarks. A real bookmark manager also needs to fetch a single bookmark, update it, and delete it.

In this step, you will add the remaining three CRUD operations. But once all four are working, you will discover a critical flaw: every bookmark you save vanishes the instant your server stops running.

In this step, get ready to:
  • Add GET, PUT, and DELETE routes that target a single bookmark by ID.
  • Test the full request/response cycle for each HTTP method.
  • Witness your data disappear on a server restart.
Add GET /:id for a single bookmark

Right now, GET /bookmarks returns every bookmark at once. You need a way to fetch just one by its ID. Route parameters let you capture a value from the URL path and use it inside your handler.

  • In src/routes/bookmarks.ts, find the existing router.get('/', ...) handler and add the following route directly below it (above the router.post block):
// Find a single bookmark by its ID from the URL
router.get('/:id', (req: Request, res: Response) => {
  const bookmark = bookmarks.find(b => b.id === parseInt(req.params.id));
  if (!bookmark) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(bookmark);
});

What does this code do?

The /:id in the route path tells Express to capture whatever appears after /bookmarks/ and store it in req.params.id.

Because the URL is always a string, you use parseInt() to convert it to a number so it matches the numeric id in your bookmarks array. If no match is found, you return a 404 status with a clear error message.

  • Save src/routes/bookmarks.ts.
  • Your server restarts automatically thanks to --watch. First, create a test bookmark by running this in your second terminal:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'
  • Now fetch that bookmark by its ID:
curl http://localhost:3000/bookmarks/1

You should see the single bookmark object returned with id: 1, the URL and title you just created.

Getting an empty response or error?

If you see Cannot GET /bookmarks/1, check that your new route uses router.get (not app.get). If you get null, the --watch restart cleared your in-memory data. Run the POST command again first, then GET by ID.

help me debug my GET /:id route

Add PUT /:id and DELETE /:id

With read covered, you need update and delete to complete the four CRUD operations. PUT replaces a bookmark's data. DELETE removes it entirely.

  • In src/routes/bookmarks.ts, add the following PUT route below the router.post block:
// Update a bookmark's fields by ID
router.put('/:id', (req: Request, res: Response) => {
  const index = bookmarks.findIndex(b => b.id === parseInt(req.params.id));
  if (index === -1) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  bookmarks[index] = { ...bookmarks[index], url, title, description: description || '' };
  res.json(bookmarks[index]);
});

What does this code do?

PUT uses findIndex instead of find because you need the position in the array to replace the object. The spread operator ...bookmarks[index] keeps the original id intact while overwriting url, title, and description with new values from the request body.

  • Save the file, create a test bookmark, then update it:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'
curl -X PUT http://localhost:3000/bookmarks/1 -H "Content-Type: application/json" -d '{"url":"https://expressjs.com/5x","title":"Express 5 Docs","description":"Official Express 5 guide"}'

You should see the updated bookmark returned with the new title and description.

  • Now add the DELETE route below the PUT block you just wrote:
// Remove a bookmark by ID
router.delete('/:id', (req: Request, res: Response) => {
  const index = bookmarks.findIndex(b => b.id === parseInt(req.params.id));
  if (index === -1) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  bookmarks.splice(index, 1);
  res.status(204).end();
});

Why 204 and not 200?

HTTP 204 No Content means the action succeeded but there is nothing to send back. The bookmark is gone, so returning an empty response with 204 is the REST convention for successful deletions.

  • Save the file, create a bookmark, then delete it:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"url":"https://nodejs.org","title":"Node.js"}'
curl -X DELETE http://localhost:3000/bookmarks/1
  • Confirm it is gone by listing all bookmarks:
curl http://localhost:3000/bookmarks

You should see an empty array []. The bookmark was successfully deleted.

Still seeing the bookmark after DELETE?

Make sure you used -X DELETE in the curl command. Without the -X flag, curl defaults to GET. Also confirm you saved the file so --watch restarted the server with your new route.

help me debug my DELETE route

Experience the data loss

All four CRUD operations work. Time to put them through a real test. Create a few bookmarks and then do something you would do every day in development: restart your server.

  • Create two bookmarks by running these commands:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"url":"https://nodejs.org","title":"Node.js Home"}'
  • Confirm both exist by running:
curl http://localhost:3000/bookmarks

You should see an array with two bookmarks.

  • Now switch to the terminal window where your server is running.
  • Stop the server by pressing Ctrl+C.
  • Restart it by running:
npm run dev

Before you test, predict what you think will happen. Will your two bookmarks still be there when you run GET /bookmarks?

  • Switch back to your second terminal and run:
curl http://localhost:3000/bookmarks

The response is []. An empty array. Everything you saved is gone.

Why did the data disappear?

Your bookmarks lived in a TypeScript typed array, which only exists in your server's memory while the process is running. The moment the process stops, that memory is released and the array ceases to exist.

This is exactly why databases exist. They write data to disk so it survives process restarts, crashes, and even machine reboots. The next step introduces SQLite to solve this problem permanently.

You might have noticed this happening already. Every time you saved a file, --watch restarted the server and silently wiped your test data.

  • Your code works correctly even though the persistence problem is unsolved. Commit what you have by running these commands:
git add .
git commit -m "feat: add GET by id, PUT, and DELETE endpoints"
git push
  • Confirm the commit landed by running:
git log --oneline

You should see three commits total. Your latest reads feat: add GET by id, PUT, and DELETE endpoints.

Git push rejected?

If you see a rejection message, make sure you pulled the latest from main before pushing. Run git pull --rebase origin main and then try git push again.

help me fix my git push error

✔️ Awesome, I've got everything!

Great. Double check you have saved src/routes/bookmarks.ts and that your commit appears in git log.

ⓧ I'd like to double check the full code

Your src/routes/bookmarks.ts should contain these blocks in order. Scroll up and confirm each one is present:

  • The import express line and the router, bookmarks, and nextId declarations.
  • GET / handler that returns all bookmarks.
  • GET /:id handler with bookmarks.find and a 404 check.
  • POST / handler that creates a bookmark and returns 201.
  • PUT /:id handler with bookmarks.findIndex, a 404 check, and spread operator update.
  • DELETE /:id handler with bookmarks.splice and 204 response.
  • The export default router at the bottom.

Your src/index.ts and package.json remain unchanged from the previous step.

Your API handles all four CRUD operations. But it has a fatal flaw: data lives only in memory. Next up, you will plug in a real database so your bookmarks survive restarts, crashes, and anything else that kills the process.

Persist Data with SQLite

You proved the problem in the last step: every bookmark you saved disappeared the moment the server restarted. That empty array was your in-memory storage admitting defeat.

A database stores data on disk, so it survives restarts, crashes, and even deployments. In this step, you will replace the in-memory array with SQLite, a file-based database that lives right inside your project folder.

In this step, get ready to:
  • Create a database module that sets up a bookmarks table.
  • Rewrite every route to use SQL queries instead of the in-memory array.
  • Prove that your data survives a server restart.
Create the database module

Your database layer is a single file that opens the database, creates the table if it does not exist, and exports the connection for your routes to use.

  • Create a new folder and file by running these commands:
mkdir src/db
touch src/db/index.ts
  • Confirm the file exists by running:
ls src/db

You should see index.ts in the output.

  • Open src/db/index.ts in your editor and paste the following code:
import Database from 'better-sqlite3';

// Define the shape of a bookmark row from the database
export interface BookmarkRow {
  id: number;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Open (or create) the database file
const db = new Database('bookmarks.db');

// WAL mode improves performance for concurrent reads
db.pragma('journal_mode = WAL');

// Create the bookmarks table if it doesn't exist yet
db.exec(`
  CREATE TABLE IF NOT EXISTS bookmarks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    url TEXT NOT NULL,
    title TEXT NOT NULL,
    description TEXT DEFAULT '',
    created_at TEXT DEFAULT (datetime('now'))
  )
`);

export default db;

What does this code do?

This module does three things every time your server starts:

  • new Database('bookmarks.db') opens (or creates) a file called bookmarks.db in your project root. This is your entire database stored as a single file.
  • db.pragma('journal_mode = WAL') enables Write-Ahead Logging, which makes reads faster when writes are happening at the same time.
  • db.exec(...) creates the bookmarks table with columns for id, url, title, description, and created_at. The IF NOT EXISTS clause means it only creates the table the first time.
  • Save src/db/index.ts.

Seeing a compilation error on save?

If better-sqlite3 fails to load, it may need to compile native bindings. Make sure you have Xcode Command Line Tools installed. If you see a build error, run npm rebuild better-sqlite3 and try again.

Help me fix native module compilation issues

Rewrite routes to use the database

Now you will replace every reference to the in-memory array with SQL queries that talk to your new database.

  • Open src/routes/bookmarks.ts in your editor.
  • Select all the existing code and delete it.
  • Paste the following code, which sets up the imports and the two GET routes:
import express, { type Request, type Response } from 'express';
import db, { type BookmarkRow } from '../db/index.ts';

const router = express.Router();

// Return all bookmarks, newest first
router.get('/', (req: Request, res: Response) => {
  const bookmarks = db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC').all() as BookmarkRow[];
  res.json(bookmarks);
});

// Return a single bookmark by ID
router.get('/:id', (req: Request, res: Response) => {
  const bookmark = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(req.params.id) as BookmarkRow | undefined;
  if (!bookmark) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(bookmark);
});

What changed from the array version?

Instead of bookmarks.find(), you now use db.prepare() to write a SQL query. The ? in WHERE id = ? is a parameter placeholder. better-sqlite3 substitutes req.params.id safely, preventing SQL injection.

.all() returns an array of all matching rows. .get() returns a single row or undefined if nothing matches.

  • Save the file and test that the GET route works by running this in your other terminal window:
curl http://localhost:3000/bookmarks

You should see [] (an empty array). The database exists but has no bookmarks yet.

  • Add the POST route below the GET /:id route in src/routes/bookmarks.ts:
// Create a new bookmark and return the full row
router.post('/', (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const result = db.prepare(
    'INSERT INTO bookmarks (url, title, description) VALUES (?, ?, ?)'
  ).run(url, title, description || '');

  // Read back the newly created row using its auto-generated ID
  const bookmark = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(result.lastInsertRowid) as BookmarkRow;
  res.status(201).json(bookmark);
});

Why read the row back after inserting?

.run() executes the INSERT and returns metadata including result.lastInsertRowid. You then SELECT that row to get the full bookmark (with its auto-generated id and created_at values) to return to the client.

  • Save the file and create a bookmark to verify the POST route works:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark returned with an id, created_at timestamp, and the data you sent.

  • Add the PUT and DELETE routes below the POST route, then add the export at the very end of the file:
// Update a bookmark's fields
router.put('/:id', (req: Request, res: Response) => {
  const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(req.params.id) as BookmarkRow | undefined;
  if (!existing) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }

  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  db.prepare(
    'UPDATE bookmarks SET url = ?, title = ?, description = ? WHERE id = ?'
  ).run(url, title, description || '', req.params.id);

  // Return the updated row
  const bookmark = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(req.params.id) as BookmarkRow;
  res.json(bookmark);
});

// Remove a bookmark
router.delete('/:id', (req: Request, res: Response) => {
  const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(req.params.id) as BookmarkRow | undefined;
  if (!existing) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }

  db.prepare('DELETE FROM bookmarks WHERE id = ?').run(req.params.id);
  res.status(204).end();
});

export default router;

What does this code do?

  • PUT first checks if the bookmark exists. If it does, it updates all fields and returns the refreshed row. If not, it returns 404.
  • DELETE also checks existence first, then removes the row and returns 204 (no content) to signal success without a response body.
  • Both routes use ? placeholders for every user-provided value, keeping queries safe from injection.
  • Save src/routes/bookmarks.ts.

Server crashing on save?

Check that your file starts with import express from 'express'; and import db from '../db/index.ts'; at the very top. A missing import will cause a reference error. Also confirm you deleted the old const bookmarks: Bookmark[] = [] and let nextId: number = 1 lines completely.

Help me debug my routes file

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/db/index.ts and src/routes/bookmarks.ts.

ⓧ I'd like to double check the full code

Your src/routes/bookmarks.ts should contain these sections in this order. Scroll up and confirm each chunk is present:

  • The express and db imports, plus const router = express.Router().
  • GET / route returning all bookmarks ordered by created_at DESC.
  • GET /:id route returning one bookmark or 404.
  • POST / route inserting a row and reading it back via lastInsertRowid.
  • PUT /:id route checking existence, updating, and returning the refreshed row.
  • DELETE /:id route checking existence, deleting, and returning 204.
  • export default router at the bottom.

Your src/db/index.ts should match the database module code from the first substep above exactly.

Test persistence and commit

This is the moment of truth. You will create a bookmark, kill the server, restart it, and see if your data survived.

  • Create a bookmark (if you haven't already) by running:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"url":"https://nodejs.org","title":"Node.js","description":"JavaScript runtime"}'
  • Confirm it exists by running:
curl http://localhost:3000/bookmarks

You should see your bookmark in the array.

  • Stop the server by pressing Ctrl+C in the terminal where it is running.
  • Restart the server by running:
npm run dev

Before you run the next command, do you think your bookmarks will still be there? Or will you see an empty array again like last time?

  • Check for your data by running:
curl http://localhost:3000/bookmarks

Your bookmark is still there. The database stored it on disk in bookmarks.db, so it survived the restart.

Why does this work now?

The in-memory array lived inside your Node.js process. When the process died, the array was gone forever. SQLite writes every INSERT, UPDATE, and DELETE to a file on your hard drive. When the server restarts, it reads from that same file.

  • Commit your work with a multi-line commit message by running:
git add .
git commit -m "feat: replace in-memory storage with SQLite database

- Add better-sqlite3 database module with WAL mode
- Rewrite all routes to use prepared statements
- Data now persists across server restarts"
git push

What is a multi-line commit message?

The first line is the subject (what changed). The blank line after it separates the subject from the body. The body bullets explain why and what specifically was done. This is how senior engineers write commits that tell the story of a feature.

  • Verify your commit history by running:
git log --oneline

You should see your new commit at the top of the log.

Data not persisting after restart?

Check that bookmarks.db exists in your project root by running ls bookmarks.db. If it does not exist, make sure src/db/index.ts is imported (even indirectly) when the server starts. Your routes file imports it, so if routes are mounted correctly the database file should be created automatically.

Help me debug my SQLite persistence issue

Now try sending invalid data to see what happens. Run this command that sends an empty body:

curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{}'

You get an ugly error about a NOT NULL constraint failing. The database rejected the empty values, but the error message is raw and unhelpful for anyone using your API. The next step fixes this with middleware that catches bad input before it reaches the database.

Your bookmarks now live permanently on disk. Next up, you will add middleware to handle logging, validation, and errors so your API stops crashing on bad input.

Add Middleware via Feature Branch and PR

Remember what happened when you POSTed an empty body to your API? You got an ugly database constraint error that leaked implementation details. Your API has no way to catch bad input before it hits the database, no request logging to help you debug, and no graceful fallback when something goes wrong.

Middleware solves all three problems without cluttering your route handlers. You will build a logger, a validator, and an error handler on a feature branch, then merge via a pull request. This is the exact workflow senior engineers use at real companies to ship self-contained features.

In this step, get ready to:
  • Create a feature branch and build a request logger middleware.
  • Add input validation and centralized error handling middleware.
  • Push the branch, open a pull request on GitHub, and merge it into main.
Create a feature branch and add the request logger

Feature branches isolate new work from your stable main branch. If anything breaks, main stays clean. When the feature is ready, you merge it via a pull request that shows exactly what changed.

  • In the terminal where your server is running, press Ctrl+C to stop it.
  • Create and switch to a new feature branch by running:
git checkout -b feat/middleware

Why a feature branch for middleware?

Middleware wraps your existing routes without modifying their logic. That makes it a self-contained addition with a clean diff that is easy to review in a pull request.

This is the pattern teams use: branch for the feature, commit on the branch, open a PR for review, then merge. Your GitHub history tells the story of each feature as a discrete unit of work.

Now you will create a middleware function that logs every incoming request with its HTTP method, path, status code, and response time.

  • Create a new folder called middleware inside your src folder by running:
mkdir src/middleware
  • Create src/middleware/logger.ts with the logger middleware by pasting this code:
import type { Request, Response, NextFunction } from 'express';

export function logger(req: Request, res: Response, next: NextFunction): void {
  // Record the time when the request arrives
  const start: number = Date.now();

  // Listen for the response to finish, then log the details
  res.on('finish', () => {
    const duration: number = Date.now() - start;
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
  });

  // Pass control to the next middleware or route handler
  next();
}

What does this code do?

  • Date.now() captures a timestamp the instant a request arrives.
  • res.on('finish', ...) registers a callback that fires after the response is fully sent. This is where you calculate how long the whole thing took.
  • next() passes the request to the next middleware or route handler. Without it, the request would hang forever.

Now register the logger in your main server file so it runs on every request.

  • In src/index.ts, find the import at the top:
import express, { type Request, type Response } from 'express';
  • Add the logger import directly below it, so your first two imports look like this:
import express, { type Request, type Response } from 'express';
import { logger } from './middleware/logger.ts';
  • Find the line app.use(express.json()); and add app.use(logger); directly below it, so the two lines read:
app.use(express.json());
app.use(logger);
  • Save src/index.ts. Your server auto-restarts thanks to --watch.
  • In your second terminal window, test the logger by running:
curl http://localhost:3000/bookmarks

Check the terminal where your server is running. You should see a log line like:

GET /bookmarks 200 3ms

The exact millisecond value will vary, but you should see the method, path, status code, and duration.

Not seeing log output?

  • Make sure app.use(logger) comes before app.use('/bookmarks', bookmarksRouter). Middleware runs in the order you register it.
  • Check that you saved src/index.ts and that the server restarted in your first terminal.
  • Confirm your import path matches exactly: './middleware/logger.ts'.

Help me debug why my logger middleware is not printing anything.

  • Commit your logger middleware by running:
git add .
git commit -m "feat: add request logger middleware"
Add validation and error handling middleware

Your API currently crashes with a raw database error when someone sends invalid data. Validation middleware intercepts bad requests before they reach your route handlers or the database.

  • Create src/middleware/validate.ts with the bookmark validation function by pasting this code:
import type { Request, Response, NextFunction } from 'express';

export function validateBookmark(req: Request, res: Response, next: NextFunction): void {
  const { url, title } = req.body as { url?: string; title?: string };
  const errors: string[] = [];

  // Check that url exists, is a string, and is not empty
  if (!url || typeof url !== 'string' || url.trim() === '') {
    errors.push('url is required and must be a non-empty string');
  }

  // Check that title exists, is a string, and is not empty
  if (!title || typeof title !== 'string' || title.trim() === '') {
    errors.push('title is required and must be a non-empty string');
  }

  // If url looks like a string, verify it is a valid URL format
  if (url && typeof url === 'string') {
    try {
      new URL(url);
    } catch {
      errors.push('url must be a valid URL');
    }
  }

  // If any errors collected, return 400 with the errors array
  if (errors.length > 0) {
    res.status(400).json({ errors });
    return;
  }

  next();
}

What does this code do?

  • The function collects all validation failures into an errors array instead of stopping at the first one. This gives the caller a complete picture of what is wrong.
  • new URL(url) throws if the string is not a valid URL. The try/catch turns that into a friendly error message.
  • If errors exist, the function returns a 400 response and never calls next(). The route handler never runs.

Now wire this validation into your POST and PUT routes so it runs before the handler.

  • In src/routes/bookmarks.ts, find the imports at the top of the file:
import express, { type Request, type Response } from 'express';
import db, { type BookmarkRow } from '../db/index.ts';
  • Add the validateBookmark import below them, so the first three lines are:
import express, { type Request, type Response } from 'express';
import db, { type BookmarkRow } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';
  • Find the POST route line:
router.post('/', (req: Request, res: Response) => {
  • Add validateBookmark as a second argument before the handler, so the line now reads:
router.post('/', validateBookmark, (req: Request, res: Response) => {
  • Find the PUT route line:
router.put('/:id', (req: Request, res: Response) => {
  • Add validateBookmark the same way:
router.put('/:id', validateBookmark, (req: Request, res: Response) => {
  • Save src/routes/bookmarks.ts.
  • Test validation by POSTing an empty body. Before you run this, what do you think you will get back? A database error like before, or something else?
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{}'

You should see a clean 400 response with both validation errors listed:

{"errors":["url is required and must be a non-empty string","title is required and must be a non-empty string"]}

No more ugly database crash. Your middleware caught the bad input before it reached the route handler.

Still seeing a database error instead of validation errors?

  • Confirm validateBookmark appears between the route path and (req: Request, res: Response) in both POST and PUT. It must be a separate argument, not inside the handler.
  • Check that you saved src/routes/bookmarks.ts and the server restarted.
  • Verify the import path: '../middleware/validate.ts' (two dots, since routes is a sibling of middleware).

Help me figure out why my validation middleware is not running.

Next, you need error handling middleware for two remaining gaps: requests to routes that do not exist, and unexpected server errors.

  • Create src/middleware/errors.ts with both error handlers by pasting this code:
import type { Request, Response, NextFunction, ErrorRequestHandler } from 'express';

// Catch any request that did not match a route
export function notFound(req: Request, res: Response, next: NextFunction): void {
  res.status(404).json({ error: `Not found: ${req.method} ${req.originalUrl}` });
}

// Centralized error handler with typed error parameter
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
  console.error(err.stack);
  const status: number = (err as { status?: number }).status || 500;
  const message: string = err.message || 'Internal server error';
  res.status(status).json({ error: message });
};

What does this code do?

  • notFound is a normal 3-argument middleware. It sits after all your routes and catches any request that fell through without a match.
  • errorHandler has 4 arguments (err, req, res, next). Express recognizes this signature as an error-handling middleware. Any unhandled error in your app lands here instead of crashing the server.

Register both error handlers in your main server file. Order matters: they must come after your routes.

  • In src/index.ts, find the logger import you added earlier:
import { logger } from './middleware/logger.ts';
  • Add the errors import directly below it:
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
  • Find the line app.use('/bookmarks', bookmarksRouter); and add these two lines directly below it:
app.use(notFound);
app.use(errorHandler);

Why does middleware order matter?

Express runs middleware top to bottom. If notFound came before your routes, every request would get a 404. If errorHandler came before notFound, it would never catch 404 errors.

The correct order is: JSON parser, logger, routes, notFound, errorHandler.

  • Save src/index.ts.
  • Test the 404 handler by requesting a route that does not exist:
curl http://localhost:3000/nonexistent

You should see a structured JSON error:

{"error":"Not found: GET /nonexistent"}

Check your server terminal too. You should see the logger output for this request showing a 404 status code.

Getting an HTML error page instead of JSON?

  • Make sure app.use(notFound) is placed after app.use('/bookmarks', bookmarksRouter) but before app.use(errorHandler).
  • Verify you saved src/index.ts and the server restarted.

Help me debug my 404 middleware.

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/index.ts, src/routes/bookmarks.ts, and all three middleware files.

ⓧ I'd like to double check the full code

Here is the complete src/index.ts at this point:

import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

const app = express();
const port: number = 3000;

app.use(express.json());
app.use(logger);

app.use('/bookmarks', bookmarksRouter);

app.use(notFound);
app.use(errorHandler);

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

For src/routes/bookmarks.ts, confirm these three changes are in place:

  • The import { validateBookmark } from '../middleware/validate.ts'; line is at the top with your other imports.
  • The POST route reads router.post('/', validateBookmark, (req: Request, res: Response) => {.
  • The PUT route reads router.put('/:id', validateBookmark, (req: Request, res: Response) => {.
  • Commit your validation and error handling middleware by running:
git add .
git commit -m "feat: add validation and error handling middleware

- validateBookmark checks url format and title presence
- notFound returns 404 JSON for unmatched routes
- errorHandler catches unhandled errors with 500 response"

What is a multi-line commit message?

The first line is the subject. The blank line separates it from the body. The body explains WHY the change matters, not just what files changed.

This is how senior engineers write commits that tell a story when someone reads the Git log months later.

Push the branch, open a PR, and merge

Your middleware is working locally. Now you will push your feature branch to GitHub, open a pull request, and merge it into main.

  • Push your feature branch to GitHub by running:
git push -u origin feat/middleware
  • Open your browser and go to your bookmark-api repository on GitHub.
  • You should see a banner saying your branch had recent pushes. Click Compare & pull request.
  • In the PR title field, enter Add request logging, validation, and error handling middleware.
  • In the description box, write a short explanation of what the middleware does and why you added it. For example: "Adds request logging, input validation, and centralized error handling to keep route handlers clean."
  • Click Create pull request.

What is a pull request?

A pull request is a proposal to merge one branch into another. It shows the diff of all your changes and lets teammates (or your future self) review the code before it hits main.

Even on solo projects, PRs create a clean record of what was shipped and why.

  • On the pull request page, click Merge pull request.
  • Click Confirm merge.
  • Switch back to your terminal and update your local main branch by running:
git checkout main
git pull

Your local main branch now includes all the middleware work from your merged PR.

  • Restart the server by running:
npm run dev
  • In your second terminal, run the final verification. What do you expect to see when you POST an empty body now?
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{}'

You should see a clean 400 response with structured validation errors:

{"errors":["url is required and must be a non-empty string","title is required and must be a non-empty string"]}

Check your server terminal. You should see the logger output showing the POST request with a 400 status code and response time.

Seeing module not found errors after switching branches?

  • Make sure your git pull completed successfully. Run git log --oneline and confirm you see the merge commit at the top.
  • Check that your middleware files exist by running ls src/middleware/. You should see errors.ts, logger.ts, and validate.ts.

Help me fix my middleware imports after merging.

Your API now validates input, logs every request, and handles errors gracefully. Next up, you will tackle the Secret Mission and add search and sorting to your GET endpoint.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.
Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.
Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});
Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }
Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.
Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});
Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }
Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.
Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});
Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }
Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.
Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});
Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }
Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.

💡 Senior pattern: separation of concerns

Senior engineers think about code in layers. Your project now has three: the route layer (HTTP concerns), the service layer (business logic), and the data layer (database operations). Keeping these separate means you can swap DynamoDB for PostgreSQL without touching your routes, or add a caching layer without changing business logic.

This separation is called the repository pattern. In production codebases, you would extract DynamoDB calls into a BookmarkRepository class, making the routes testable with a mock repository.

Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

💡 Senior pattern: fail-fast configuration

Notice you read TABLE_NAME from an environment variable with a fallback default. Senior engineers validate ALL configuration at startup. If a required variable is missing, the app should fail immediately with a clear error rather than crashing minutes later on first database call.

In production, you would use a library like Zod to validate env vars at startup and export typed config. This pattern is called fail-fast configuration. If TABLE_NAME is undefined in Lambda, you want to know in the first millisecond, not after a user hits an endpoint.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

Senior pattern: entry point inversion

Senior engineers design for replaceability. By separating app.ts from index.ts, you gain three capabilities: Lambda and local dev share the exact same app code (no divergence). Integration tests can import app.ts directly with Supertest without starting a server. You can swap from Lambda to ECS or a VM by changing only the entry point.

This pattern is called dependency inversion. The app does not know or care how it gets invoked. The invoker (Lambda handler or local listener) depends on the app, not the other way around.

Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }

Senior pattern: least-privilege IAM

The IAM policy uses dynamodb:* for simplicity, but a senior engineer would narrow this to only the actions your code actually calls: GetItem, PutItem, Scan, DeleteItem, UpdateItem. This is the principle of least privilege.

If your Lambda is compromised, a wildcard policy lets the attacker drop your entire table. Narrowed permissions limit the blast radius. In production, you would also scope the Resource to include both the table ARN and its indexes (e.g. arn:...table/bookmarks/index/*).

💡 Senior pattern: infrastructure as cattle

Notice you never clicked through the AWS console to create these resources. Every piece of infrastructure is declared in code, version-controlled, and reproducible. If you delete everything with terraform destroy, you can recreate it identically with terraform apply.

This is the core insight of Infrastructure as Code: infrastructure is not a snowflake. It is disposable, repeatable, and auditable. Senior engineers treat servers like cattle, not pets. When something breaks, you rebuild from code rather than debugging a hand-configured resource.

💡 Senior pattern: immutable infrastructure

This is Infrastructure as Code (IaC). Senior engineers never create resources by clicking through the AWS console. Console clicks are unrepeatable, undocumented, and impossible to review. Terraform files are version-controlled, peer-reviewed in PRs, and can recreate your entire stack from scratch in minutes.

The principle of least privilege in the IAM policy grants dynamodb:* only on the bookmarks table ARN, not all DynamoDB tables. In production, you would narrow this further to specific actions like GetItem, PutItem, Scan, DeleteItem.

Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.

💡 Senior pattern: quality gates as code

This pipeline implements a core senior engineering principle: the deployment pipeline as a quality gate. No code reaches production without passing lint and type-check. The needs: quality dependency means a single failing rule blocks the entire deploy.

In larger teams, you would add integration tests, security scanning (SAST/DAST), and a terraform plan output posted as a PR comment so reviewers can see infrastructure changes before approving the merge. The key insight: make the right thing easy and the wrong thing hard. If pushing broken code is physically blocked, the team ships fewer bugs.

💡 Senior pattern: environment promotion

Your workflow deploys directly to production on merge. Senior teams add an intermediate step: deploy to a staging environment first, run smoke tests against it, then promote to production. You would do this by adding a terraform workspace for each environment (staging/prod) and a manual approval step between them.

Another advanced pattern is trunk-based development: every engineer merges to main multiple times per day via small PRs. The CI pipeline runs fast (under 5 minutes), and feature flags control what users see. This eliminates long-lived branches and merge conflicts.

💡 Senior pattern: shift-left quality gates

This pipeline implements the trunk-based development pattern that senior engineers use at companies like Google and Netflix. The rules are simple: main is always deployable, all work happens on short-lived branches, merging to main triggers production deployment. No staging environment, no manual QA gate.

The needs: quality dependency creates a quality gate. Code that fails lint or type-check physically cannot deploy. This shifts errors left: you catch them in seconds on a PR, not in hours during a production incident.

💡 Extra for Experts: production pipeline patterns

In production systems, senior engineers add these pipeline enhancements. Each one is a separate improvement you can add later:

  • Rollback strategy: If deploy fails, automatically revert to the previous Lambda version using aliases.
  • Environment promotion: Deploy to staging first, run smoke tests, then promote to production.
  • OIDC authentication: Replace static AWS keys with short-lived tokens from GitHub's OIDC provider. No long-lived secrets.
  • Terraform plan on PRs: Post the terraform plan output as a PR comment so reviewers can see infrastructure changes before merge.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.

💡 Senior pattern: separation of concerns

Senior engineers think about code in layers. Your project now has three: the route layer (HTTP concerns), the service layer (business logic), and the data layer (database operations). Keeping these separate means you can swap DynamoDB for PostgreSQL without touching your routes, or add a caching layer without changing business logic.

This separation is called the repository pattern. In production codebases, you would extract DynamoDB calls into a BookmarkRepository class, making the routes testable with a mock repository.

Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

💡 Senior pattern: fail-fast configuration

Notice you read TABLE_NAME from an environment variable with a fallback default. Senior engineers validate ALL configuration at startup. If a required variable is missing, the app should fail immediately with a clear error rather than crashing minutes later on first database call.

In production, you would use a library like Zod to validate env vars at startup and export typed config. This pattern is called fail-fast configuration. If TABLE_NAME is undefined in Lambda, you want to know in the first millisecond, not after a user hits an endpoint.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

Senior pattern: entry point inversion

Senior engineers design for replaceability. By separating app.ts from index.ts, you gain three capabilities: Lambda and local dev share the exact same app code (no divergence). Integration tests can import app.ts directly with Supertest without starting a server. You can swap from Lambda to ECS or a VM by changing only the entry point.

This pattern is called dependency inversion. The app does not know or care how it gets invoked. The invoker (Lambda handler or local listener) depends on the app, not the other way around.

Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }

Senior pattern: least-privilege IAM

The IAM policy uses dynamodb:* for simplicity, but a senior engineer would narrow this to only the actions your code actually calls: GetItem, PutItem, Scan, DeleteItem, UpdateItem. This is the principle of least privilege.

If your Lambda is compromised, a wildcard policy lets the attacker drop your entire table. Narrowed permissions limit the blast radius. In production, you would also scope the Resource to include both the table ARN and its indexes (e.g. arn:...table/bookmarks/index/*).

💡 Senior pattern: infrastructure as cattle

Notice you never clicked through the AWS console to create these resources. Every piece of infrastructure is declared in code, version-controlled, and reproducible. If you delete everything with terraform destroy, you can recreate it identically with terraform apply.

This is the core insight of Infrastructure as Code: infrastructure is not a snowflake. It is disposable, repeatable, and auditable. Senior engineers treat servers like cattle, not pets. When something breaks, you rebuild from code rather than debugging a hand-configured resource.

💡 Senior pattern: immutable infrastructure

This is Infrastructure as Code (IaC). Senior engineers never create resources by clicking through the AWS console. Console clicks are unrepeatable, undocumented, and impossible to review. Terraform files are version-controlled, peer-reviewed in PRs, and can recreate your entire stack from scratch in minutes.

The principle of least privilege in the IAM policy grants dynamodb:* only on the bookmarks table ARN, not all DynamoDB tables. In production, you would narrow this further to specific actions like GetItem, PutItem, Scan, DeleteItem.

Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.

💡 Senior pattern: quality gates as code

This pipeline implements a core senior engineering principle: the deployment pipeline as a quality gate. No code reaches production without passing lint and type-check. The needs: quality dependency means a single failing rule blocks the entire deploy.

In larger teams, you would add integration tests, security scanning (SAST/DAST), and a terraform plan output posted as a PR comment so reviewers can see infrastructure changes before approving the merge. The key insight: make the right thing easy and the wrong thing hard. If pushing broken code is physically blocked, the team ships fewer bugs.

💡 Senior pattern: environment promotion

Your workflow deploys directly to production on merge. Senior teams add an intermediate step: deploy to a staging environment first, run smoke tests against it, then promote to production. You would do this by adding a terraform workspace for each environment (staging/prod) and a manual approval step between them.

Another advanced pattern is trunk-based development: every engineer merges to main multiple times per day via small PRs. The CI pipeline runs fast (under 5 minutes), and feature flags control what users see. This eliminates long-lived branches and merge conflicts.

💡 Senior pattern: shift-left quality gates

This pipeline implements the trunk-based development pattern that senior engineers use at companies like Google and Netflix. The rules are simple: main is always deployable, all work happens on short-lived branches, merging to main triggers production deployment. No staging environment, no manual QA gate.

The needs: quality dependency creates a quality gate. Code that fails lint or type-check physically cannot deploy. This shifts errors left: you catch them in seconds on a PR, not in hours during a production incident.

💡 Extra for Experts: production pipeline patterns

In production systems, senior engineers add these pipeline enhancements. Each one is a separate improvement you can add later:

  • Rollback strategy: If deploy fails, automatically revert to the previous Lambda version using aliases.
  • Environment promotion: Deploy to staging first, run smoke tests, then promote to production.
  • OIDC authentication: Replace static AWS keys with short-lived tokens from GitHub's OIDC provider. No long-lived secrets.
  • Terraform plan on PRs: Post the terraform plan output as a PR comment so reviewers can see infrastructure changes before merge.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.

💡 Senior pattern: separation of concerns

Senior engineers think about code in layers. Your project now has three: the route layer (HTTP concerns), the service layer (business logic), and the data layer (database operations). Keeping these separate means you can swap DynamoDB for PostgreSQL without touching your routes, or add a caching layer without changing business logic.

This separation is called the repository pattern. In production codebases, you would extract DynamoDB calls into a BookmarkRepository class, making the routes testable with a mock repository.

Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

💡 Senior pattern: fail-fast configuration

Notice you read TABLE_NAME from an environment variable with a fallback default. Senior engineers validate ALL configuration at startup. If a required variable is missing, the app should fail immediately with a clear error rather than crashing minutes later on first database call.

In production, you would use a library like Zod to validate env vars at startup and export typed config. This pattern is called fail-fast configuration. If TABLE_NAME is undefined in Lambda, you want to know in the first millisecond, not after a user hits an endpoint.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

Senior pattern: entry point inversion

Senior engineers design for replaceability. By separating app.ts from index.ts, you gain three capabilities: Lambda and local dev share the exact same app code (no divergence). Integration tests can import app.ts directly with Supertest without starting a server. You can swap from Lambda to ECS or a VM by changing only the entry point.

This pattern is called dependency inversion. The app does not know or care how it gets invoked. The invoker (Lambda handler or local listener) depends on the app, not the other way around.

Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }

Senior pattern: least-privilege IAM

The IAM policy uses dynamodb:* for simplicity, but a senior engineer would narrow this to only the actions your code actually calls: GetItem, PutItem, Scan, DeleteItem, UpdateItem. This is the principle of least privilege.

If your Lambda is compromised, a wildcard policy lets the attacker drop your entire table. Narrowed permissions limit the blast radius. In production, you would also scope the Resource to include both the table ARN and its indexes (e.g. arn:...table/bookmarks/index/*).

💡 Senior pattern: infrastructure as cattle

Notice you never clicked through the AWS console to create these resources. Every piece of infrastructure is declared in code, version-controlled, and reproducible. If you delete everything with terraform destroy, you can recreate it identically with terraform apply.

This is the core insight of Infrastructure as Code: infrastructure is not a snowflake. It is disposable, repeatable, and auditable. Senior engineers treat servers like cattle, not pets. When something breaks, you rebuild from code rather than debugging a hand-configured resource.

💡 Senior pattern: immutable infrastructure

This is Infrastructure as Code (IaC). Senior engineers never create resources by clicking through the AWS console. Console clicks are unrepeatable, undocumented, and impossible to review. Terraform files are version-controlled, peer-reviewed in PRs, and can recreate your entire stack from scratch in minutes.

The principle of least privilege in the IAM policy grants dynamodb:* only on the bookmarks table ARN, not all DynamoDB tables. In production, you would narrow this further to specific actions like GetItem, PutItem, Scan, DeleteItem.

Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.

💡 Senior pattern: quality gates as code

This pipeline implements a core senior engineering principle: the deployment pipeline as a quality gate. No code reaches production without passing lint and type-check. The needs: quality dependency means a single failing rule blocks the entire deploy.

In larger teams, you would add integration tests, security scanning (SAST/DAST), and a terraform plan output posted as a PR comment so reviewers can see infrastructure changes before approving the merge. The key insight: make the right thing easy and the wrong thing hard. If pushing broken code is physically blocked, the team ships fewer bugs.

💡 Senior pattern: environment promotion

Your workflow deploys directly to production on merge. Senior teams add an intermediate step: deploy to a staging environment first, run smoke tests against it, then promote to production. You would do this by adding a terraform workspace for each environment (staging/prod) and a manual approval step between them.

Another advanced pattern is trunk-based development: every engineer merges to main multiple times per day via small PRs. The CI pipeline runs fast (under 5 minutes), and feature flags control what users see. This eliminates long-lived branches and merge conflicts.

💡 Senior pattern: shift-left quality gates

This pipeline implements the trunk-based development pattern that senior engineers use at companies like Google and Netflix. The rules are simple: main is always deployable, all work happens on short-lived branches, merging to main triggers production deployment. No staging environment, no manual QA gate.

The needs: quality dependency creates a quality gate. Code that fails lint or type-check physically cannot deploy. This shifts errors left: you catch them in seconds on a PR, not in hours during a production incident.

💡 Extra for Experts: production pipeline patterns

In production systems, senior engineers add these pipeline enhancements. Each one is a separate improvement you can add later:

  • Rollback strategy: If deploy fails, automatically revert to the previous Lambda version using aliases.
  • Environment promotion: Deploy to staging first, run smoke tests, then promote to production.
  • OIDC authentication: Replace static AWS keys with short-lived tokens from GitHub's OIDC provider. No long-lived secrets.
  • Terraform plan on PRs: Post the terraform plan output as a PR comment so reviewers can see infrastructure changes before merge.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.

💡 Senior pattern: separation of concerns

Senior engineers think about code in layers. Your project now has three: the route layer (HTTP concerns), the service layer (business logic), and the data layer (database operations). Keeping these separate means you can swap DynamoDB for PostgreSQL without touching your routes, or add a caching layer without changing business logic.

This separation is called the repository pattern. In production codebases, you would extract DynamoDB calls into a BookmarkRepository class, making the routes testable with a mock repository.

Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

💡 Senior pattern: fail-fast configuration

Notice you read TABLE_NAME from an environment variable with a fallback default. Senior engineers validate ALL configuration at startup. If a required variable is missing, the app should fail immediately with a clear error rather than crashing minutes later on first database call.

In production, you would use a library like Zod to validate env vars at startup and export typed config. This pattern is called fail-fast configuration. If TABLE_NAME is undefined in Lambda, you want to know in the first millisecond, not after a user hits an endpoint.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

Senior pattern: entry point inversion

Senior engineers design for replaceability. By separating app.ts from index.ts, you gain three capabilities: Lambda and local dev share the exact same app code (no divergence). Integration tests can import app.ts directly with Supertest without starting a server. You can swap from Lambda to ECS or a VM by changing only the entry point.

This pattern is called dependency inversion. The app does not know or care how it gets invoked. The invoker (Lambda handler or local listener) depends on the app, not the other way around.

Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }

Senior pattern: least-privilege IAM

The IAM policy uses dynamodb:* for simplicity, but a senior engineer would narrow this to only the actions your code actually calls: GetItem, PutItem, Scan, DeleteItem, UpdateItem. This is the principle of least privilege.

If your Lambda is compromised, a wildcard policy lets the attacker drop your entire table. Narrowed permissions limit the blast radius. In production, you would also scope the Resource to include both the table ARN and its indexes (e.g. arn:...table/bookmarks/index/*).

💡 Senior pattern: infrastructure as cattle

Notice you never clicked through the AWS console to create these resources. Every piece of infrastructure is declared in code, version-controlled, and reproducible. If you delete everything with terraform destroy, you can recreate it identically with terraform apply.

This is the core insight of Infrastructure as Code: infrastructure is not a snowflake. It is disposable, repeatable, and auditable. Senior engineers treat servers like cattle, not pets. When something breaks, you rebuild from code rather than debugging a hand-configured resource.

💡 Senior pattern: immutable infrastructure

This is Infrastructure as Code (IaC). Senior engineers never create resources by clicking through the AWS console. Console clicks are unrepeatable, undocumented, and impossible to review. Terraform files are version-controlled, peer-reviewed in PRs, and can recreate your entire stack from scratch in minutes.

The principle of least privilege in the IAM policy grants dynamodb:* only on the bookmarks table ARN, not all DynamoDB tables. In production, you would narrow this further to specific actions like GetItem, PutItem, Scan, DeleteItem.

Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.

💡 Senior pattern: quality gates as code

This pipeline implements a core senior engineering principle: the deployment pipeline as a quality gate. No code reaches production without passing lint and type-check. The needs: quality dependency means a single failing rule blocks the entire deploy.

In larger teams, you would add integration tests, security scanning (SAST/DAST), and a terraform plan output posted as a PR comment so reviewers can see infrastructure changes before approving the merge. The key insight: make the right thing easy and the wrong thing hard. If pushing broken code is physically blocked, the team ships fewer bugs.

💡 Senior pattern: environment promotion

Your workflow deploys directly to production on merge. Senior teams add an intermediate step: deploy to a staging environment first, run smoke tests against it, then promote to production. You would do this by adding a terraform workspace for each environment (staging/prod) and a manual approval step between them.

Another advanced pattern is trunk-based development: every engineer merges to main multiple times per day via small PRs. The CI pipeline runs fast (under 5 minutes), and feature flags control what users see. This eliminates long-lived branches and merge conflicts.

💡 Senior pattern: shift-left quality gates

This pipeline implements the trunk-based development pattern that senior engineers use at companies like Google and Netflix. The rules are simple: main is always deployable, all work happens on short-lived branches, merging to main triggers production deployment. No staging environment, no manual QA gate.

The needs: quality dependency creates a quality gate. Code that fails lint or type-check physically cannot deploy. This shifts errors left: you catch them in seconds on a PR, not in hours during a production incident.

💡 Extra for Experts: production pipeline patterns

In production systems, senior engineers add these pipeline enhancements. Each one is a separate improvement you can add later:

  • Rollback strategy: If deploy fails, automatically revert to the previous Lambda version using aliases.
  • Environment promotion: Deploy to staging first, run smoke tests, then promote to production.
  • OIDC authentication: Replace static AWS keys with short-lived tokens from GitHub's OIDC provider. No long-lived secrets.
  • Terraform plan on PRs: Post the terraform plan output as a PR comment so reviewers can see infrastructure changes before merge.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.

💡 Senior pattern: separation of concerns

Senior engineers think about code in layers. Your project now has three: the route layer (HTTP concerns), the service layer (business logic), and the data layer (database operations). Keeping these separate means you can swap DynamoDB for PostgreSQL without touching your routes, or add a caching layer without changing business logic.

This separation is called the repository pattern. In production codebases, you would extract DynamoDB calls into a BookmarkRepository class, making the routes testable with a mock repository.

Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

💡 Senior pattern: fail-fast configuration

Notice you read TABLE_NAME from an environment variable with a fallback default. Senior engineers validate ALL configuration at startup. If a required variable is missing, the app should fail immediately with a clear error rather than crashing minutes later on first database call.

In production, you would use a library like Zod to validate env vars at startup and export typed config. This pattern is called fail-fast configuration. If TABLE_NAME is undefined in Lambda, you want to know in the first millisecond, not after a user hits an endpoint.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

Senior pattern: entry point inversion

Senior engineers design for replaceability. By separating app.ts from index.ts, you gain three capabilities: Lambda and local dev share the exact same app code (no divergence). Integration tests can import app.ts directly with Supertest without starting a server. You can swap from Lambda to ECS or a VM by changing only the entry point.

This pattern is called dependency inversion. The app does not know or care how it gets invoked. The invoker (Lambda handler or local listener) depends on the app, not the other way around.

Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }

Senior pattern: least-privilege IAM

The IAM policy uses dynamodb:* for simplicity, but a senior engineer would narrow this to only the actions your code actually calls: GetItem, PutItem, Scan, DeleteItem, UpdateItem. This is the principle of least privilege.

If your Lambda is compromised, a wildcard policy lets the attacker drop your entire table. Narrowed permissions limit the blast radius. In production, you would also scope the Resource to include both the table ARN and its indexes (e.g. arn:...table/bookmarks/index/*).

💡 Senior pattern: infrastructure as cattle

Notice you never clicked through the AWS console to create these resources. Every piece of infrastructure is declared in code, version-controlled, and reproducible. If you delete everything with terraform destroy, you can recreate it identically with terraform apply.

This is the core insight of Infrastructure as Code: infrastructure is not a snowflake. It is disposable, repeatable, and auditable. Senior engineers treat servers like cattle, not pets. When something breaks, you rebuild from code rather than debugging a hand-configured resource.

💡 Senior pattern: immutable infrastructure

This is Infrastructure as Code (IaC). Senior engineers never create resources by clicking through the AWS console. Console clicks are unrepeatable, undocumented, and impossible to review. Terraform files are version-controlled, peer-reviewed in PRs, and can recreate your entire stack from scratch in minutes.

The principle of least privilege in the IAM policy grants dynamodb:* only on the bookmarks table ARN, not all DynamoDB tables. In production, you would narrow this further to specific actions like GetItem, PutItem, Scan, DeleteItem.

Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.

💡 Senior pattern: quality gates as code

This pipeline implements a core senior engineering principle: the deployment pipeline as a quality gate. No code reaches production without passing lint and type-check. The needs: quality dependency means a single failing rule blocks the entire deploy.

In larger teams, you would add integration tests, security scanning (SAST/DAST), and a terraform plan output posted as a PR comment so reviewers can see infrastructure changes before approving the merge. The key insight: make the right thing easy and the wrong thing hard. If pushing broken code is physically blocked, the team ships fewer bugs.

💡 Senior pattern: environment promotion

Your workflow deploys directly to production on merge. Senior teams add an intermediate step: deploy to a staging environment first, run smoke tests against it, then promote to production. You would do this by adding a terraform workspace for each environment (staging/prod) and a manual approval step between them.

Another advanced pattern is trunk-based development: every engineer merges to main multiple times per day via small PRs. The CI pipeline runs fast (under 5 minutes), and feature flags control what users see. This eliminates long-lived branches and merge conflicts.

💡 Senior pattern: shift-left quality gates

This pipeline implements the trunk-based development pattern that senior engineers use at companies like Google and Netflix. The rules are simple: main is always deployable, all work happens on short-lived branches, merging to main triggers production deployment. No staging environment, no manual QA gate.

The needs: quality dependency creates a quality gate. Code that fails lint or type-check physically cannot deploy. This shifts errors left: you catch them in seconds on a PR, not in hours during a production incident.

💡 Extra for Experts: production pipeline patterns

In production systems, senior engineers add these pipeline enhancements. Each one is a separate improvement you can add later:

  • Rollback strategy: If deploy fails, automatically revert to the previous Lambda version using aliases.
  • Environment promotion: Deploy to staging first, run smoke tests, then promote to production.
  • OIDC authentication: Replace static AWS keys with short-lived tokens from GitHub's OIDC provider. No long-lived secrets.
  • Terraform plan on PRs: Post the terraform plan output as a PR comment so reviewers can see infrastructure changes before merge.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.

💡 Senior pattern: separation of concerns

Senior engineers think about code in layers. Your project now has three: the route layer (HTTP concerns), the service layer (business logic), and the data layer (database operations). Keeping these separate means you can swap DynamoDB for PostgreSQL without touching your routes, or add a caching layer without changing business logic.

This separation is called the repository pattern. In production codebases, you would extract DynamoDB calls into a BookmarkRepository class, making the routes testable with a mock repository.

Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

💡 Senior pattern: fail-fast configuration

Notice you read TABLE_NAME from an environment variable with a fallback default. Senior engineers validate ALL configuration at startup. If a required variable is missing, the app should fail immediately with a clear error rather than crashing minutes later on first database call.

In production, you would use a library like Zod to validate env vars at startup and export typed config. This pattern is called fail-fast configuration. If TABLE_NAME is undefined in Lambda, you want to know in the first millisecond, not after a user hits an endpoint.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

Senior pattern: entry point inversion

Senior engineers design for replaceability. By separating app.ts from index.ts, you gain three capabilities: Lambda and local dev share the exact same app code (no divergence). Integration tests can import app.ts directly with Supertest without starting a server. You can swap from Lambda to ECS or a VM by changing only the entry point.

This pattern is called dependency inversion. The app does not know or care how it gets invoked. The invoker (Lambda handler or local listener) depends on the app, not the other way around.

Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }

Senior pattern: least-privilege IAM

The IAM policy uses dynamodb:* for simplicity, but a senior engineer would narrow this to only the actions your code actually calls: GetItem, PutItem, Scan, DeleteItem, UpdateItem. This is the principle of least privilege.

If your Lambda is compromised, a wildcard policy lets the attacker drop your entire table. Narrowed permissions limit the blast radius. In production, you would also scope the Resource to include both the table ARN and its indexes (e.g. arn:...table/bookmarks/index/*).

💡 Senior pattern: infrastructure as cattle

Notice you never clicked through the AWS console to create these resources. Every piece of infrastructure is declared in code, version-controlled, and reproducible. If you delete everything with terraform destroy, you can recreate it identically with terraform apply.

This is the core insight of Infrastructure as Code: infrastructure is not a snowflake. It is disposable, repeatable, and auditable. Senior engineers treat servers like cattle, not pets. When something breaks, you rebuild from code rather than debugging a hand-configured resource.

💡 Senior pattern: immutable infrastructure

This is Infrastructure as Code (IaC). Senior engineers never create resources by clicking through the AWS console. Console clicks are unrepeatable, undocumented, and impossible to review. Terraform files are version-controlled, peer-reviewed in PRs, and can recreate your entire stack from scratch in minutes.

The principle of least privilege in the IAM policy grants dynamodb:* only on the bookmarks table ARN, not all DynamoDB tables. In production, you would narrow this further to specific actions like GetItem, PutItem, Scan, DeleteItem.

Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.

💡 Senior pattern: quality gates as code

This pipeline implements a core senior engineering principle: the deployment pipeline as a quality gate. No code reaches production without passing lint and type-check. The needs: quality dependency means a single failing rule blocks the entire deploy.

In larger teams, you would add integration tests, security scanning (SAST/DAST), and a terraform plan output posted as a PR comment so reviewers can see infrastructure changes before approving the merge. The key insight: make the right thing easy and the wrong thing hard. If pushing broken code is physically blocked, the team ships fewer bugs.

💡 Senior pattern: environment promotion

Your workflow deploys directly to production on merge. Senior teams add an intermediate step: deploy to a staging environment first, run smoke tests against it, then promote to production. You would do this by adding a terraform workspace for each environment (staging/prod) and a manual approval step between them.

Another advanced pattern is trunk-based development: every engineer merges to main multiple times per day via small PRs. The CI pipeline runs fast (under 5 minutes), and feature flags control what users see. This eliminates long-lived branches and merge conflicts.

💡 Senior pattern: shift-left quality gates

This pipeline implements the trunk-based development pattern that senior engineers use at companies like Google and Netflix. The rules are simple: main is always deployable, all work happens on short-lived branches, merging to main triggers production deployment. No staging environment, no manual QA gate.

The needs: quality dependency creates a quality gate. Code that fails lint or type-check physically cannot deploy. This shifts errors left: you catch them in seconds on a PR, not in hours during a production incident.

💡 Extra for Experts: production pipeline patterns

In production systems, senior engineers add these pipeline enhancements. Each one is a separate improvement you can add later:

  • Rollback strategy: If deploy fails, automatically revert to the previous Lambda version using aliases.
  • Environment promotion: Deploy to staging first, run smoke tests, then promote to production.
  • OIDC authentication: Replace static AWS keys with short-lived tokens from GitHub's OIDC provider. No long-lived secrets.
  • Terraform plan on PRs: Post the terraform plan output as a PR comment so reviewers can see infrastructure changes before merge.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.

💡 Senior pattern: separation of concerns

Senior engineers think about code in layers. Your project now has three: the route layer (HTTP concerns), the service layer (business logic), and the data layer (database operations). Keeping these separate means you can swap DynamoDB for PostgreSQL without touching your routes, or add a caching layer without changing business logic.

This separation is called the repository pattern. In production codebases, you would extract DynamoDB calls into a BookmarkRepository class, making the routes testable with a mock repository.

Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

💡 Senior pattern: fail-fast configuration

Notice you read TABLE_NAME from an environment variable with a fallback default. Senior engineers validate ALL configuration at startup. If a required variable is missing, the app should fail immediately with a clear error rather than crashing minutes later on first database call.

In production, you would use a library like Zod to validate env vars at startup and export typed config. This pattern is called fail-fast configuration. If TABLE_NAME is undefined in Lambda, you want to know in the first millisecond, not after a user hits an endpoint.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

Senior pattern: entry point inversion

Senior engineers design for replaceability. By separating app.ts from index.ts, you gain three capabilities: Lambda and local dev share the exact same app code (no divergence). Integration tests can import app.ts directly with Supertest without starting a server. You can swap from Lambda to ECS or a VM by changing only the entry point.

This pattern is called dependency inversion. The app does not know or care how it gets invoked. The invoker (Lambda handler or local listener) depends on the app, not the other way around.

Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }

Senior pattern: least-privilege IAM

The IAM policy uses dynamodb:* for simplicity, but a senior engineer would narrow this to only the actions your code actually calls: GetItem, PutItem, Scan, DeleteItem, UpdateItem. This is the principle of least privilege.

If your Lambda is compromised, a wildcard policy lets the attacker drop your entire table. Narrowed permissions limit the blast radius. In production, you would also scope the Resource to include both the table ARN and its indexes (e.g. arn:...table/bookmarks/index/*).

💡 Senior pattern: infrastructure as cattle

Notice you never clicked through the AWS console to create these resources. Every piece of infrastructure is declared in code, version-controlled, and reproducible. If you delete everything with terraform destroy, you can recreate it identically with terraform apply.

This is the core insight of Infrastructure as Code: infrastructure is not a snowflake. It is disposable, repeatable, and auditable. Senior engineers treat servers like cattle, not pets. When something breaks, you rebuild from code rather than debugging a hand-configured resource.

💡 Senior pattern: immutable infrastructure

This is Infrastructure as Code (IaC). Senior engineers never create resources by clicking through the AWS console. Console clicks are unrepeatable, undocumented, and impossible to review. Terraform files are version-controlled, peer-reviewed in PRs, and can recreate your entire stack from scratch in minutes.

The principle of least privilege in the IAM policy grants dynamodb:* only on the bookmarks table ARN, not all DynamoDB tables. In production, you would narrow this further to specific actions like GetItem, PutItem, Scan, DeleteItem.

Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.

💡 Senior pattern: quality gates as code

This pipeline implements a core senior engineering principle: the deployment pipeline as a quality gate. No code reaches production without passing lint and type-check. The needs: quality dependency means a single failing rule blocks the entire deploy.

In larger teams, you would add integration tests, security scanning (SAST/DAST), and a terraform plan output posted as a PR comment so reviewers can see infrastructure changes before approving the merge. The key insight: make the right thing easy and the wrong thing hard. If pushing broken code is physically blocked, the team ships fewer bugs.

💡 Senior pattern: environment promotion

Your workflow deploys directly to production on merge. Senior teams add an intermediate step: deploy to a staging environment first, run smoke tests against it, then promote to production. You would do this by adding a terraform workspace for each environment (staging/prod) and a manual approval step between them.

Another advanced pattern is trunk-based development: every engineer merges to main multiple times per day via small PRs. The CI pipeline runs fast (under 5 minutes), and feature flags control what users see. This eliminates long-lived branches and merge conflicts.

💡 Senior pattern: shift-left quality gates

This pipeline implements the trunk-based development pattern that senior engineers use at companies like Google and Netflix. The rules are simple: main is always deployable, all work happens on short-lived branches, merging to main triggers production deployment. No staging environment, no manual QA gate.

The needs: quality dependency creates a quality gate. Code that fails lint or type-check physically cannot deploy. This shifts errors left: you catch them in seconds on a PR, not in hours during a production incident.

💡 Extra for Experts: production pipeline patterns

In production systems, senior engineers add these pipeline enhancements. Each one is a separate improvement you can add later:

  • Rollback strategy: If deploy fails, automatically revert to the previous Lambda version using aliases.
  • Environment promotion: Deploy to staging first, run smoke tests, then promote to production.
  • OIDC authentication: Replace static AWS keys with short-lived tokens from GitHub's OIDC provider. No long-lived secrets.
  • Terraform plan on PRs: Post the terraform plan output as a PR comment so reviewers can see infrastructure changes before merge.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API accepts requests from anyone at any speed with no origin restrictions. In production, this means a single bad actor can overwhelm your server with thousands of requests per second, and any website can call your endpoints without permission.

In this step, you will add three layers of production hardening: rate limiting to throttle abusive clients, CORS to control which origins can call your API, and typed error classes that give every error a consistent shape.

In this step, get ready to:
  • Add rate limiting, CORS, and Helmet security headers.
  • Create typed custom error classes for consistent error responses.
  • Add input sanitization to prevent stored XSS attacks.
Install security packages and configure rate limiting
  • Install the security packages by running:
npm install cors helmet express-rate-limit
npm install --save-dev @types/cors
  • Create src/middleware/security.ts with rate limiting and CORS configuration:
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';

// Allow requests from any origin (restrict in production)
export const corsMiddleware = cors({
  origin: '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
});

// Set security headers (XSS protection, HSTS, etc.)
export const securityHeaders = helmet();

// Limit each IP to 100 requests per 15-minute window
export const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later' },
});

Senior pattern: defense in depth

Senior engineers implement defense in depth: multiple independent security layers that each catch different threats. CORS prevents unauthorized browser clients. Rate limiting prevents DDoS and brute-force attacks. Helmet sets headers that prevent clickjacking, XSS, and protocol downgrade attacks.

No single layer is sufficient. A determined attacker bypasses CORS (it only works in browsers). Rate limiting alone does not prevent credential stuffing. Helmet alone does not stop application-level abuse. Together, they make attacks significantly harder.

Create typed error classes

Right now, your error handler uses raw objects. Senior engineers use custom error classes so every error carries a typed status code and message.

  • Create src/errors.ts with typed error classes:
// Base class for all API errors
export class AppError extends Error {
  public readonly statusCode: number;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
    this.name = this.constructor.name;
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 404);
  }
}

export class ValidationError extends AppError {
  constructor(message: string) {
    super(message, 400);
  }
}

export class AuthenticationError extends AppError {
  constructor() {
    super('Authentication required', 401);
  }
}

export class RateLimitError extends AppError {
  constructor() {
    super('Too many requests', 429);
  }
}

Senior pattern: error categorization

Typed error classes give you compile-time guarantees about your error handling. Instead of scattering res.status(404).json({...}) across 20 route handlers, you throw new NotFoundError('Bookmark') and the centralized error handler formats it consistently.

This is the single responsibility principle applied to errors: routes decide WHAT went wrong, the error handler decides HOW to respond. When you later add structured logging, every error automatically gets logged with its class name, status code, and stack trace.

  • Register the security middleware in src/index.ts before your routes. Add these lines after app.use(express.json()):
import { corsMiddleware, securityHeaders, limiter } from './middleware/security.ts';

app.use(securityHeaders);
app.use(corsMiddleware);
app.use(limiter);
  • Save all files and test rate limiting by running:
curl -I http://localhost:3000/bookmarks

You should see X-RateLimit-Limit: 100 and X-RateLimit-Remaining: 99 in the response headers. Security headers from Helmet will also appear.

  • Commit your security hardening by running:
git add .
git commit -m "feat: add rate limiting, CORS, and typed error classes"
git push

Your API now rejects abusive traffic, controls which origins can call it, and returns consistent typed errors. Next, you will add authentication so only registered users can create bookmarks.

Your API is open to everyone. Anyone with the URL can create, read, update, and delete bookmarks. In production, you need to know WHO is making each request and restrict access to only authenticated users.

In this step, you will add JWT (JSON Web Token) authentication: a sign-up endpoint that creates users, a login endpoint that returns a token, and a middleware that protects routes from unauthenticated access. You will also implement idempotency keys to prevent duplicate bookmark creation on retries.

In this step, get ready to:
  • Create sign-up and login endpoints with bcrypt password hashing.
  • Build a verifyToken middleware that protects bookmark routes.
  • Add idempotency keys to prevent duplicate POST requests.
Install authentication packages
  • Install JWT and bcrypt packages by running:
npm install jsonwebtoken bcryptjs
npm install --save-dev @types/jsonwebtoken @types/bcryptjs

Senior pattern: stateless auth vs sessions

JWTs are stateless. The server does not store sessions in memory or a database. Instead, the token itself contains the user's identity (signed cryptographically). The server verifies the signature on every request. This makes JWT perfect for serverless: no shared session store needed across Lambda invocations.

The trade-off: you cannot revoke a JWT before it expires without adding a blocklist (which reintroduces state). Senior engineers choose short expiry times (15 min) + refresh tokens as the production pattern.

💡 Senior pattern: idempotency

Your POST endpoint creates a new bookmark every time it is called. If a client retries a failed request (network timeout, 502 from API Gateway), you get a duplicate. Senior engineers solve this with idempotency keys: the client sends a unique key with each request. If the server sees the same key twice, it returns the original response without creating a duplicate.

Stripe, PayPal, and every payment API uses this pattern. For your bookmark API, you would store the idempotency key in DynamoDB with a TTL. If a duplicate arrives within the TTL window, return the cached response.

  • Commit your authentication layer by running:
git add .
git commit -m "feat: add JWT authentication with protected routes"
git push

Your API now authenticates users and protects routes. Next, you will add integration tests to prove everything works before deployment.

Your API works when you test it manually with curl. But manual testing does not scale. When you change the auth middleware tomorrow, how do you know you did not break the bookmark endpoints?

In this step, you will add automated integration tests using Vitest and Supertest. These tests call your Express app directly (no server needed), verify response shapes with Zod, and run in your CI pipeline to catch regressions before they reach production.

In this step, get ready to:
  • Install Vitest and Supertest for fast integration testing.
  • Write tests for public and protected endpoints.
  • Add Zod for runtime response contract validation.
Install testing packages
  • Install the testing stack by running:
npm install --save-dev vitest supertest @types/supertest zod
  • Add a test script to package.json:
"test": "vitest run"

Senior pattern: test pyramid

Senior engineers think in terms of the test pyramid: many fast unit tests at the base, fewer integration tests in the middle, and very few end-to-end tests at the top. Your integration tests sit in the middle. They are slower than unit tests (they hit real middleware and routing) but faster than E2E tests (no real HTTP server or database needed).

The key insight: test at the highest level that remains fast. Integration tests through Supertest exercise your entire middleware stack, routing, validation, and auth in one call. If they pass, you have high confidence the API works.

💡 Senior pattern: contract testing with Zod

TypeScript types vanish at runtime. Your BookmarkItem interface guarantees shape at compile time, but a bug in your route handler could return null or an extra field and TypeScript would not catch it.

Zod validates the actual runtime response shape in your tests. If the response does not match the schema, the test fails. This is contract testing: proving that your API returns exactly what your types promise.

  • Run your test suite by running:
npm test

You should see all tests pass with green checkmarks.

  • Commit your test suite by running:
git add .
git commit -m "feat: add integration tests with Vitest and Supertest"
git push

Your API now has automated tests that catch regressions before deployment. Next, you will swap SQLite for DynamoDB to prepare for serverless deployment.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.

💡 Senior pattern: separation of concerns

Senior engineers think about code in layers. Your project now has three: the route layer (HTTP concerns), the service layer (business logic), and the data layer (database operations). Keeping these separate means you can swap DynamoDB for PostgreSQL without touching your routes, or add a caching layer without changing business logic.

This separation is called the repository pattern. In production codebases, you would extract DynamoDB calls into a BookmarkRepository class, making the routes testable with a mock repository.

Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

💡 Senior pattern: fail-fast configuration

Notice you read TABLE_NAME from an environment variable with a fallback default. Senior engineers validate ALL configuration at startup. If a required variable is missing, the app should fail immediately with a clear error rather than crashing minutes later on first database call.

In production, you would use a library like Zod to validate env vars at startup and export typed config. This pattern is called fail-fast configuration. If TABLE_NAME is undefined in Lambda, you want to know in the first millisecond, not after a user hits an endpoint.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

Senior pattern: entry point inversion

Senior engineers design for replaceability. By separating app.ts from index.ts, you gain three capabilities: Lambda and local dev share the exact same app code (no divergence). Integration tests can import app.ts directly with Supertest without starting a server. You can swap from Lambda to ECS or a VM by changing only the entry point.

This pattern is called dependency inversion. The app does not know or care how it gets invoked. The invoker (Lambda handler or local listener) depends on the app, not the other way around.

Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }

Senior pattern: least-privilege IAM

The IAM policy uses dynamodb:* for simplicity, but a senior engineer would narrow this to only the actions your code actually calls: GetItem, PutItem, Scan, DeleteItem, UpdateItem. This is the principle of least privilege.

If your Lambda is compromised, a wildcard policy lets the attacker drop your entire table. Narrowed permissions limit the blast radius. In production, you would also scope the Resource to include both the table ARN and its indexes (e.g. arn:...table/bookmarks/index/*).

💡 Senior pattern: infrastructure as cattle

Notice you never clicked through the AWS console to create these resources. Every piece of infrastructure is declared in code, version-controlled, and reproducible. If you delete everything with terraform destroy, you can recreate it identically with terraform apply.

This is the core insight of Infrastructure as Code: infrastructure is not a snowflake. It is disposable, repeatable, and auditable. Senior engineers treat servers like cattle, not pets. When something breaks, you rebuild from code rather than debugging a hand-configured resource.

💡 Senior pattern: immutable infrastructure

This is Infrastructure as Code (IaC). Senior engineers never create resources by clicking through the AWS console. Console clicks are unrepeatable, undocumented, and impossible to review. Terraform files are version-controlled, peer-reviewed in PRs, and can recreate your entire stack from scratch in minutes.

The principle of least privilege in the IAM policy grants dynamodb:* only on the bookmarks table ARN, not all DynamoDB tables. In production, you would narrow this further to specific actions like GetItem, PutItem, Scan, DeleteItem.

Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

Your API is live, but when something goes wrong at 3 AM, how do you know? Right now, your logger writes to stdout which Lambda forwards to CloudWatch. But unstructured text logs are nearly impossible to query at scale.

In this step, you will replace console.log with structured logging (JSON), add correlation IDs to trace requests across log lines, and set up a CloudWatch alarm that alerts you when errors spike.

In this step, get ready to:
  • Replace console.log with structured JSON logging.
  • Add request correlation IDs (X-Request-ID header).
  • Create a CloudWatch alarm for 5xx error rate.

Senior pattern: structured logging and correlation IDs

Senior engineers follow one rule for production logs: every log line is a JSON object. Structured logs let you query with CloudWatch Logs Insights: "show me all requests where statusCode >= 500 in the last hour, grouped by path". You cannot do this with plain text logs.

A correlation ID is a UUID generated at the start of each request and included in every log line for that request. When a user reports "my bookmark didn't save", you search by their correlation ID and see the complete request lifecycle: middleware entry, validation, DynamoDB call, error, response.

  • Commit your observability improvements by running:
git add .
git commit -m "feat: add structured logging and CloudWatch alarm"
git push

Your API now produces queryable logs and alerts you when errors spike. Next, you will learn how to evolve your database schema without downtime.

Your DynamoDB table has been running in production with real data. Now you need to add a feature: searching bookmarks by title. DynamoDB cannot efficiently query on non-key attributes without a Global Secondary Index (GSI). Adding a GSI to a live table is a schema migration.

In this step, you will learn how to evolve a live database schema without downtime: add a GSI via Terraform, write a backfill script for existing data, and understand the blue/green migration pattern.

In this step, get ready to:
  • Add a GSI to your DynamoDB table via Terraform.
  • Write a backfill script that indexes existing bookmarks.
  • Update your search query to use the new index.

Senior pattern: zero-downtime schema evolution

In relational databases, migrations mean ALTER TABLE. In DynamoDB, the primary key is immutable after table creation. You evolve the schema by adding GSIs (new ways to query existing data), adding new attributes to items, or creating new tables and migrating data.

The senior pattern is additive-only migrations: add the new index, backfill it, switch reads to the new index, then (optionally) remove the old access pattern. Never delete an attribute or index that existing code depends on. This is how you achieve zero-downtime schema evolution.

💡 Senior pattern: idempotent backfills

A backfill re-processes existing data to populate a new index or attribute. Senior engineers write backfills as idempotent scripts that can be safely re-run. If the backfill crashes halfway through, you restart it and it picks up where it left off (using pagination tokens). Never run a backfill that mutates data non-idempotently.

  • Commit your migration by running:
git add .
git commit -m "feat: add title GSI for search with backfill script"
git push

Your database schema can now evolve safely alongside your application code. Next, you will automate the entire pipeline with CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.

💡 Senior pattern: quality gates as code

This pipeline implements a core senior engineering principle: the deployment pipeline as a quality gate. No code reaches production without passing lint and type-check. The needs: quality dependency means a single failing rule blocks the entire deploy.

In larger teams, you would add integration tests, security scanning (SAST/DAST), and a terraform plan output posted as a PR comment so reviewers can see infrastructure changes before approving the merge. The key insight: make the right thing easy and the wrong thing hard. If pushing broken code is physically blocked, the team ships fewer bugs.

💡 Senior pattern: environment promotion

Your workflow deploys directly to production on merge. Senior teams add an intermediate step: deploy to a staging environment first, run smoke tests against it, then promote to production. You would do this by adding a terraform workspace for each environment (staging/prod) and a manual approval step between them.

Another advanced pattern is trunk-based development: every engineer merges to main multiple times per day via small PRs. The CI pipeline runs fast (under 5 minutes), and feature flags control what users see. This eliminates long-lived branches and merge conflicts.

💡 Senior pattern: shift-left quality gates

This pipeline implements the trunk-based development pattern that senior engineers use at companies like Google and Netflix. The rules are simple: main is always deployable, all work happens on short-lived branches, merging to main triggers production deployment. No staging environment, no manual QA gate.

The needs: quality dependency creates a quality gate. Code that fails lint or type-check physically cannot deploy. This shifts errors left: you catch them in seconds on a PR, not in hours during a production incident.

💡 Extra for Experts: production pipeline patterns

In production systems, senior engineers add these pipeline enhancements. Each one is a separate improvement you can add later:

  • Rollback strategy: If deploy fails, automatically revert to the previous Lambda version using aliases.
  • Environment promotion: Deploy to staging first, run smoke tests, then promote to production.
  • OIDC authentication: Replace static AWS keys with short-lived tokens from GitHub's OIDC provider. No long-lived secrets.
  • Terraform plan on PRs: Post the terraform plan output as a PR comment so reviewers can see infrastructure changes before merge.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API accepts requests from anyone at any speed with no origin restrictions. In production, this means a single bad actor can overwhelm your server with thousands of requests per second, and any website can call your endpoints without permission.

In this step, you will add three layers of production hardening: rate limiting to throttle abusive clients, CORS to control which origins can call your API, and typed error classes that give every error a consistent shape.

In this step, get ready to:
  • Add rate limiting, CORS, and Helmet security headers.
  • Create typed custom error classes for consistent error responses.
  • Add input sanitization to prevent stored XSS attacks.
Install security packages and configure rate limiting
  • Install the security packages by running:
npm install cors helmet express-rate-limit
npm install --save-dev @types/cors
  • Create src/middleware/security.ts with rate limiting and CORS configuration:
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';

// Allow requests from any origin (restrict in production)
export const corsMiddleware = cors({
  origin: '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
});

// Set security headers (XSS protection, HSTS, etc.)
export const securityHeaders = helmet();

// Limit each IP to 100 requests per 15-minute window
export const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later' },
});

Senior pattern: defense in depth

Senior engineers implement defense in depth: multiple independent security layers that each catch different threats. CORS prevents unauthorized browser clients. Rate limiting prevents DDoS and brute-force attacks. Helmet sets headers that prevent clickjacking, XSS, and protocol downgrade attacks.

No single layer is sufficient. A determined attacker bypasses CORS (it only works in browsers). Rate limiting alone does not prevent credential stuffing. Helmet alone does not stop application-level abuse. Together, they make attacks significantly harder.

Create typed error classes

Right now, your error handler uses raw objects. Senior engineers use custom error classes so every error carries a typed status code and message.

  • Create src/errors.ts with typed error classes:
// Base class for all API errors
export class AppError extends Error {
  public readonly statusCode: number;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
    this.name = this.constructor.name;
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 404);
  }
}

export class ValidationError extends AppError {
  constructor(message: string) {
    super(message, 400);
  }
}

export class AuthenticationError extends AppError {
  constructor() {
    super('Authentication required', 401);
  }
}

export class RateLimitError extends AppError {
  constructor() {
    super('Too many requests', 429);
  }
}

Senior pattern: error categorization

Typed error classes give you compile-time guarantees about your error handling. Instead of scattering res.status(404).json({...}) across 20 route handlers, you throw new NotFoundError('Bookmark') and the centralized error handler formats it consistently.

This is the single responsibility principle applied to errors: routes decide WHAT went wrong, the error handler decides HOW to respond. When you later add structured logging, every error automatically gets logged with its class name, status code, and stack trace.

  • Register the security middleware in src/index.ts before your routes. Add these lines after app.use(express.json()):
import { corsMiddleware, securityHeaders, limiter } from './middleware/security.ts';

app.use(securityHeaders);
app.use(corsMiddleware);
app.use(limiter);
  • Save all files and test rate limiting by running:
curl -I http://localhost:3000/bookmarks

You should see X-RateLimit-Limit: 100 and X-RateLimit-Remaining: 99 in the response headers. Security headers from Helmet will also appear.

  • Commit your security hardening by running:
git add .
git commit -m "feat: add rate limiting, CORS, and typed error classes"
git push

Your API now rejects abusive traffic, controls which origins can call it, and returns consistent typed errors. Next, you will add authentication so only registered users can create bookmarks.

Your API is open to everyone. Anyone with the URL can create, read, update, and delete bookmarks. In production, you need to know WHO is making each request and restrict access to only authenticated users.

In this step, you will add JWT (JSON Web Token) authentication: a sign-up endpoint that creates users, a login endpoint that returns a token, and a middleware that protects routes from unauthenticated access. You will also implement idempotency keys to prevent duplicate bookmark creation on retries.

In this step, get ready to:
  • Create sign-up and login endpoints with bcrypt password hashing.
  • Build a verifyToken middleware that protects bookmark routes.
  • Add idempotency keys to prevent duplicate POST requests.
Install authentication packages
  • Install JWT and bcrypt packages by running:
npm install jsonwebtoken bcryptjs
npm install --save-dev @types/jsonwebtoken @types/bcryptjs

Senior pattern: stateless auth vs sessions

JWTs are stateless. The server does not store sessions in memory or a database. Instead, the token itself contains the user's identity (signed cryptographically). The server verifies the signature on every request. This makes JWT perfect for serverless: no shared session store needed across Lambda invocations.

The trade-off: you cannot revoke a JWT before it expires without adding a blocklist (which reintroduces state). Senior engineers choose short expiry times (15 min) + refresh tokens as the production pattern.

💡 Senior pattern: idempotency

Your POST endpoint creates a new bookmark every time it is called. If a client retries a failed request (network timeout, 502 from API Gateway), you get a duplicate. Senior engineers solve this with idempotency keys: the client sends a unique key with each request. If the server sees the same key twice, it returns the original response without creating a duplicate.

Stripe, PayPal, and every payment API uses this pattern. For your bookmark API, you would store the idempotency key in DynamoDB with a TTL. If a duplicate arrives within the TTL window, return the cached response.

  • Commit your authentication layer by running:
git add .
git commit -m "feat: add JWT authentication with protected routes"
git push

Your API now authenticates users and protects routes. Next, you will add integration tests to prove everything works before deployment.

Your API works when you test it manually with curl. But manual testing does not scale. When you change the auth middleware tomorrow, how do you know you did not break the bookmark endpoints?

In this step, you will add automated integration tests using Vitest and Supertest. These tests call your Express app directly (no server needed), verify response shapes with Zod, and run in your CI pipeline to catch regressions before they reach production.

In this step, get ready to:
  • Install Vitest and Supertest for fast integration testing.
  • Write tests for public and protected endpoints.
  • Add Zod for runtime response contract validation.
Install testing packages
  • Install the testing stack by running:
npm install --save-dev vitest supertest @types/supertest zod
  • Add a test script to package.json:
"test": "vitest run"

Senior pattern: test pyramid

Senior engineers think in terms of the test pyramid: many fast unit tests at the base, fewer integration tests in the middle, and very few end-to-end tests at the top. Your integration tests sit in the middle. They are slower than unit tests (they hit real middleware and routing) but faster than E2E tests (no real HTTP server or database needed).

The key insight: test at the highest level that remains fast. Integration tests through Supertest exercise your entire middleware stack, routing, validation, and auth in one call. If they pass, you have high confidence the API works.

💡 Senior pattern: contract testing with Zod

TypeScript types vanish at runtime. Your BookmarkItem interface guarantees shape at compile time, but a bug in your route handler could return null or an extra field and TypeScript would not catch it.

Zod validates the actual runtime response shape in your tests. If the response does not match the schema, the test fails. This is contract testing: proving that your API returns exactly what your types promise.

  • Run your test suite by running:
npm test

You should see all tests pass with green checkmarks.

  • Commit your test suite by running:
git add .
git commit -m "feat: add integration tests with Vitest and Supertest"
git push

Your API now has automated tests that catch regressions before deployment. Next, you will swap SQLite for DynamoDB to prepare for serverless deployment.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.

💡 Senior pattern: separation of concerns

Senior engineers think about code in layers. Your project now has three: the route layer (HTTP concerns), the service layer (business logic), and the data layer (database operations). Keeping these separate means you can swap DynamoDB for PostgreSQL without touching your routes, or add a caching layer without changing business logic.

This separation is called the repository pattern. In production codebases, you would extract DynamoDB calls into a BookmarkRepository class, making the routes testable with a mock repository.

Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

💡 Senior pattern: fail-fast configuration

Notice you read TABLE_NAME from an environment variable with a fallback default. Senior engineers validate ALL configuration at startup. If a required variable is missing, the app should fail immediately with a clear error rather than crashing minutes later on first database call.

In production, you would use a library like Zod to validate env vars at startup and export typed config. This pattern is called fail-fast configuration. If TABLE_NAME is undefined in Lambda, you want to know in the first millisecond, not after a user hits an endpoint.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

Senior pattern: entry point inversion

Senior engineers design for replaceability. By separating app.ts from index.ts, you gain three capabilities: Lambda and local dev share the exact same app code (no divergence). Integration tests can import app.ts directly with Supertest without starting a server. You can swap from Lambda to ECS or a VM by changing only the entry point.

This pattern is called dependency inversion. The app does not know or care how it gets invoked. The invoker (Lambda handler or local listener) depends on the app, not the other way around.

Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }

Senior pattern: least-privilege IAM

The IAM policy uses dynamodb:* for simplicity, but a senior engineer would narrow this to only the actions your code actually calls: GetItem, PutItem, Scan, DeleteItem, UpdateItem. This is the principle of least privilege.

If your Lambda is compromised, a wildcard policy lets the attacker drop your entire table. Narrowed permissions limit the blast radius. In production, you would also scope the Resource to include both the table ARN and its indexes (e.g. arn:...table/bookmarks/index/*).

💡 Senior pattern: infrastructure as cattle

Notice you never clicked through the AWS console to create these resources. Every piece of infrastructure is declared in code, version-controlled, and reproducible. If you delete everything with terraform destroy, you can recreate it identically with terraform apply.

This is the core insight of Infrastructure as Code: infrastructure is not a snowflake. It is disposable, repeatable, and auditable. Senior engineers treat servers like cattle, not pets. When something breaks, you rebuild from code rather than debugging a hand-configured resource.

💡 Senior pattern: immutable infrastructure

This is Infrastructure as Code (IaC). Senior engineers never create resources by clicking through the AWS console. Console clicks are unrepeatable, undocumented, and impossible to review. Terraform files are version-controlled, peer-reviewed in PRs, and can recreate your entire stack from scratch in minutes.

The principle of least privilege in the IAM policy grants dynamodb:* only on the bookmarks table ARN, not all DynamoDB tables. In production, you would narrow this further to specific actions like GetItem, PutItem, Scan, DeleteItem.

Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

Your API is live, but when something goes wrong at 3 AM, how do you know? Right now, your logger writes to stdout which Lambda forwards to CloudWatch. But unstructured text logs are nearly impossible to query at scale.

In this step, you will replace console.log with structured logging (JSON), add correlation IDs to trace requests across log lines, and set up a CloudWatch alarm that alerts you when errors spike.

In this step, get ready to:
  • Replace console.log with structured JSON logging.
  • Add request correlation IDs (X-Request-ID header).
  • Create a CloudWatch alarm for 5xx error rate.

Senior pattern: structured logging and correlation IDs

Senior engineers follow one rule for production logs: every log line is a JSON object. Structured logs let you query with CloudWatch Logs Insights: "show me all requests where statusCode >= 500 in the last hour, grouped by path". You cannot do this with plain text logs.

A correlation ID is a UUID generated at the start of each request and included in every log line for that request. When a user reports "my bookmark didn't save", you search by their correlation ID and see the complete request lifecycle: middleware entry, validation, DynamoDB call, error, response.

  • Commit your observability improvements by running:
git add .
git commit -m "feat: add structured logging and CloudWatch alarm"
git push

Your API now produces queryable logs and alerts you when errors spike. Next, you will learn how to evolve your database schema without downtime.

Your DynamoDB table has been running in production with real data. Now you need to add a feature: searching bookmarks by title. DynamoDB cannot efficiently query on non-key attributes without a Global Secondary Index (GSI). Adding a GSI to a live table is a schema migration.

In this step, you will learn how to evolve a live database schema without downtime: add a GSI via Terraform, write a backfill script for existing data, and understand the blue/green migration pattern.

In this step, get ready to:
  • Add a GSI to your DynamoDB table via Terraform.
  • Write a backfill script that indexes existing bookmarks.
  • Update your search query to use the new index.

Senior pattern: zero-downtime schema evolution

In relational databases, migrations mean ALTER TABLE. In DynamoDB, the primary key is immutable after table creation. You evolve the schema by adding GSIs (new ways to query existing data), adding new attributes to items, or creating new tables and migrating data.

The senior pattern is additive-only migrations: add the new index, backfill it, switch reads to the new index, then (optionally) remove the old access pattern. Never delete an attribute or index that existing code depends on. This is how you achieve zero-downtime schema evolution.

💡 Senior pattern: idempotent backfills

A backfill re-processes existing data to populate a new index or attribute. Senior engineers write backfills as idempotent scripts that can be safely re-run. If the backfill crashes halfway through, you restart it and it picks up where it left off (using pagination tokens). Never run a backfill that mutates data non-idempotently.

  • Commit your migration by running:
git add .
git commit -m "feat: add title GSI for search with backfill script"
git push

Your database schema can now evolve safely alongside your application code. Next, you will automate the entire pipeline with CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.

💡 Senior pattern: quality gates as code

This pipeline implements a core senior engineering principle: the deployment pipeline as a quality gate. No code reaches production without passing lint and type-check. The needs: quality dependency means a single failing rule blocks the entire deploy.

In larger teams, you would add integration tests, security scanning (SAST/DAST), and a terraform plan output posted as a PR comment so reviewers can see infrastructure changes before approving the merge. The key insight: make the right thing easy and the wrong thing hard. If pushing broken code is physically blocked, the team ships fewer bugs.

💡 Senior pattern: environment promotion

Your workflow deploys directly to production on merge. Senior teams add an intermediate step: deploy to a staging environment first, run smoke tests against it, then promote to production. You would do this by adding a terraform workspace for each environment (staging/prod) and a manual approval step between them.

Another advanced pattern is trunk-based development: every engineer merges to main multiple times per day via small PRs. The CI pipeline runs fast (under 5 minutes), and feature flags control what users see. This eliminates long-lived branches and merge conflicts.

💡 Senior pattern: shift-left quality gates

This pipeline implements the trunk-based development pattern that senior engineers use at companies like Google and Netflix. The rules are simple: main is always deployable, all work happens on short-lived branches, merging to main triggers production deployment. No staging environment, no manual QA gate.

The needs: quality dependency creates a quality gate. Code that fails lint or type-check physically cannot deploy. This shifts errors left: you catch them in seconds on a PR, not in hours during a production incident.

💡 Extra for Experts: production pipeline patterns

In production systems, senior engineers add these pipeline enhancements. Each one is a separate improvement you can add later:

  • Rollback strategy: If deploy fails, automatically revert to the previous Lambda version using aliases.
  • Environment promotion: Deploy to staging first, run smoke tests, then promote to production.
  • OIDC authentication: Replace static AWS keys with short-lived tokens from GitHub's OIDC provider. No long-lived secrets.
  • Terraform plan on PRs: Post the terraform plan output as a PR comment so reviewers can see infrastructure changes before merge.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API accepts requests from anyone at any speed with no origin restrictions. In production, this means a single bad actor can overwhelm your server with thousands of requests per second, and any website can call your endpoints without permission.

In this step, you will add three layers of production hardening: rate limiting to throttle abusive clients, CORS to control which origins can call your API, and typed error classes that give every error a consistent shape.

In this step, get ready to:
  • Add rate limiting, CORS, and Helmet security headers.
  • Create typed custom error classes for consistent error responses.
  • Add input sanitization to prevent stored XSS attacks.
Install security packages and configure rate limiting
  • Install the security packages by running:
npm install cors helmet express-rate-limit
npm install --save-dev @types/cors
  • Create src/middleware/security.ts with rate limiting and CORS configuration:
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';

// Allow requests from any origin (restrict in production)
export const corsMiddleware = cors({
  origin: '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
});

// Set security headers (XSS protection, HSTS, etc.)
export const securityHeaders = helmet();

// Limit each IP to 100 requests per 15-minute window
export const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later' },
});

Senior pattern: defense in depth

Senior engineers implement defense in depth: multiple independent security layers that each catch different threats. CORS prevents unauthorized browser clients. Rate limiting prevents DDoS and brute-force attacks. Helmet sets headers that prevent clickjacking, XSS, and protocol downgrade attacks.

No single layer is sufficient. A determined attacker bypasses CORS (it only works in browsers). Rate limiting alone does not prevent credential stuffing. Helmet alone does not stop application-level abuse. Together, they make attacks significantly harder.

Create typed error classes

Right now, your error handler uses raw objects. Senior engineers use custom error classes so every error carries a typed status code and message.

  • Create src/errors.ts with typed error classes:
// Base class for all API errors
export class AppError extends Error {
  public readonly statusCode: number;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
    this.name = this.constructor.name;
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 404);
  }
}

export class ValidationError extends AppError {
  constructor(message: string) {
    super(message, 400);
  }
}

export class AuthenticationError extends AppError {
  constructor() {
    super('Authentication required', 401);
  }
}

export class RateLimitError extends AppError {
  constructor() {
    super('Too many requests', 429);
  }
}

Senior pattern: error categorization

Typed error classes give you compile-time guarantees about your error handling. Instead of scattering res.status(404).json({...}) across 20 route handlers, you throw new NotFoundError('Bookmark') and the centralized error handler formats it consistently.

This is the single responsibility principle applied to errors: routes decide WHAT went wrong, the error handler decides HOW to respond. When you later add structured logging, every error automatically gets logged with its class name, status code, and stack trace.

  • Register the security middleware in src/index.ts before your routes. Add these lines after app.use(express.json()):
import { corsMiddleware, securityHeaders, limiter } from './middleware/security.ts';

app.use(securityHeaders);
app.use(corsMiddleware);
app.use(limiter);
  • Save all files and test rate limiting by running:
curl -I http://localhost:3000/bookmarks

You should see X-RateLimit-Limit: 100 and X-RateLimit-Remaining: 99 in the response headers. Security headers from Helmet will also appear.

  • Commit your security hardening by running:
git add .
git commit -m "feat: add rate limiting, CORS, and typed error classes"
git push

Your API now rejects abusive traffic, controls which origins can call it, and returns consistent typed errors. Next, you will add authentication so only registered users can create bookmarks.

Your API is open to everyone. Anyone with the URL can create, read, update, and delete bookmarks. In production, you need to know WHO is making each request and restrict access to only authenticated users.

In this step, you will add JWT (JSON Web Token) authentication: a sign-up endpoint that creates users, a login endpoint that returns a token, and a middleware that protects routes from unauthenticated access. You will also implement idempotency keys to prevent duplicate bookmark creation on retries.

In this step, get ready to:
  • Create sign-up and login endpoints with bcrypt password hashing.
  • Build a verifyToken middleware that protects bookmark routes.
  • Add idempotency keys to prevent duplicate POST requests.
Install authentication packages
  • Install JWT and bcrypt packages by running:
npm install jsonwebtoken bcryptjs
npm install --save-dev @types/jsonwebtoken @types/bcryptjs

Senior pattern: stateless auth vs sessions

JWTs are stateless. The server does not store sessions in memory or a database. Instead, the token itself contains the user's identity (signed cryptographically). The server verifies the signature on every request. This makes JWT perfect for serverless: no shared session store needed across Lambda invocations.

The trade-off: you cannot revoke a JWT before it expires without adding a blocklist (which reintroduces state). Senior engineers choose short expiry times (15 min) + refresh tokens as the production pattern.

💡 Senior pattern: idempotency

Your POST endpoint creates a new bookmark every time it is called. If a client retries a failed request (network timeout, 502 from API Gateway), you get a duplicate. Senior engineers solve this with idempotency keys: the client sends a unique key with each request. If the server sees the same key twice, it returns the original response without creating a duplicate.

Stripe, PayPal, and every payment API uses this pattern. For your bookmark API, you would store the idempotency key in DynamoDB with a TTL. If a duplicate arrives within the TTL window, return the cached response.

  • Commit your authentication layer by running:
git add .
git commit -m "feat: add JWT authentication with protected routes"
git push

Your API now authenticates users and protects routes. Next, you will add integration tests to prove everything works before deployment.

Your API works when you test it manually with curl. But manual testing does not scale. When you change the auth middleware tomorrow, how do you know you did not break the bookmark endpoints?

In this step, you will add automated integration tests using Vitest and Supertest. These tests call your Express app directly (no server needed), verify response shapes with Zod, and run in your CI pipeline to catch regressions before they reach production.

In this step, get ready to:
  • Install Vitest and Supertest for fast integration testing.
  • Write tests for public and protected endpoints.
  • Add Zod for runtime response contract validation.
Install testing packages
  • Install the testing stack by running:
npm install --save-dev vitest supertest @types/supertest zod
  • Add a test script to package.json:
"test": "vitest run"

Senior pattern: test pyramid

Senior engineers think in terms of the test pyramid: many fast unit tests at the base, fewer integration tests in the middle, and very few end-to-end tests at the top. Your integration tests sit in the middle. They are slower than unit tests (they hit real middleware and routing) but faster than E2E tests (no real HTTP server or database needed).

The key insight: test at the highest level that remains fast. Integration tests through Supertest exercise your entire middleware stack, routing, validation, and auth in one call. If they pass, you have high confidence the API works.

💡 Senior pattern: contract testing with Zod

TypeScript types vanish at runtime. Your BookmarkItem interface guarantees shape at compile time, but a bug in your route handler could return null or an extra field and TypeScript would not catch it.

Zod validates the actual runtime response shape in your tests. If the response does not match the schema, the test fails. This is contract testing: proving that your API returns exactly what your types promise.

  • Run your test suite by running:
npm test

You should see all tests pass with green checkmarks.

  • Commit your test suite by running:
git add .
git commit -m "feat: add integration tests with Vitest and Supertest"
git push

Your API now has automated tests that catch regressions before deployment. Next, you will swap SQLite for DynamoDB to prepare for serverless deployment.

Your API works locally with SQLite, but SQLite stores data in a file on disk. AWS Lambda runs your code in ephemeral containers that can be destroyed at any time. A file-based database would lose data between invocations.

In this step, you will swap SQLite for DynamoDB, a fully managed NoSQL database that is serverless, scales automatically, and works perfectly with Lambda. The API contract stays the same. Only the storage layer changes.

In this step, get ready to:
  • Install the AWS SDK for DynamoDB and create a typed database client.
  • Rewrite your routes to use DynamoDB operations instead of SQL queries.
  • Test that your API still passes the same curl commands as before.
Install the AWS SDK and create a DynamoDB client

The AWS SDK v3 uses modular packages. You only install the DynamoDB pieces you need, keeping your Lambda deployment small.

  • Install the DynamoDB SDK packages by running:
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
  • Remove the SQLite packages you no longer need:
npm uninstall better-sqlite3
npm uninstall --save-dev @types/better-sqlite3
  • Delete the old database file and module:
rm -rf src/db bookmarks.db

Now create a new DynamoDB client module.

  • Create a new folder and file by running:
mkdir src/db
touch src/db/index.ts
  • Open src/db/index.ts and paste this code:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

// Define the shape of a bookmark stored in DynamoDB
export interface BookmarkItem {
  id: string;
  url: string;
  title: string;
  description: string;
  created_at: string;
}

// Table name read from environment variable
export const TABLE_NAME: string = process.env.TABLE_NAME || 'bookmarks';

// Create the low-level DynamoDB client
const client = new DynamoDBClient({});

// Wrap it with the Document Client for simpler read/write
const docClient = DynamoDBDocumentClient.from(client);

export default docClient;

What changed from SQLite?

  • DynamoDBDocumentClient handles marshalling JavaScript objects to DynamoDB's wire format automatically. No manual type conversions needed.
  • The TABLE_NAME comes from an environment variable so Terraform can inject it at deploy time.
  • The id field changes from a number to a string. DynamoDB uses string partition keys, and you will generate UUIDs.

💡 Senior pattern: separation of concerns

Senior engineers think about code in layers. Your project now has three: the route layer (HTTP concerns), the service layer (business logic), and the data layer (database operations). Keeping these separate means you can swap DynamoDB for PostgreSQL without touching your routes, or add a caching layer without changing business logic.

This separation is called the repository pattern. In production codebases, you would extract DynamoDB calls into a BookmarkRepository class, making the routes testable with a mock repository.

Rewrite routes for DynamoDB

DynamoDB uses operations like PutCommand, GetCommand, ScanCommand, and DeleteCommand instead of SQL. The route structure stays identical.

  • Open src/routes/bookmarks.ts and replace the entire file with:
import express, { type Request, type Response } from 'express';
import { randomUUID } from 'crypto';
import { PutCommand, GetCommand, ScanCommand, UpdateCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
import docClient, { TABLE_NAME, type BookmarkItem } from '../db/index.ts';
import { validateBookmark } from '../middleware/validate.ts';

const router = express.Router();

// GET / returns all bookmarks
router.get('/', async (req: Request, res: Response) => {
  const result = await docClient.send(new ScanCommand({ TableName: TABLE_NAME }));
  res.json(result.Items || []);
});

// GET /:id returns a single bookmark
router.get('/:id', async (req: Request, res: Response) => {
  const result = await docClient.send(new GetCommand({
    TableName: TABLE_NAME,
    Key: { id: req.params.id },
  }));
  if (!result.Item) {
    return res.status(404).json({ error: 'Bookmark not found' });
  }
  res.json(result.Item);
});

// POST / creates a new bookmark
router.post('/', validateBookmark, async (req: Request, res: Response) => {
  const { url, title, description } = req.body as { url: string; title: string; description?: string };
  const bookmark: BookmarkItem = {
    id: randomUUID(),
    url,
    title,
    description: description || '',
    created_at: new Date().toISOString(),
  };
  await docClient.send(new PutCommand({ TableName: TABLE_NAME, Item: bookmark }));
  res.status(201).json(bookmark);
});

export default router;

Why async/await now?

SQLite with better-sqlite3 was synchronous. DynamoDB calls go over the network, so they return Promises. Express 5 automatically catches rejected promises and forwards them to your error handler.

💡 Senior pattern: fail-fast configuration

Notice you read TABLE_NAME from an environment variable with a fallback default. Senior engineers validate ALL configuration at startup. If a required variable is missing, the app should fail immediately with a clear error rather than crashing minutes later on first database call.

In production, you would use a library like Zod to validate env vars at startup and export typed config. This pattern is called fail-fast configuration. If TABLE_NAME is undefined in Lambda, you want to know in the first millisecond, not after a user hits an endpoint.

  • Save src/routes/bookmarks.ts.
  • Update .gitignore to remove the SQLite entry and add Terraform state:
node_modules/
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
  • Commit your changes by running:
git add .
git commit -m "feat: replace SQLite with DynamoDB client

- Remove better-sqlite3 dependency
- Add AWS SDK v3 DynamoDB packages
- Rewrite routes to use Document Client operations
- IDs are now UUIDs for DynamoDB partition keys"
git push

Your API now talks to DynamoDB instead of a local file. Next up, you will deploy this to AWS Lambda with Terraform so it runs in the cloud.

Your API runs locally and talks to DynamoDB. But nobody else can reach it. To serve real traffic, you need it running in the cloud behind a public URL.

In this step, you will use Terraform to declare your AWS infrastructure as code: a Lambda function, an API Gateway with a public URL, a DynamoDB table, and IAM roles.

In this step, get ready to:
  • Wrap your Express app with serverless-express for Lambda.
  • Write Terraform config for Lambda, API Gateway, DynamoDB, and IAM.
  • Deploy with terraform apply and test your live URL.
Add the Lambda handler
  • Install the serverless-express adapter by running:
npm install @codegenie/serverless-express
  • Create src/lambda.ts, src/app.ts, and update src/index.ts:
import serverlessExpress from '@codegenie/serverless-express';
import { app } from './app.ts';

export const handler = serverlessExpress({ app });
import express from 'express';
import { logger } from './middleware/logger.ts';
import { notFound, errorHandler } from './middleware/errors.ts';
import bookmarksRouter from './routes/bookmarks.ts';

export const app = express();

app.use(express.json());
app.use(logger);
app.use('/bookmarks', bookmarksRouter);
app.use(notFound);
app.use(errorHandler);
import { app } from './app.ts';

const port: number = 3000;

app.listen(port, () => {
  console.log(`Bookmark API running at http://localhost:${port}`);
});

Senior pattern: entry point inversion

Senior engineers design for replaceability. By separating app.ts from index.ts, you gain three capabilities: Lambda and local dev share the exact same app code (no divergence). Integration tests can import app.ts directly with Supertest without starting a server. You can swap from Lambda to ECS or a VM by changing only the entry point.

This pattern is called dependency inversion. The app does not know or care how it gets invoked. The invoker (Lambda handler or local listener) depends on the app, not the other way around.

Write the Terraform configuration
  • Create main.tf with the provider, DynamoDB table, and IAM role:
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "bookmark-api-tf-state"
    key    = "terraform.tfstate"
    region = "us-east-1"
  }
}
provider "aws" { region = "us-east-1" }

resource "aws_dynamodb_table" "bookmarks" {
  name         = "bookmarks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"
  attribute { name = "id"; type = "S" }
}

resource "aws_iam_role" "lambda_role" {
  name = "bookmark-api-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole", Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow", Action = ["dynamodb:*"]
      Resource = aws_dynamodb_table.bookmarks.arn
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
  • Create lambda.tf with the Lambda function and API Gateway:
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/dist"
  output_path = "${path.module}/lambda.zip"
}
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda_zip.output_path
  function_name    = "bookmark-api"
  role             = aws_iam_role.lambda_role.arn
  handler          = "lambda.handler"
  runtime          = "nodejs22.x"
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  timeout          = 10
  environment { variables = { TABLE_NAME = aws_dynamodb_table.bookmarks.name } }
}
resource "aws_apigatewayv2_api" "api" {
  name = "bookmark-api"
  protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "lambda" {
  api_id = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "prod" {
  api_id = aws_apigatewayv2_api.api.id
  name   = "$default"
  auto_deploy = true
}
resource "aws_lambda_permission" "apigw" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}
output "api_url" { value = aws_apigatewayv2_api.api.api_endpoint }

Senior pattern: least-privilege IAM

The IAM policy uses dynamodb:* for simplicity, but a senior engineer would narrow this to only the actions your code actually calls: GetItem, PutItem, Scan, DeleteItem, UpdateItem. This is the principle of least privilege.

If your Lambda is compromised, a wildcard policy lets the attacker drop your entire table. Narrowed permissions limit the blast radius. In production, you would also scope the Resource to include both the table ARN and its indexes (e.g. arn:...table/bookmarks/index/*).

💡 Senior pattern: infrastructure as cattle

Notice you never clicked through the AWS console to create these resources. Every piece of infrastructure is declared in code, version-controlled, and reproducible. If you delete everything with terraform destroy, you can recreate it identically with terraform apply.

This is the core insight of Infrastructure as Code: infrastructure is not a snowflake. It is disposable, repeatable, and auditable. Senior engineers treat servers like cattle, not pets. When something breaks, you rebuild from code rather than debugging a hand-configured resource.

💡 Senior pattern: immutable infrastructure

This is Infrastructure as Code (IaC). Senior engineers never create resources by clicking through the AWS console. Console clicks are unrepeatable, undocumented, and impossible to review. Terraform files are version-controlled, peer-reviewed in PRs, and can recreate your entire stack from scratch in minutes.

The principle of least privilege in the IAM policy grants dynamodb:* only on the bookmarks table ARN, not all DynamoDB tables. In production, you would narrow this further to specific actions like GetItem, PutItem, Scan, DeleteItem.

Build, deploy, and test
  • Create the S3 state bucket, build, and deploy by running:
aws s3 mb s3://bookmark-api-tf-state --region us-east-1
npm run build
terraform init
terraform apply

Type yes when Terraform shows the plan. Copy the api_url from the output.

  • Test your live API (replace the URL with your output):
curl -X POST https://YOUR_API_URL/bookmarks -H "Content-Type: application/json" -d '{"url":"https://expressjs.com","title":"Express Docs"}'

You should see a JSON bookmark with a UUID. Your API is live on the internet.

  • Commit and push by running:
git add .
git commit -m "feat: deploy to AWS Lambda with Terraform"
git push

Your API is deployed. Next, you will automate the build and deploy with GitHub Actions CI/CD.

Your API is live, but when something goes wrong at 3 AM, how do you know? Right now, your logger writes to stdout which Lambda forwards to CloudWatch. But unstructured text logs are nearly impossible to query at scale.

In this step, you will replace console.log with structured logging (JSON), add correlation IDs to trace requests across log lines, and set up a CloudWatch alarm that alerts you when errors spike.

In this step, get ready to:
  • Replace console.log with structured JSON logging.
  • Add request correlation IDs (X-Request-ID header).
  • Create a CloudWatch alarm for 5xx error rate.

Senior pattern: structured logging and correlation IDs

Senior engineers follow one rule for production logs: every log line is a JSON object. Structured logs let you query with CloudWatch Logs Insights: "show me all requests where statusCode >= 500 in the last hour, grouped by path". You cannot do this with plain text logs.

A correlation ID is a UUID generated at the start of each request and included in every log line for that request. When a user reports "my bookmark didn't save", you search by their correlation ID and see the complete request lifecycle: middleware entry, validation, DynamoDB call, error, response.

  • Commit your observability improvements by running:
git add .
git commit -m "feat: add structured logging and CloudWatch alarm"
git push

Your API now produces queryable logs and alerts you when errors spike. Next, you will learn how to evolve your database schema without downtime.

Your DynamoDB table has been running in production with real data. Now you need to add a feature: searching bookmarks by title. DynamoDB cannot efficiently query on non-key attributes without a Global Secondary Index (GSI). Adding a GSI to a live table is a schema migration.

In this step, you will learn how to evolve a live database schema without downtime: add a GSI via Terraform, write a backfill script for existing data, and understand the blue/green migration pattern.

In this step, get ready to:
  • Add a GSI to your DynamoDB table via Terraform.
  • Write a backfill script that indexes existing bookmarks.
  • Update your search query to use the new index.

Senior pattern: zero-downtime schema evolution

In relational databases, migrations mean ALTER TABLE. In DynamoDB, the primary key is immutable after table creation. You evolve the schema by adding GSIs (new ways to query existing data), adding new attributes to items, or creating new tables and migrating data.

The senior pattern is additive-only migrations: add the new index, backfill it, switch reads to the new index, then (optionally) remove the old access pattern. Never delete an attribute or index that existing code depends on. This is how you achieve zero-downtime schema evolution.

💡 Senior pattern: idempotent backfills

A backfill re-processes existing data to populate a new index or attribute. Senior engineers write backfills as idempotent scripts that can be safely re-run. If the backfill crashes halfway through, you restart it and it picks up where it left off (using pagination tokens). Never run a backfill that mutates data non-idempotently.

  • Commit your migration by running:
git add .
git commit -m "feat: add title GSI for search with backfill script"
git push

Your database schema can now evolve safely alongside your application code. Next, you will automate the entire pipeline with CI/CD.

You deployed manually with terraform apply. But in a real team, nobody runs deploy commands from their laptop. Every change goes through a pipeline that lints, type-checks, and deploys automatically.

In this step, you will create a GitHub Actions workflow that runs lint and typecheck on every PR, then runs terraform plan on PRs and terraform apply on merge to main.

In this step, get ready to:
  • Add ESLint with typescript-eslint for automated code quality checks.
  • Create a GitHub Actions workflow for lint, typecheck, and Terraform deploy.
  • Configure AWS credentials as GitHub secrets and trigger a deploy.
Add ESLint for automated linting
  • Install ESLint and the TypeScript plugin by running:
npm install --save-dev eslint @eslint/js typescript-eslint
  • Create eslint.config.ts in your project root:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      // Warn on unused variables (error in CI)
      '@typescript-eslint/no-unused-vars': 'warn',
    },
  },
  { ignores: ['dist/', 'node_modules/'] },
);
  • Add a lint script to package.json alongside your existing scripts:
"lint": "eslint src/"
  • Test your linter by running:
npm run lint

You should see no errors. If you do, fix them before continuing.

What is typescript-eslint?

ESLint alone only understands JavaScript. The typescript-eslint plugin teaches ESLint to parse TypeScript and enforce type-aware rules like catching unused variables, unsafe any usage, and missing return types.

Create the GitHub Actions workflow
  • Create the workflow directory and file by running:
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
  • Open .github/workflows/deploy.yml and paste this workflow:
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  deploy:
    needs: quality
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - uses: hashicorp/setup-terraform@v3
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
      - run: terraform init
      - run: terraform apply -auto-approve

What does this workflow do?

  • quality job runs on every push and PR. It installs dependencies, lints, and type-checks. If either fails, the PR is blocked.
  • deploy job only runs on pushes to main (after merge). It builds, configures AWS credentials from secrets, and runs terraform apply to deploy.
  • The needs: quality line means deploy only runs if lint and typecheck pass.

💡 Senior pattern: quality gates as code

This pipeline implements a core senior engineering principle: the deployment pipeline as a quality gate. No code reaches production without passing lint and type-check. The needs: quality dependency means a single failing rule blocks the entire deploy.

In larger teams, you would add integration tests, security scanning (SAST/DAST), and a terraform plan output posted as a PR comment so reviewers can see infrastructure changes before approving the merge. The key insight: make the right thing easy and the wrong thing hard. If pushing broken code is physically blocked, the team ships fewer bugs.

💡 Senior pattern: environment promotion

Your workflow deploys directly to production on merge. Senior teams add an intermediate step: deploy to a staging environment first, run smoke tests against it, then promote to production. You would do this by adding a terraform workspace for each environment (staging/prod) and a manual approval step between them.

Another advanced pattern is trunk-based development: every engineer merges to main multiple times per day via small PRs. The CI pipeline runs fast (under 5 minutes), and feature flags control what users see. This eliminates long-lived branches and merge conflicts.

💡 Senior pattern: shift-left quality gates

This pipeline implements the trunk-based development pattern that senior engineers use at companies like Google and Netflix. The rules are simple: main is always deployable, all work happens on short-lived branches, merging to main triggers production deployment. No staging environment, no manual QA gate.

The needs: quality dependency creates a quality gate. Code that fails lint or type-check physically cannot deploy. This shifts errors left: you catch them in seconds on a PR, not in hours during a production incident.

💡 Extra for Experts: production pipeline patterns

In production systems, senior engineers add these pipeline enhancements. Each one is a separate improvement you can add later:

  • Rollback strategy: If deploy fails, automatically revert to the previous Lambda version using aliases.
  • Environment promotion: Deploy to staging first, run smoke tests, then promote to production.
  • OIDC authentication: Replace static AWS keys with short-lived tokens from GitHub's OIDC provider. No long-lived secrets.
  • Terraform plan on PRs: Post the terraform plan output as a PR comment so reviewers can see infrastructure changes before merge.
Add AWS credentials to GitHub and trigger a deploy
  • Go to your bookmark-api repository on GitHub.
  • Click Settings, then Secrets and variables, then Actions.
  • Click New repository secret.
  • Add AWS_ACCESS_KEY_ID with your sandbox access key.
  • Add AWS_SECRET_ACCESS_KEY with your sandbox secret key.
  • Commit and push the workflow and ESLint config:
git add .
git commit -m "feat: add CI/CD with GitHub Actions

- Add ESLint with typescript-eslint strict config
- Lint and typecheck on every PR
- Auto-deploy to Lambda on merge to main"
git push

Go to the Actions tab in your GitHub repository. You should see the workflow running. The quality job lints and type-checks, then the deploy job builds and deploys to Lambda.

Workflow failing on the deploy step?

  • Verify both secrets are set correctly in your repository settings. The names must match exactly: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
  • Confirm your IAM user has permissions for Lambda, API Gateway, DynamoDB, IAM, and S3 (for Terraform state).

Help me debug my GitHub Actions deployment.

Your API now has a full CI/CD pipeline. Every PR gets linted and type-checked. Every merge to main automatically deploys to AWS. This is the workflow used at production companies.

Your API accepts requests from anyone at any speed with no origin restrictions. In production, this means a single bad actor can overwhelm your server with thousands of requests per second, and any website can call your endpoints without permission.

In this step, you will add three layers of production hardening: rate limiting to throttle abusive clients, CORS to control which origins can call your API, and typed error classes that give every error a consistent shape.

In this step, get ready to:
  • Add rate limiting, CORS, and Helmet security headers.
  • Create typed custom error classes for consistent error responses.
  • Add input sanitization to prevent stored XSS attacks.
Install security packages and configure rate limiting
  • Install the security packages by running:
npm install cors helmet express-rate-limit
npm install --save-dev @types/cors
  • Create src/middleware/security.ts with rate limiting and CORS configuration:
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';

// Allow requests from any origin (restrict in production)
export const corsMiddleware = cors({
  origin: '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
});

// Set security headers (XSS protection, HSTS, etc.)
export const securityHeaders = helmet();

// Limit each IP to 100 requests per 15-minute window
export const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later' },
});

Senior pattern: defense in depth

Senior engineers implement defense in depth: multiple independent security layers that each catch different threats. CORS prevents unauthorized browser clients. Rate limiting prevents DDoS and brute-force attacks. Helmet sets headers that prevent clickjacking, XSS, and protocol downgrade attacks.

No single layer is sufficient. A determined attacker bypasses CORS (it only works in browsers). Rate limiting alone does not prevent credential stuffing. Helmet alone does not stop application-level abuse. Together, they make attacks significantly harder.

Create typed error classes

Right now, your error handler uses raw objects. Senior engineers use custom error classes so every error carries a typed status code and message.

  • Create src/errors.ts with typed error classes:
// Base class for all API errors
export class AppError extends Error {
  public readonly statusCode: number;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
    this.name = this.constructor.name;
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 404);
  }
}

export class ValidationError extends AppError {
  constructor(message: string) {
    super(message, 400);
  }
}

export class AuthenticationError extends AppError {
  constructor() {
    super('Authentication required', 401);
  }
}

export class RateLimitError extends AppError {
  constructor() {
    super('Too many requests', 429);
  }
}

Senior pattern: error categorization

Typed error classes give you compile-time guarantees about your error handling. Instead of scattering res.status(404).json({...}) across 20 route handlers, you throw new NotFoundError('Bookmark') and the centralized error handler formats it consistently.

This is the single responsibility principle applied to errors: routes decide WHAT went wrong, the error handler decides HOW to respond. When you later add structured logging, every error automatically gets logged with its class name, status code, and stack trace.

  • Register the security middleware in src/index.ts before your routes. Add these lines after app.use(express.json()):
import { corsMiddleware, securityHeaders, limiter } from './middleware/security.ts';

app.use(securityHeaders);
app.use(corsMiddleware);
app.use(limiter);
  • Save all files and test rate limiting by running:
curl -I http://localhost:3000/bookmarks

You should see X-RateLimit-Limit: 100 and X-RateLimit-Remaining: 99 in the response headers. Security headers from Helmet will also appear.

  • Commit your security hardening by running:
git add .
git commit -m "feat: add rate limiting, CORS, and typed error classes"
git push

Your API now rejects abusive traffic, controls which origins can call it, and returns consistent typed errors. Next, you will add authentication so only registered users can create bookmarks.

Your API is open to everyone. Anyone with the URL can create, read, update, and delete bookmarks. In production, you need to know WHO is making each request and restrict access to only authenticated users.

In this step, you will add JWT (JSON Web Token) authentication: a sign-up endpoint that creates users, a login endpoint that returns a token, and a middleware that protects routes from unauthenticated access. You will also implement idempotency keys to prevent duplicate bookmark creation on retries.

In this step, get ready to:
  • Create sign-up and login endpoints with bcrypt password hashing.
  • Build a verifyToken middleware that protects bookmark routes.
  • Add idempotency keys to prevent duplicate POST requests.
Install authentication packages
  • Install JWT and bcrypt packages by running:
npm install jsonwebtoken bcryptjs
npm install --save-dev @types/jsonwebtoken @types/bcryptjs

Senior pattern: stateless auth vs sessions

JWTs are stateless. The server does not store sessions in memory or a database. Instead, the token itself contains the user's identity (signed cryptographically). The server verifies the signature on every request. This makes JWT perfect for serverless: no shared session store needed across Lambda invocations.

The trade-off: you cannot revoke a JWT before it expires without adding a blocklist (which reintroduces state). Senior engineers choose short expiry times (15 min) + refresh tokens as the production pattern.

💡 Senior pattern: idempotency

Your POST endpoint creates a new bookmark every time it is called. If a client retries a failed request (network timeout, 502 from API Gateway), you get a duplicate. Senior engineers solve this with idempotency keys: the client sends a unique key with each request. If the server sees the same key twice, it returns the original response without creating a duplicate.

Stripe, PayPal, and every payment API uses this pattern. For your bookmark API, you would store the idempotency key in DynamoDB with a TTL. If a duplicate arrives within the TTL window, return the cached response.

  • Commit your authentication layer by running:
git add .
git commit -m "feat: add JWT authentication with protected routes"
git push

Your API now authenticates users and protects routes. Next, you will add integration tests to prove everything works before deployment.

Your API works when you test it manually with curl. But manual testing does not scale. When you change the auth middleware tomorrow, how do you know you did not break the bookmark endpoints?

In this step, you will add automated integration tests using Vitest and Supertest. These tests call your Express app directly (no server needed), verify response shapes with Zod, and run in your CI pipeline to catch regressions before they reach production.

In this step, get ready to:
  • Install Vitest and Supertest for fast integration testing.
  • Write tests for public and protected endpoints.
  • Add Zod for runtime response contract validation.
Install testing packages
  • Install the testing stack by running:
npm install --save-dev vitest supertest @types/supertest zod
  • Add a test script to package.json:
"test": "vitest run"

Senior pattern: test pyramid

Senior engineers think in terms of the test pyramid: many fast unit tests at the base, fewer integration tests in the middle, and very few end-to-end tests at the top. Your integration tests sit in the middle. They are slower than unit tests (they hit real middleware and routing) but faster than E2E tests (no real HTTP server or database needed).

The key insight: test at the highest level that remains fast. Integration tests through Supertest exercise your entire middleware stack, routing, validation, and auth in one call. If they pass, you have high confidence the API works.

💡 Senior pattern: contract testing with Zod

TypeScript types vanish at runtime. Your BookmarkItem interface guarantees shape at compile time, but a bug in your route handler could return null or an extra field and TypeScript would not catch it.

Zod validates the actual runtime response shape in your tests. If the response does not match the schema, the test fails. This is contract testing: proving that your API returns exactly what your types promise.

  • Run your test suite by running:
npm test

You should see all tests pass with green checkmarks.

  • Commit your test suite by running:
git add .
git commit -m "feat: add integration tests with Vitest and Supertest"
git push

Your API now has automated tests that catch regressions before deployment. Next, you will swap SQLite for DynamoDB to prepare for serverless deployment.

Secret mission

Add Search and Sorting

Your GET /bookmarks endpoint returns everything in the database every time. A real API lets users filter and sort results using query parameters. In this mission, you will build dynamic SQL queries with an allowlist and parameterized inputs to prevent SQL injection.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with a free GitHub repository, so there are no ongoing costs. Decide whether to keep your files for the next project in the series, or clean up entirely.

Resources you used:

  • bookmark-api project folder on your Desktop (source code, node_modules/, bookmarks.db).
  • bookmark-api GitHub repository (free, public).

Keep everything running

No action needed. Keep everything for Project 2 in the series (Secure and Document Your API). Your code, database, and GitHub repo stay as they are.

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Everything stays on disk for when you return.

  • If the server is still running, press Ctrl+C to stop it.
  • Your files and GitHub repo remain. Restart anytime with npm run dev from inside the bookmark-api folder.

Delete - I don't want to use this again

Remove all project files and the GitHub repository.

  • Delete the project folder by running:
rm -rf ~/Desktop/bookmark-api
  • Delete the GitHub repo: open it on GitHub, click Settings, scroll to Danger Zone, click Delete this repository, and confirm.

Nice Work!

Nice Work!

You just built your first REST API from scratch. You went from an empty folder to a working server with full CRUD operations, a real database, typed middleware, and a clean Git history. This is the foundation everything else in the series builds on.

You've learned how to:

  • Build a CRUD REST API with Express 5 and TypeScript strict mode that handles GET, POST, PUT, and DELETE requests with proper HTTP status codes.
  • Persist data in a SQLite database using typed prepared statements that survive server restarts.
  • Implement the middleware pattern for request logging, input validation, and centralized error handling, shipped via a feature branch and merged pull request.
  • Secret Mission: Added search and sorting via query parameters with SQL injection prevention.

Ready to quiz yourself?