Skip to main content

10 - Full-Stack Architectures at the Edge: Case Study from Zero to Production

Edge Computing 20 min Full-Stack Architectures at the Edge: Case Study from Zero to Production A complete case study building a full-stack REST API on...

What do you think about this article?

At a Glance

Edge Computing 20 min Full-Stack Architectures at the Edge: Case Study from Zero to Production A complete case study building a full-stack REST API on...

Edge Computing 20 min

Full-Stack Architectures at the Edge: Case Study from Zero to Production

A comprehensive case study building a full-stack REST API using Cloudflare Workers as compute, D1 for relational database and R2 for assets and uploads, with CI/CD via GitHub Actions.

Full-Stack Edge REST API Workers D1 R2 Case Study CI/CD

What We Will Build: CatalogAPI

In this article we build CatalogAPI: A REST API for a catalog products with JWT authentication, image upload, full-text search and rate limiting. Architecture uses Alone Cloudflare: Workers for the compute, D1 for the database relational, R2 for asset storage, KV for sessions and rate limiting. Zero servers to manage, zero VPC, automatic global deployment to 300+ PoPs.

Case Study Technology Stack

  • Compute: Cloudflare Workers (TypeScript, Hono.js framework)
  • Databases: D1 (SQLite) with automatic migrations
  • Object Storage: R2 for product images
  • Cache/Sessions: Workers KV
  • Auth: JWT with private key in Workers Secret
  • Rate Limiting: Sliding window with atomic KV
  • CI/CD: GitHub Actions with automatic testing and deployment
  • Monitoring: Cloudflare Workers Logpush to R2

Project Structure

catalog-api/
  src/
    index.ts              # Entry point, setup Hono
    middleware/
      auth.ts             # JWT verification middleware
      rateLimit.ts        # Sliding window rate limiter
      cors.ts             # CORS headers
    routes/
      products.ts         # CRUD prodotti
      categories.ts       # CRUD categorie
      uploads.ts          # Upload immagini su R2
      auth.ts             # Login / refresh token
    services/
      jwt.ts              # Firma e verifica JWT
      search.ts           # Full-text search su D1
    types/
      env.ts              # Interfaccia Env (binding)
      models.ts           # Interfacce dati
  migrations/
    0001_initial.sql      # Schema D1 iniziale
    0002_full_text.sql    # FTS5 extension
  test/
    routes/
      products.test.ts
      auth.test.ts
    integration/
      upload.test.ts
  wrangler.toml
  vitest.config.ts

Scheme D1: Database Design

-- migrations/0001_initial.sql

CREATE TABLE categories (
  id        TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
  name      TEXT NOT NULL UNIQUE,
  slug      TEXT NOT NULL UNIQUE,
  created_at INTEGER DEFAULT (unixepoch())
);

CREATE TABLE products (
  id           TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
  name         TEXT NOT NULL,
  slug         TEXT NOT NULL UNIQUE,
  description  TEXT,
  price        REAL NOT NULL CHECK (price >= 0),
  stock        INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
  category_id  TEXT REFERENCES categories(id) ON DELETE SET NULL,
  image_key    TEXT,           -- chiave R2 per l'immagine principale
  status       TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'deleted')),
  created_at   INTEGER DEFAULT (unixepoch()),
  updated_at   INTEGER DEFAULT (unixepoch())
);

CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_status ON products(status);
CREATE INDEX idx_products_price ON products(price);

CREATE TABLE users (
  id           TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
  email        TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  role         TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'editor', 'viewer')),
  created_at   INTEGER DEFAULT (unixepoch())
);

-- migrations/0002_full_text.sql
-- FTS5 per ricerca full-text efficiente
CREATE VIRTUAL TABLE products_fts USING fts5(
  id UNINDEXED,
  name,
  description,
  content='products',
  content_rowid='rowid'
);

-- Trigger per mantenere sincronizzata la FTS table
CREATE TRIGGER products_ai AFTER INSERT ON products BEGIN
  INSERT INTO products_fts(rowid, id, name, description)
  VALUES (new.rowid, new.id, new.name, new.description);
END;

CREATE TRIGGER products_au AFTER UPDATE ON products BEGIN
  UPDATE products_fts SET name = new.name, description = new.description
  WHERE id = new.id;
END;

CREATE TRIGGER products_ad AFTER DELETE ON products BEGIN
  DELETE FROM products_fts WHERE id = old.id;
