Building a Serverless Todo API on Cloudflare Workers

Custom Domains for Cloudflare Workers have officially exited beta and are now generally available. This feature lets developers connect Workers to the Internet without managing DNS records or certificates — simply enter a hostname and Cloudflare handles the rest.

More significantly, Custom Domains eliminate the concept of an origin server. Workers using Custom Domains run entirely on Cloudflare's edge network, with no "home" server to route back to. Each request is handled in the data center where it arrives, making the entire network the application server.

Architecture Overview

For this todo application, we'll build an API Gateway that handles routing, authorization checks, and request validation. The gateway fans out to separate private microservices for each operation, with each microservice bound to a D1 database for CRUD operations. This structure allows teams to update individual endpoints without redeploying the entire application.

This application consists of a Custom Domain on our gateway Worker, a series of endpoint Workers, and a D1 database powering it all.

Setting Up the D1 Database

We start by creating a D1 database using the Wrangler CLI:

npx wrangler d1 create <todo | custom-database-name>

The command will prompt you to add a binding configuration to your wrangler.toml file:

[[ d1_databases ]]
binding = "db" # i.e. available in your Worker on env.db
database_name = "<todo | custom-database-name>"
database_id = "<UUID>"

Next, we define the database schema. Since this is a simple todo app, the schema includes a table for todos with some seeded data in db/schema.sql:

DROP TABLE IF EXISTS todos;
CREATE TABLE todos (id INTEGER PRIMARY KEY, todo TEXT, todoStatus BOOLEAN NOT NULL CHECK (todoStatus IN (0, 1)));
INSERT INTO todos (todo, todoStatus) VALUES ("Fold my laundry", 0),("Get flowers for mum’s birthday", 0),("Find Nemo", 0),("Water the monstera", 1);

Initialize the database and verify it with:

npx wrangler d1 execute <todo | custom-database-name> --file=./schema.sql
npx wrangler d1 execute <todo | custom-database-name> --command='SELECT * FROM todos';

Creating Private Endpoint Workers

Each endpoint gets its own private microservice — one each for create, read, update, delete, and list operations. The full source code is available in the worker-todos-api repository. Here's the list worker as an example:

list/src/list.js

export default {
   async fetch(request, env) {
     const { results } = await env.db.prepare(
       "SELECT * FROM todos"
     ).all();
     return Response.json(results);
   },
 };

The worker accesses D1 via the db environment variable. In list/wrangler.toml, we configure the D1 binding and set workers_dev to false so no public preview is generated — these are private services meant only for internal routing:

name = "todo-list"
main = "src/list.js"
compatibility_date = "2022-09-07"
workers_dev = false
usage_model = "unbound"

[[ d1_databases ]]
binding = "db" # i.e. available in your Worker on env.db
database_name = "<todo | custom-database-name>"
database_id = "UUID"

Deploy with:

todo/list on ∞main [!] 
› wrangler publish
 ⛅️ wrangler 0.0.0-893830aa
-----------------------------------------------------------------------
Retrieving cached values for account from ../../../node_modules/.cache/wrangler
Your worker has access to the following bindings:
- D1 Databases:
  - db: todo (UUID)
Total Upload: 4.71 KiB / gzip: 1.60 KiB
Uploaded todo-list (0.96 sec)
No publish targets for todo-list (0.00 sec)

Notice that Wrangler reports no "publish targets" for the worker. That's intentional — these workers aren't exposed to HTTP directly. Instead, the API Gateway will route to them via Service Bindings.

Repeat this process for each of the remaining CRUD endpoints using the source code provided in the repository.

Routing Through the API Gateway

The gateway Worker inspects the request URL and method to determine which microservice should handle the request:

gateway/src/gateway.js

export default {
 async fetch(request, env) {
   try{
     const url = new URL(request.url)
     const idPattern = new URLPattern({ pathname: '/:id' })
     if (idPattern.test(request.url)) {
       switch (request.method){
         case 'GET':
           return await env.get.fetch(request.clone())
         case 'PATCH':
           return await env.update.fetch(request.clone())
         case 'DELETE':
           return await env.delete.fetch(request.clone())
         default:
           return new Response("Unsupported method for endpoint /:id", {status: 405})
       }
     } else if (url.pathname == '/') {
       switch (request.method){
         case 'GET':
           return await env.list.fetch(request.clone())
         case 'POST':
           return await env.create.fetch(request.clone())
         default:
           return new Response("Unsupported method for endpoint /", {status: 405})
       }
     }
     return new Response("Not found. Supported endpoints are /:id and /", {status: 404})
   } catch(e) {
     return new Response(e, {status: 500})
   }
 },
};

The gateway's wrangler.toml configures both the Service Bindings and the Custom Domain that exposes the application publicly:

gateway/wrangler.toml

name = "todo-gateway"
main = "src/gateway.js"
compatibility_date = "2022-09-07"
workers_dev = false
usage_model = "unbound"
 
routes =  [
   {pattern="todos.radiobox.tv", custom_domain=true, zone_name="radiobox.tv"}
]
 
services = [
   {binding = "get",service = "todo-get"},
   {binding = "delete",service = "todo-delete"},
   {binding = "create",service = "todo-create"},
   {binding = "update",service = "todo-update"},
   {binding = "list",service = "todo-list"}
]

After running wrangler publish, the todo API is live on Cloudflare's network within seconds — no origin servers, no DNS management, no certificate renewal.

› wrangler publish
 ⛅️ wrangler 0.0.0-893830aa
-----------------------------------------------------------------------
Retrieving cached values for account from ../../../node_modules/.cache/wrangler
Your worker has access to the following bindings:
- Services:
  - get: todo-get
  - delete: todo-delete
  - create: todo-create
  - update: todo-update
  - list: todo-list
Total Upload: 1.21 KiB / gzip: 0.42 KiB
Uploaded todo-gateway (0.62 sec)
Published todo-gateway (0.51 sec)
  todos.radiobox.tv (custom domain - zone name: radiobox.tv)

Adding Security and Access Control

Because the application natively runs on Cloudflare's infrastructure, you can layer on Cloudflare's security products directly. Enabling Managed WAF rules protects against SQL injection attacks on the API endpoint. For restricting access to privileged clients only, Cloudflare Access can be placed in front with custom access rules.

our application can have any number of Cloudflare services running in front of it

Custom Domains on Workers make it straightforward to build applications that run natively on Cloudflare's global network. During the open beta, over 5,000 developers used the feature and provided feedback that helped shape the GA release. The developers working on this project extend their thanks to that community.

The next installment in this series will cover building a frontend. In the meantime, you can experiment with the live todos API at todos.radiobox.tv.