Your First Cloudflare Worker: Fetch Handler, Wrangler and Deploy
A step-by-step guide to creating, testing, and deploying a Cloudflare Worker from zero: from the fetch handler to the wrangler deployment, passing through environment variables, secrets and advanced routing.
From Zero to Worker in Production
In the previous article we understood V8 isolates and why they eliminate cold starts. In this article we build something concrete: a Worker who manages routing, reads environment variables, uses secrets and responds with JSON. At the end you will have a Worker deployed on your Cloudflare account, reachable globally in less than 1ms of startup.
What You Will Learn
- Development environment setup with Wrangler CLI and TypeScript
- File structure
wrangler.tomland all the main fields - The fetch handler: request, env, ExecutionContext in detail
- Manual and itty-router routing for complex URL patterns
- Environment variables, secrets, and how to access them in type-safe TypeScript
- Local test with
wrangler devand hot reload - Deploy in production and environment management (staging/prod)
- Custom domains and route patterns
Prerequisites and Initial Setup
You will need a free Cloudflare account and Node.js 18+. The free tier includes 100,000 requests per day, more than enough for development and personal projects.
# Installa Wrangler globalmente (CLI ufficiale Cloudflare)
npm install -g wrangler
# Verifica l'installazione
wrangler --version
# wrangler 3.x.x
# Autenticati con il tuo account Cloudflare
# Apre il browser per il login OAuth
wrangler login
# Verifica che l'autenticazione abbia funzionato
wrangler whoami
# Logged in as: tuo@email.com
# Account ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Create the Worker Project
Wrangler provides a command create who shelves the project with
TypeScript and all necessary dependencies:
# Crea un nuovo Worker con template TypeScript
npm create cloudflare@latest mio-primo-worker -- --type worker
# Oppure con Wrangler direttamente
wrangler init mio-primo-worker --type worker
# Struttura del progetto generata:
mio-primo-worker/
├── src/
│ └── index.ts # Entry point del Worker
├── test/
│ └── index.spec.ts # Test con Vitest (opzionale)
├── wrangler.toml # Configurazione principale
├── tsconfig.json # TypeScript config
├── package.json
└── .gitignore
cd mio-primo-worker
npm install
wrangler.toml: The Central Configuration
The file wrangler.toml it is the heart of the Worker configuration.
Each field has implications on deployment behavior:
# wrangler.toml
# Nome del Worker (deve essere unico nel tuo account)
name = "mio-primo-worker"
# Versione del formato di configurazione
main = "src/index.ts"
# Compatibilita: usa sempre una data recente per avere le ultime API
compatibility_date = "2025-11-01"
# Flags per abilitare funzionalita sperimentali/stabili
compatibility_flags = ["nodejs_compat"]
# Workers paused: false (default)
# workers_dev = true # Abilita il sottodominio .workers.dev (default true)
# Variabili d'ambiente (visibili nel codice, NON secret)
[vars]
ENVIRONMENT = "production"
API_BASE_URL = "https://api.esempio.it"
MAX_RETRIES = "3"
# Route patterns: il Worker risponde a questi URL
# (richiede un dominio nel tuo account Cloudflare)
# [[routes]]
# pattern = "esempio.it/api/*"
# zone_name = "esempio.it"
# Ambiente di staging (sovrascrive i valori di root)
[env.staging]
name = "mio-primo-worker-staging"
vars = { ENVIRONMENT = "staging" }
# Binding KV Namespace (vedi articolo 3)
# [[kv_namespaces]]
# binding = "CACHE"
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# preview_id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
The Fetch Handler: Complete Anatomy
Each Worker must export a default object with a method fetch.
Let's see each parameter in detail:
// src/index.ts
export interface Env {
// Variabili definite in [vars] di wrangler.toml
ENVIRONMENT: string;
API_BASE_URL: string;
MAX_RETRIES: string;
// Secrets aggiunti con wrangler secret put
DATABASE_URL: string;
API_KEY: string;
// Binding KV (se configurato)
// CACHE: KVNamespace;
}
export default {
/**
* Fetch handler: chiamato per ogni richiesta HTTP
*
* @param request - La richiesta HTTP in arrivo (Request standard WhatWG)
* @param env - I binding: variabili, secrets, KV, R2, D1, Durable Objects, AI
* @param ctx - Execution context: waitUntil() per operazioni post-risposta
*/
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Informazioni Cloudflare sulla richiesta
const cf = request.cf;
console.log({
country: cf?.country, // "IT", "US", "DE", ...
city: cf?.city, // "Milan", "New York", ...
datacenter: cf?.colo, // "MXP" (Milano), "LHR" (Londra), ...
asn: cf?.asn, // Numero ASN dell'ISP
tlsVersion: cf?.tlsVersion, // "TLSv1.3"
});
const url = new URL(request.url);
const { pathname, searchParams } = url;
// Routing semplice basato sul pathname
if (pathname === '/') {
return handleRoot(request, env);
}
if (pathname.startsWith('/api/')) {
return handleApi(request, env, ctx, pathname);
}
if (pathname === '/health') {
return new Response(JSON.stringify({ status: 'ok', env: env.ENVIRONMENT }), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Not Found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
},
};
async function handleRoot(request: Request, env: Env): Promise<Response> {
const html = `
<!DOCTYPE html>
<html>
<head><title>Mio Primo Worker</title></head>
<body>
<h1>Hello from ${env.ENVIRONMENT}!</h1>
<p>Cloudflare Worker is running.</p>
</body>
</html>
`;
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
}
async function handleApi(
request: Request,
env: Env,
ctx: ExecutionContext,
pathname: string,
): Promise<Response> {
// Verifica l'API key (dal header Authorization)
const authHeader = request.headers.get('Authorization');
if (!authHeader || authHeader !== `Bearer ${env.API_KEY}`) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
// Proxy verso il backend con retry logic
const maxRetries = parseInt(env.MAX_RETRIES);
const backendUrl = `${env.API_BASE_URL}${pathname}`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(backendUrl, {
method: request.method,
headers: request.headers,
body: request.method !== 'GET' ? request.body : undefined,
});
if (response.ok || attempt === maxRetries - 1) {
return response;
}
} catch (error) {
if (attempt === maxRetries - 1) {
return new Response(JSON.stringify({ error: 'Backend unavailable' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
// Exponential backoff (attenzione: consuma CPU time)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100));
}
}
return new Response(JSON.stringify({ error: 'Max retries exceeded' }), { status: 502 });
}
Advanced Routing with itty-router
For applications with many routes, writing manual routing becomes verbose. The library itty-router is designed specifically for Workers: it is lowercase (less than 1KB) and uses the same standard Web APIs.
# Installa itty-router
npm install itty-router
// src/index.ts con itty-router
import { Router, error, json, withParams } from 'itty-router';
export interface Env {
ENVIRONMENT: string;
API_KEY: string;
}
const router = Router();
// Middleware globale: aggiunge params al request object
router.all('*', withParams);
// Middleware di autenticazione
const authenticate = (request: Request & { params: Record<string, string> }, env: Env) => {
const apiKey = request.headers.get('x-api-key');
if (apiKey !== env.API_KEY) {
return error(401, 'Invalid API key');
}
// Ritornare undefined continua al prossimo handler
};
// Route pubbliche
router.get('/', () => new Response('Hello from Workers!'));
router.get('/health', (req: Request, env: Env) =>
json({ status: 'ok', environment: env.ENVIRONMENT, timestamp: Date.now() })
);
// Route con parametri URL
router.get('/users/:userId', authenticate, async (request, env: Env) => {
const { userId } = request.params;
// Fetch dall'origine con parametri
const user = await fetchUser(userId, env);
if (!user) {
return error(404, `User ${userId} not found`);
}
return json(user);
});
// Route con query parameters
router.get('/search', async (request: Request) => {
const url = new URL(request.url);
const query = url.searchParams.get('q');
const page = parseInt(url.searchParams.get('page') ?? '1');
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '10'), 100);
if (!query) {
return error(400, 'Query parameter "q" is required');
}
return json({
query,
page,
limit,
results: [], // qui andrebbero i risultati reali
});
});
// POST con body JSON
router.post('/users', authenticate, async (request: Request, env: Env) => {
let body: unknown;
try {
body = await request.json();
} catch {
return error(400, 'Invalid JSON body');
}
// Validazione base
if (typeof body !== 'object' || body === null || !('name' in body)) {
return error(422, 'Field "name" is required');
}
// Logica di business...
return json({ created: true, data: body }, { status: 201 });
});
// Catch-all per 404
router.all('*', () => error(404, 'Route not found'));
async function fetchUser(userId: string, env: Env) {
// Esempio: fetch da un'API esterna
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
if (!response.ok) return null;
return response.json();
}
export default {
fetch: router.fetch,
};
Managing Environment Variables and Secrets
Workers distinguishes two types of configuration values: variables (visible in the code and in Wrangler, not encrypted) e secrets (encrypted, visible only in the Worker at runtime, never clear in the dashboard).
# Aggiungere un secret (interattivo: chiede il valore)
wrangler secret put DATABASE_URL
# Enter a secret value: [digitare il valore, non visibile]
# Successfully created secret DATABASE_URL
# Aggiungere un secret da stdin (utile in CI/CD)
echo "postgres://user:password@host:5432/db" | wrangler secret put DATABASE_URL
# Listare i secrets (mostra solo i nomi, non i valori)
wrangler secret list
# [
# { "name": "DATABASE_URL", "type": "secret_text" },
# { "name": "API_KEY", "type": "secret_text" }
# ]
# Eliminare un secret
wrangler secret delete DATABASE_URL
# Aggiungere secrets per ambiente specifico
wrangler secret put DATABASE_URL --env staging
wrangler secret put DATABASE_URL --env production
Variables vs Secrets: When to Use What
- Variables ([vars]): Public API URLs, feature flags, configuration of the environment. Don't use them for sensitive data because they are visible in wrangler.toml and in the Cloudflare Dashboard.
- Secrets: API keys, database passwords, JWT tokens, any data sensitive. They are encrypted and are never exposed in the clear, not even in the logs.
Local Development with wrangler dev
wrangler dev starts a local server that simulates the Workers runtime with
automatic hot reload:
# Avvia il server di sviluppo locale
npm run dev # equivale a: wrangler dev
# Output tipico:
# ⛅️ wrangler 3.x.x
# -------------------
# Using vars defined in .dev.vars
# Your Worker has access to the following bindings:
# - Vars:
# - ENVIRONMENT: "development"
# - API_BASE_URL: "https://api.esempio.it"
# ⎔ Starting local server...
# [wrangler:inf] Ready on http://localhost:8787
# In un altro terminale, testa le route:
curl http://localhost:8787/health
# {"status":"ok","environment":"development","timestamp":1742000000000}
curl -H "x-api-key: test-key" http://localhost:8787/users/1
# {"id":1,"name":"Leanne Graham","username":"Bret",...}
For local secrets, create a file .dev.vars (to be added to
.gitignore!):
# .dev.vars - NON committare questo file!
# Questi valori sovrascrivono [vars] in wrangler.toml durante lo sviluppo locale
DATABASE_URL=postgres://localhost:5432/dev_db
API_KEY=dev-test-key-12345
STRIPE_WEBHOOK_SECRET=whsec_test_xxxxx
# Per usare .dev.vars anche per variabili non-secret:
ENVIRONMENT=development
API_BASE_URL=http://localhost:3000
Deploy in Production
Deploy is a single command that compiles the TypeScript, bundling the dependencies with esbuild and upload the result to the 300+ Cloudflare PoPs:
# Deploy all'ambiente di default (production)
wrangler deploy
# Output:
# ⛅️ wrangler 3.x.x
# -------------------
# Total Upload: 45.32 KiB / gzip: 12.18 KiB
# Uploaded mio-primo-worker (1.23 sec)
# Published mio-primo-worker (0.45 sec)
# https://mio-primo-worker.tuo-account.workers.dev
# Current Deployment ID: xxxxxxxxxx
# Current Version ID: yyyyyyyyyy
# Deploy su ambiente specifico
wrangler deploy --env staging
# Preview del deploy senza pubblicare
wrangler deploy --dry-run
# Verificare il Worker deployato
curl https://mio-primo-worker.tuo-account.workers.dev/health
Custom Domains and Route Patterns
To associate the Worker with a custom domain (which must be on Cloudflare), configure
the routes in wrangler.toml:
# wrangler.toml - configurazione route con dominio custom
name = "api-gateway"
main = "src/index.ts"
compatibility_date = "2025-11-01"
# Opzione 1: route pattern (piu flessibile)
# Corrisponde a: esempio.it/api/ e tutti i sottopercorsi
[[routes]]
pattern = "esempio.it/api/*"
zone_name = "esempio.it"
# Opzione 2: custom domain diretto (Workers For Platforms)
# [env.production]
# routes = [{ pattern = "api.esempio.it/*", zone_name = "esempio.it" }]
# Opzione 3: Worker Routes nel dashboard Cloudflare
# (utile per pattern complessi o gestione manuale)
Error Management and Response Helpers
A robust error handling pattern uses a global wrapper:
// src/utils/error-handler.ts
export function handleError(error: unknown): Response {
if (error instanceof WorkerError) {
return new Response(JSON.stringify({
error: error.message,
code: error.code,
}), {
status: error.status,
headers: { 'Content-Type': 'application/json' },
});
}
// Errore non atteso: non esporre dettagli in produzione
console.error('Unexpected error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
export class WorkerError extends Error {
constructor(
message: string,
public readonly status: number = 500,
public readonly code: string = 'INTERNAL_ERROR'
) {
super(message);
this.name = 'WorkerError';
}
static notFound(resource: string): WorkerError {
return new WorkerError(`${resource} not found`, 404, 'NOT_FOUND');
}
static unauthorized(): WorkerError {
return new WorkerError('Unauthorized', 401, 'UNAUTHORIZED');
}
static badRequest(message: string): WorkerError {
return new WorkerError(message, 400, 'BAD_REQUEST');
}
}
// src/index.ts - integrazione con il fetch handler
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
try {
return await handleRequest(request, env, ctx);
} catch (error) {
return handleError(error);
}
},
};
CORS: Cross-Origin Management in Workers
For APIs that need to be called from browsers on different domains, configure CORS directly in the Worker:
// src/middleware/cors.ts
const ALLOWED_ORIGINS = [
'https://esempio.it',
'https://www.esempio.it',
'http://localhost:3000', // solo in development
];
export function corsHeaders(request: Request, origin?: string): HeadersInit {
const requestOrigin = origin ?? request.headers.get('Origin') ?? '';
const allowed = ALLOWED_ORIGINS.includes(requestOrigin) ? requestOrigin : '';
return {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
};
}
// Gestisci il preflight OPTIONS
export function handleCors(request: Request): Response | null {
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: corsHeaders(request),
});
}
return null; // Continua con il normale handling
}
// Integrazione nel fetch handler
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
const response = await router.fetch(request, env);
// Aggiungi headers CORS a tutte le risposte
const newResponse = new Response(response.body, response);
Object.entries(corsHeaders(request)).forEach(([key, value]) => {
newResponse.headers.set(key, value);
});
return newResponse;
},
};
Conclusions and Next Steps
You now have all the tools to create robust TypeScript Workers: Routing with
itty-router, management of variables and secrets, local development with wrangler dev
and deploy into production. The next step is to make your Workers stateful
with the different storage layers that Cloudflare makes available.
Next Articles in the Series
- Article 3: Edge Persistence — KV, R2, and D1 — when and how to use Workers KV for global caching, R2 for object storage S3-compatible with no egress fee, and D1 for relational SQL queries.
- Article 4: Durable Objects — the state primitive strongly consistent and WebSocket at the edge.
- Article 9: Testing with Miniflare and Vitest — unit tests and integration tests for Workers without deployment.