END;

Entry Point: Hono.js on Workers

Hono.js is the most used web framework on Workers: it is written for the edge (no Node.js dependencies), has an ultra-fast router, composable and type-safe middleware with TypeScript.

// src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { secureHeaders } from 'hono/secure-headers';
import { timing } from 'hono/timing';

import { authMiddleware } from './middleware/auth';
import { rateLimitMiddleware } from './middleware/rateLimit';
import { productsRouter } from './routes/products';
import { categoriesRouter } from './routes/categories';
import { uploadsRouter } from './routes/uploads';
import { authRouter } from './routes/auth';
import type { Env } from './types/env';

const app = new Hono<{ Bindings: Env }>();

// Middleware globali
app.use('*', cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
  allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
}));

app.use('*', secureHeaders());
app.use('*', timing());

// Rate limiting su tutte le route API
app.use('/api/*', rateLimitMiddleware);

// Route pubbliche (no auth)
app.route('/api/auth', authRouter);
app.get('/api/products', async (c) => {
  // GET list e pubblico, senza autenticazione
  const { default: handler } = await import('./routes/products');
  return handler.fetch(c.req.raw, c.env, c.executionCtx);
});

// Route protette (richiedono JWT)
app.use('/api/products/*', authMiddleware);
app.use('/api/categories/*', authMiddleware);
app.use('/api/uploads/*', authMiddleware);

app.route('/api/products', productsRouter);
app.route('/api/categories', categoriesRouter);
app.route('/api/uploads', uploadsRouter);

// Health check
app.get('/health', (c) => c.json({ status: 'ok', timestamp: Date.now() }));

// 404 handler
app.notFound((c) => c.json({ error: 'Not Found' }, 404));

// Error handler
app.onError((err, c) => {
  console.error('Unhandled error:', err);
  return c.json({ error: 'Internal Server Error' }, 500);
});

export default app;

Auth: JWT with Workers Secrets

// src/services/jwt.ts
// Web Crypto API (disponibile in Workers, nessuna dipendenza esterna)

const JWT_ALGORITHM = 'HS256';

export async function signJWT(
  payload: Record<string, unknown>,
  secret: string,
  expiresInSeconds = 3600
): Promise<string> {
  const header = { alg: JWT_ALGORITHM, typ: 'JWT' };

  const now = Math.floor(Date.now() / 1000);
  const fullPayload = {
    ...payload,
    iat: now,
    exp: now + expiresInSeconds,
  };

  const encodedHeader = btoa(JSON.stringify(header)).replace(/=/g, '');
  const encodedPayload = btoa(JSON.stringify(fullPayload)).replace(/=/g, '');
  const data = `${encodedHeader}.${encodedPayload}`;

  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );

  const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
  const encodedSignature = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=/g, '');

  return `${data}.${encodedSignature}`;
}

export async function verifyJWT(
  token: string,
  secret: string
): Promise<Record<string, unknown> | null> {
  try {
    const [encodedHeader, encodedPayload, encodedSignature] = token.split('.');

    if (!encodedHeader || !encodedPayload || !encodedSignature) return null;

    // Verifica firma
    const data = `${encodedHeader}.${encodedPayload}`;
    const key = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['verify']
    );

    const signatureBytes = Uint8Array.from(atob(encodedSignature.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
    const valid = await crypto.subtle.verify('HMAC', key, signatureBytes, new TextEncoder().encode(data));

    if (!valid) return null;

    // Verifica scadenza
    const payload = JSON.parse(atob(encodedPayload)) as { exp: number };
    if (payload.exp < Math.floor(Date.now() / 1000)) return null;

    return payload as Record<string, unknown>;
  } catch {
    return null;
  }
}

// src/middleware/auth.ts
import type { MiddlewareHandler } from 'hono';
import { verifyJWT } from '../services/jwt';
import type { Env } from '../types/env';

export const authMiddleware: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
  const authHeader = c.req.header('Authorization');

  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Missing or invalid Authorization header' }, 401);
  }

  const token = authHeader.slice(7);
  const payload = await verifyJWT(token, c.env.JWT_SECRET);

  if (!payload) {
    return c.json({ error: 'Invalid or expired token' }, 401);
  }

  // Passa il payload al contesto per i route handler successivi
  c.set('user', payload);
  await next();
};

