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.toml and 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 dev and 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.