All articles
Developer Tools

Deploying a Full-Stack App on Cloudflare Workers with D1: A Complete Guide

January 8, 2026 10 min read

What if you could deploy a full-stack web application — API, database, and frontend — without provisioning a single server? With Cloudflare Workers for compute and D1 for your database, you can build and deploy production-grade applications that run at the edge, scale automatically, and cost almost nothing at low traffic.

In this guide, we'll walk through building a complete full-stack application on Cloudflare's platform — from project setup to production deployment.

Why Cloudflare Workers + D1?

Before diving in, here's why this stack is compelling:

Edge Computing

Your code runs in 300+ data centers worldwide. Users get sub-50ms response times regardless of location.

Zero Cold Starts

Unlike AWS Lambda or Google Cloud Functions, Workers have no cold start penalty. Every request is fast.

SQLite-Based Database

D1 is SQLite at the edge. If you know SQL, you know D1. No ORMs required, no connection pooling headaches.

Generous Free Tier

100,000 requests/day on Workers, 5M reads/day on D1. Most small-to-medium apps run for free.

Project Setup

Step 1: Initialize the Project

# Create a new Workers project
npm create cloudflare@latest my-app -- --type=hello-world --ts

cd my-app

# Initialize D1 database
npx wrangler d1 create my-app-db

Step 2: Configure wrangler.toml

After creating the D1 database, Wrangler gives you a database ID. Add it to your config:

# wrangler.toml
name = "my-app"
main = "src/index.ts"
compatibility_date = "2025-01-01"

[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "your-database-id-here"

Step 3: Define Your Schema

Create a migration file for your database schema:

-- migrations/001_init.sql
CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT UNIQUE NOT NULL,
  name TEXT NOT NULL,
  password_hash TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  published INTEGER DEFAULT 0,
  created_at TEXT DEFAULT (datetime('now')),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE INDEX idx_posts_slug ON posts(slug);
CREATE INDEX idx_posts_user ON posts(user_id);
# Apply the migration
npx wrangler d1 migrations apply my-app-db --local   # local dev
npx wrangler d1 migrations apply my-app-db --remote  # production

Building the API

Type Definitions

// src/types.ts
export interface Env {
  DB: D1Database;
}

export interface User {
  id: number;
  email: string;
  name: string;
  created_at: string;
}

export interface Post {
  id: number;
  user_id: number;
  title: string;
  content: string;
  slug: string;
  published: number;
  created_at: string;
}

The Worker Entry Point

// src/index.ts
import { Env } from "./types";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    // CORS headers for API
    const corsHeaders = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
    };

    if (request.method === "OPTIONS") {
      return new Response(null, { headers: corsHeaders });
    }

    try {
      // Route matching
      if (path === "/api/posts" && request.method === "GET") {
        return handleGetPosts(env, corsHeaders);
      }
      if (path === "/api/posts" && request.method === "POST") {
        return handleCreatePost(request, env, corsHeaders);
      }

      const postMatch = path.match(/^\/api\/posts\/([\w-]+)$/);
      if (postMatch) {
        const slug = postMatch[1];
        if (request.method === "GET") {
          return handleGetPost(slug, env, corsHeaders);
        }
      }

      return new Response("Not Found", { status: 404 });
    } catch (err) {
      return Response.json(
        { error: "Internal Server Error" },
        { status: 500, headers: corsHeaders }
      );
    }
  },
};

async function handleGetPosts(env: Env, headers: Record<string, string>) {
  const { results } = await env.DB.prepare(
    "SELECT id, title, slug, created_at FROM posts WHERE published = 1 ORDER BY created_at DESC"
  ).all();

  return Response.json(results, { headers });
}

async function handleGetPost(slug: string, env: Env, headers: Record<string, string>) {
  const post = await env.DB.prepare(
    "SELECT * FROM posts WHERE slug = ? AND published = 1"
  ).bind(slug).first();

  if (!post) {
    return Response.json({ error: "Post not found" }, { status: 404, headers });
  }

  return Response.json(post, { headers });
}