Rate Limiting: Sliding Window with KV

// src/middleware/rateLimit.ts
// Sliding window rate limiter: massimo N richieste per finestra temporale

import type { MiddlewareHandler } from 'hono';
import type { Env } from '../types/env';

const WINDOW_SIZE_SECONDS = 60;  // Finestra di 1 minuto
const MAX_REQUESTS = 100;         // 100 richieste per finestra (per IP)

export const rateLimitMiddleware: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
  // Usa l'IP del client come chiave (Cloudflare lo espone in cf.connectingIp)
  const clientIp = c.req.header('CF-Connecting-IP') ??
                   c.req.header('X-Forwarded-For') ??
                   'unknown';

  const now = Math.floor(Date.now() / 1000);
  const windowStart = now - WINDOW_SIZE_SECONDS;
  const key = `ratelimit:${clientIp}:${Math.floor(now / WINDOW_SIZE_SECONDS)}`;

  // Recupera il contatore corrente per questa finestra
  const currentCount = parseInt(await c.env.RATE_LIMIT_KV.get(key) ?? '0');

  if (currentCount >= MAX_REQUESTS) {
    const resetAt = (Math.floor(now / WINDOW_SIZE_SECONDS) + 1) * WINDOW_SIZE_SECONDS;
    return c.json(
      { error: 'Rate limit exceeded', resetAt },
      429,
      {
        'X-RateLimit-Limit': String(MAX_REQUESTS),
        'X-RateLimit-Remaining': '0',
        'X-RateLimit-Reset': String(resetAt),
        'Retry-After': String(resetAt - now),
      }
    );
  }

  // Incrementa il contatore
  const newCount = currentCount + 1;
  c.executionCtx.waitUntil(
    c.env.RATE_LIMIT_KV.put(key, String(newCount), {
      expirationTtl: WINDOW_SIZE_SECONDS * 2,
    })
  );

  // Aggiungi header informativi
  c.header('X-RateLimit-Limit', String(MAX_REQUESTS));
  c.header('X-RateLimit-Remaining', String(MAX_REQUESTS - newCount));

  await next();
};

Product Route: CRUD with D1

// src/routes/products.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import type { Env } from '../types/env';

const productsRouter = new Hono<{ Bindings: Env }>();

// Schema di validazione con Zod
const createProductSchema = z.object({
  name: z.string().min(1).max(200),
  slug: z.string().min(1).regex(/^[a-z0-9-]+$/),
  description: z.string().max(5000).optional(),
  price: z.number().min(0),
  stock: z.number().int().min(0).default(0),
  category_id: z.string().uuid().optional(),
});

// GET /api/products - Lista con paginazione, filtri e ricerca
productsRouter.get('/', async (c) => {
  const { page = '1', limit = '20', category, search, min_price, max_price } = c.req.query();

  const pageNum = Math.max(1, parseInt(page));
  const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
  const offset = (pageNum - 1) * limitNum;

  let query: string;
  const params: (string | number)[] = [];

  if (search) {
    // Full-text search via FTS5
    query = `
      SELECT p.*, c.name as category_name
      FROM products_fts f
      JOIN products p ON f.id = p.id
      LEFT JOIN categories c ON p.category_id = c.id
      WHERE products_fts MATCH ? AND p.status = 'active'
    `;
    params.push(search);
  } else {
    query = `
      SELECT p.*, c.name as category_name
      FROM products p
      LEFT JOIN categories c ON p.category_id = c.id
      WHERE p.status = 'active'
    `;
  }

  if (category) {
    query += ` AND c.slug = ?`;
    params.push(category);
  }

  if (min_price) {
    query += ` AND p.price >= ?`;
    params.push(parseFloat(min_price));
  }

  if (max_price) {
    query += ` AND p.price <= ?`;
    params.push(parseFloat(max_price));
  }

  // Count totale per paginazione
  const countQuery = `SELECT COUNT(*) as total FROM (${query})`;
  const countResult = await c.env.DB.prepare(countQuery).bind(...params).first<{ total: number }>();
  const total = countResult?.total ?? 0;

  // Query paginata
  query += ` ORDER BY p.created_at DESC LIMIT ? OFFSET ?`;
  params.push(limitNum, offset);

  const products = await c.env.DB.prepare(query).bind(...params).all();

  return c.json({
    data: products.results,
    pagination: {
      page: pageNum,
      limit: limitNum,
      total,
      pages: Math.ceil(total / limitNum),
    },
  });
});

