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.
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.