async function handleCreatePost(request: Request, env: Env, headers: Record<string, string>) {
  const body = await request.json() as {
    title: string;
    content: string;
    slug: string;
    user_id: number;
  };

  const result = await env.DB.prepare(
    "INSERT INTO posts (user_id, title, content, slug, published) VALUES (?, ?, ?, ?, 1)"
  ).bind(body.user_id, body.title, body.content, body.slug).run();

  return Response.json(
    { id: result.meta.last_row_id, slug: body.slug },
    { status: 201, headers }
  );
}

Serving a Frontend

You have two options for the frontend:

Option A: Server-Rendered HTML

Return HTML directly from your Worker. Great for server-rendered apps where SEO matters. No build step for the frontend, and everything deploys as a single unit.

function renderPage(posts: Post[]) {
  return new Response(`
    <!DOCTYPE html>
    <html>
    <head>
      <title>My Blog</title>
      <script src="https://cdn.tailwindcss.com"></script>
    </head>
    <body class="max-w-2xl mx-auto p-8">
      <h1 class="text-3xl font-bold mb-8">My Blog</h1>
      ${posts.map(p => `
        <article class="mb-6">
          <a href="/posts/${p.slug}" class="text-xl font-semibold text-blue-600 hover:underline">
            ${p.title}
          </a>
          <p class="text-gray-500 text-sm">${p.created_at}</p>
        </article>
      `).join("")}
    </body>
    </html>
  `, { headers: { "Content-Type": "text/html" } });
}

Option B: Separate Frontend on Cloudflare Pages

Deploy a React/Vue/Svelte app to Cloudflare Pages and have it call your Worker API. Best for complex, interactive UIs. The Worker handles the API, Pages handles the static assets.

Development Workflow

# Local development with live reload
npx wrangler dev

# Run migrations locally
npx wrangler d1 migrations apply my-app-db --local

# Deploy to production
npx wrangler deploy

# Run migrations in production
npx wrangler d1 migrations apply my-app-db --remote

# View production logs
npx wrangler tail

Adding Authentication

For authentication, you can implement a simple session-based auth system using cookies:

// Simple password hashing (use a proper library in production)
async function hashPassword(password: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(password);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return btoa(String.fromCharCode(...new Uint8Array(hash)));
}

// Session token stored as an HttpOnly cookie
function setSession(token: string): string {
  return `session=${token}; HttpOnly; Secure; SameSite=Strict; Max-Age=86400; Path=/`;
}

// Auth middleware
async function requireAuth(request: Request, env: Env): Promise<User | null> {
  const cookie = request.headers.get("Cookie") || "";
  const match = cookie.match(/session=([^;]+)/);
  if (!match) return null;

  const session = await env.DB.prepare(
    "SELECT users.* FROM sessions JOIN users ON sessions.user_id = users.id WHERE sessions.token = ?"
  ).bind(match[1]).first<User>();

  return session || null;
}

Production Checklist

Before deploying to production, make sure you've covered:

  • Custom domain — Set up a custom domain in the Cloudflare dashboard
  • Environment variables — Use wrangler secret put for sensitive values
  • Error handling — Catch all errors and return meaningful error responses
  • Rate limiting — Protect your API from abuse
  • Input validation — Never trust client-side data
  • Database backups — D1 supports Time Travel for point-in-time recovery

What We've Built on This Stack

At AXOX, we've built multiple production applications using Cloudflare Workers + D1:

EdgeSubmit

Form submission API — handles thousands of form submissions daily with sub-50ms response times

PxShot

Screenshot API — captures web pages as images using Browser Rendering + D1 for API key management

AXOX Portal

Internal CRM — client management, deal pipeline, project tracking, all on Workers + D1

Need Help Building on Cloudflare?

We build production applications on Cloudflare's platform — Workers, D1, Pages, R2, and more. Whether you need an API, a full-stack app, or a migration from traditional infrastructure, we can help.

Start a Project →

Questions about deploying on Cloudflare Workers? Get in touch — we're happy to answer questions or help you get started.

cloudflare workersd1full stackserverlessdeployment