// GET /api/products/:id
productsRouter.get('/:id', async (c) => {
  const id = c.req.param('id');

  const product = await c.env.DB.prepare(`
    SELECT p.*, c.name as category_name, c.slug as category_slug
    FROM products p
    LEFT JOIN categories c ON p.category_id = c.id
    WHERE p.id = ? AND p.status != 'deleted'
  `).bind(id).first();

  if (!product) {
    return c.json({ error: 'Product not found' }, 404);
  }

  // Aggiungi URL pre-firmato per l'immagine R2 (valido 1 ora)
  let imageUrl: string | null = null;
  if ((product as { image_key?: string }).image_key) {
    const key = (product as { image_key: string }).image_key;
    // Usa URL pubblica R2 (richiede bucket pubblico o URL custom domain)
    imageUrl = `https://assets.myapp.com/${key}`;
  }

  return c.json({ ...product, imageUrl });
});

// POST /api/products - Crea prodotto (richiede auth admin/editor)
productsRouter.post('/', zValidator('json', createProductSchema), async (c) => {
  const user = c.get('user') as { role: string } | undefined;

  if (!user || !['admin', 'editor'].includes(user.role)) {
    return c.json({ error: 'Insufficient permissions' }, 403);
  }

  const data = c.req.valid('json');

  // Verifica univocità slug
  const existing = await c.env.DB.prepare(
    'SELECT id FROM products WHERE slug = ?'
  ).bind(data.slug).first();

  if (existing) {
    return c.json({ error: 'Slug already exists' }, 409);
  }

  const result = await c.env.DB.prepare(`
    INSERT INTO products (name, slug, description, price, stock, category_id)
    VALUES (?, ?, ?, ?, ?, ?)
    RETURNING *
  `).bind(
    data.name,
    data.slug,
    data.description ?? null,
    data.price,
    data.stock,
    data.category_id ?? null
  ).first();

  return c.json(result, 201);
});

// PATCH /api/products/:id
productsRouter.patch('/:id', async (c) => {
  const id = c.req.param('id');
  const user = c.get('user') as { role: string } | undefined;

  if (!user || !['admin', 'editor'].includes(user.role)) {
    return c.json({ error: 'Insufficient permissions' }, 403);
  }

  const body = await c.req.json() as Partial<{ name: string; price: number; stock: number; status: string }>;

  const updates: string[] = [];
  const values: (string | number)[] = [];

  if (body.name !== undefined) { updates.push('name = ?'); values.push(body.name); }
  if (body.price !== undefined) { updates.push('price = ?'); values.push(body.price); }
  if (body.stock !== undefined) { updates.push('stock = ?'); values.push(body.stock); }
  if (body.status !== undefined) { updates.push('status = ?'); values.push(body.status); }

  if (updates.length === 0) {
    return c.json({ error: 'No fields to update' }, 400);
  }

  updates.push('updated_at = unixepoch()');
  values.push(id);

  const result = await c.env.DB.prepare(
    `UPDATE products SET ${updates.join(', ')} WHERE id = ? AND status != 'deleted' RETURNING *`
  ).bind(...values).first();

  if (!result) {
    return c.json({ error: 'Product not found' }, 404);
  }

  return c.json(result);
});

export { productsRouter };

Upload Images to R2

// src/routes/uploads.ts
import { Hono } from 'hono';
import type { Env } from '../types/env';

const uploadsRouter = new Hono<{ Bindings: Env }>();

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB

uploadsRouter.post('/product-image/:productId', async (c) => {
  const productId = c.req.param('productId');
  const user = c.get('user') as { role: string } | undefined;

  if (!user || !['admin', 'editor'].includes(user.role)) {
    return c.json({ error: 'Insufficient permissions' }, 403);
  }

  // Verifica che il prodotto esista
  const product = await c.env.DB.prepare(
    "SELECT id FROM products WHERE id = ? AND status != 'deleted'"
  ).bind(productId).first();

  if (!product) {
    return c.json({ error: 'Product not found' }, 404);
  }

  const formData = await c.req.formData();
  const file = formData.get('image') as File | null;

  if (!file) {
    return c.json({ error: 'No image file provided' }, 400);
  }

  // Validazione tipo e dimensione
  if (!ALLOWED_TYPES.includes(file.type)) {
    return c.json({
      error: `Invalid file type. Allowed: ${ALLOWED_TYPES.join(', ')}`
    }, 400);
  }

  if (file.size > MAX_SIZE_BYTES) {
    return c.json({ error: 'File too large. Maximum 5MB' }, 400);
  }

  // Genera chiave R2 univoca
  const ext = file.type.split('/')[1];
  const r2Key = `products/${productId}/${crypto.randomUUID()}.${ext}`;

  // Upload su R2
  await c.env.ASSETS_BUCKET.put(r2Key, file.stream(), {
    httpMetadata: {
      contentType: file.type,
      cacheControl: 'public, max-age=31536000, immutable',
    },
    customMetadata: {
      productId,
      originalName: file.name,
      uploadedAt: new Date().toISOString(),
    },
  });

  // Aggiorna il prodotto con la nuova chiave R2
  // Prima elimina l'immagine precedente se esiste
  const oldProduct = await c.env.DB.prepare(
    'SELECT image_key FROM products WHERE id = ?'
  ).bind(productId).first<{ image_key: string | null }>();

  if (oldProduct?.image_key) {
    // Elimina la vecchia immagine in background
    c.executionCtx.waitUntil(
      c.env.ASSETS_BUCKET.delete(oldProduct.image_key)
    );
  }

  await c.env.DB.prepare(
    'UPDATE products SET image_key = ?, updated_at = unixepoch() WHERE id = ?'
  ).bind(r2Key, productId).run();

  return c.json({
    key: r2Key,
    url: `https://assets.myapp.com/${r2Key}`,
    size: file.size,
    type: file.type,
  }, 201);
});

// DELETE /api/uploads/product-image/:productId
uploadsRouter.delete('/product-image/:productId', async (c) => {
  const productId = c.req.param('productId');
  const user = c.get('user') as { role: string } | undefined;

  if (!user || user.role !== 'admin') {
    return c.json({ error: 'Admin required' }, 403);
  }

  const product = await c.env.DB.prepare(
    'SELECT image_key FROM products WHERE id = ?'
  ).bind(productId).first<{ image_key: string | null }>();

  if (!product?.image_key) {
    return c.json({ error: 'No image found for this product' }, 404);
  }

  await c.env.ASSETS_BUCKET.delete(product.image_key);

  await c.env.DB.prepare(
    'UPDATE products SET image_key = NULL, updated_at = unixepoch() WHERE id = ?'
  ).bind(productId).run();

  return new Response(null, { status: 204 });
});

export { uploadsRouter };

wrangler.toml Complete

# wrangler.toml
name = "catalog-api"
main = "src/index.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]

# Worker deve avere Workers Paid Plan per D1, R2 e CPU > 10ms
account_id = "YOUR_ACCOUNT_ID"

[[d1_databases]]
binding = "DB"
database_name = "catalog-db"
database_id = "YOUR_D1_DATABASE_ID"
migrations_dir = "migrations"  # wrangler d1 migrations apply catalog-db

[[r2_buckets]]
binding = "ASSETS_BUCKET"
bucket_name = "catalog-assets"
# Configura custom domain per URL pubblici: https://assets.myapp.com

[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "YOUR_KV_NAMESPACE_ID"

# Secrets (impostati con: wrangler secret put JWT_SECRET)
# JWT_SECRET = ...  (non nel file, usa wrangler secret)

[vars]
ENVIRONMENT = "production"
MAX_UPLOAD_MB = "5"

# Configurazione per staging
[env.staging]
name = "catalog-api-staging"
vars = { ENVIRONMENT = "staging" }

[[env.staging.d1_databases]]
binding = "DB"
database_name = "catalog-db-staging"
database_id = "YOUR_STAGING_D1_ID"

# Routes: mappa il Worker al custom domain
[[routes]]
pattern = "api.myapp.com/*"
zone_id = "YOUR_ZONE_ID"

Migration D1: Apply the Scheme

# Crea il database D1
npx wrangler d1 create catalog-db

# Applica le migration iniziali
npx wrangler d1 migrations apply catalog-db

# In staging (dry run per verificare)
npx wrangler d1 migrations apply catalog-db --env staging --dry-run

# Query diretta per debug (locale)
npx wrangler d1 execute catalog-db --local --command "SELECT count(*) FROM products"

# Esporta snapshot per backup
npx wrangler d1 export catalog-db --output backup-$(date +%Y%m%d).sql

CI/CD: GitHub Actions Complete

# .github/workflows/deploy.yml
name: Test and Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: '20'

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - run: npm ci

      - name: TypeScript check
        run: npx tsc --noEmit

      - name: Lint
        run: npm run lint

      - name: Unit + Integration tests
        run: npx vitest run --coverage
        env:
          CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  deploy-staging:
    name: Deploy Staging
    needs: test
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci

      - name: Apply D1 migrations (staging)
        run: npx wrangler d1 migrations apply catalog-db --env staging
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Deploy to staging
        run: npx wrangler deploy --env staging
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Run smoke tests against staging
        run: npm run test:e2e
        env:
          API_BASE_URL: https://catalog-api-staging.myapp.com

  deploy-production:
    name: Deploy Production
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      - run: npm ci

      - name: Apply D1 migrations (production)
        run: npx wrangler d1 migrations apply catalog-db
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Deploy to production
        run: npx wrangler deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Purge Cloudflare Cache dopo deploy
        run: |
          curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
            -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
            -H "Content-Type: application/json" \
            --data '{"purge_everything":true}'

Observability: Logs and Monitoring

// src/middleware/logging.ts
// Middleware di logging strutturato verso R2 (via Logpush) o console

import type { MiddlewareHandler } from 'hono';
import type { Env } from '../types/env';

interface RequestLog {
  timestamp: string;
  method: string;
  path: string;
  status: number;
  durationMs: number;
  country: string;
  datacenter: string;
  ip: string;
  userAgent: string;
  userId?: string;
}

export const loggingMiddleware: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
  const startTime = Date.now();

  await next();

  const durationMs = Date.now() - startTime;
  const user = c.get('user') as { id?: string } | undefined;

  const log: RequestLog = {
    timestamp: new Date().toISOString(),
    method: c.req.method,
    path: new URL(c.req.url).pathname,
    status: c.res.status,
    durationMs,
    country: c.req.header('CF-IPCountry') ?? 'unknown',
    datacenter: (c.req.raw as Request & { cf?: { colo?: string } }).cf?.colo ?? 'unknown',
    ip: c.req.header('CF-Connecting-IP') ?? 'unknown',
    userAgent: c.req.header('User-Agent') ?? 'unknown',
    userId: user?.id,
  };

  // Log strutturato verso console (visibile in Cloudflare Dashboard)
  console.log(JSON.stringify(log));

  // Configura Cloudflare Logpush nel dashboard per inviare automaticamente
  // i Workers logs verso R2, Datadog, Splunk, ecc.
};

// Alerting: monitora errori 5xx su Workers Analytics Engine

Costs and Scaling: Economic Analysis

Component Price Included in the Paid Plan ($5/month)
Workers requests $0.30 / million (after included) 10 million / month
Workers CPU time $0.02 / million CPU-ms 30 million CPU-ms / month
D1 rows read $0.001 / million 25 billion / month
D1 rows written $1.00 / million 50 million / month
R2 storage $0.015 / GB / month 10GB
R2 operations $0.36 / million Class A 1 million Class A, 10M Class B
KV reads $0.50 / million 10 million / month

For a medium traffic API (1M requests/month, 100K D1 writes, 10GB R2): the total cost is approx $5-15/month. An architecture equivalent on AWS (API Gateway + Lambda + RDS + S3 + CloudFront) would cost easily $150-400/month at the same scale.

Conclusions of the Series

This case study demonstrated that it is possible to build a REST API complete production, with authentication, upload, full-text search and rate limiting, using Alone the Cloudflare platform. The result is an application distributed across 300+ global datacenters, with sub-millisecond latency, scaling infinitely automatic and costs a fraction of traditional cloud architectures.

The V8 isolates, which we introduced in article 1, are the foundation of everything: they allow you to run TypeScript code anywhere in the world without cold start, without server management, without VPC. Operational complexity is eliminated; the business complexity remains yours.

Related Series: Other Resources

  • Kubernetes at Scale (Series 36): When Workers are not enough and you need them of containers with more CPU and RAM.
  • Event-Driven Architecture (Series 39): Workers + Queue bindings for asynchronous patterns and processing pipelines.
  • DevSecOps (Series 05): Security scanning of dependencies and policy as code applied to the Workers ecosystem.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects