Skip to main content

Getting Started with Cloudflare Workers

This guide walks you through building a Zelt application on Cloudflare Workers from scratch.

Prerequisites​

  • Node.js v20 or higher (or Bun v1.0 or higher)
  • A package manager: pnpm (recommended), npm, or bun

Additional requirements for Cloudflare Workers:

Installation​

pnpm add @zeltjs/core @zeltjs/adapter-cloudflare-workers
pnpm add -D wrangler @cloudflare/workers-types

Project Structure​

my-app/
├── src/
│   ├── entry/
│   │   ├── controllers/    # HTTP endpoints
│   │   └── commands/       # CLI commands
│   ├── services/           # Business logic
│   ├── configs/            # Configuration classes
│   ├── app.ts              # Application definition
│   ├── cli.ts              # CLI entry point
│   └── main.ts             # HTTP server entry point
├── package.json
└── tsconfig.json
DirectoryPurpose
entry/External entry points (HTTP, CLI)
services/Business logic, injected via DI
configs/Environment variables and settings

For Cloudflare Workers, also add wrangler.toml at the project root.

Hello World​

Step 1: Create the Controller​

Controllers handle incoming HTTP requests and return responses. Each controller is a class decorated with @Controller that defines a route prefix.

Create src/entry/controllers/hello.controller.ts:

@Controller('/hello')
export class HelloController {
  @Get('/:name')
  greet(req = request()) {
    const name = req.pathParam('name');
    return { message: `Hello, ${name}!` };
  }
}
  • @Controller('/hello') — Sets the base path for all routes in this controller
  • @Get('/:name') — Handles GET requests to /hello/:name
  • req.pathParam('name') — Extracts the name parameter from the URL path

Step 2: Create the Application​

Create src/app.ts to wire up your controllers:

export const app = createApp([http({
    controllers: [HelloController],
  })]);

Step 3: Create the Worker Entry Point​

Create src/index.ts as the Cloudflare Workers entry point:

const workers = await onCloudflareWorkers(app);

export default { fetch: workers.http.fetch };

The onCloudflareWorkers() function is async and prepares your app for the Workers runtime. It returns the declared feature namespaces and common runtime APIs such as shutdown and get. Publish an HTTP feature through workers.http.fetch; no root fetch is added and HTTP is not required for initialization. By default, it uses lazy initialization (warmup: false) — controllers are resolved on the first request rather than at startup. This optimizes cold start times in serverless environments.

Step 4: Configure Wrangler​

Create wrangler.toml:

name = "my-zelt-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]

[vars]
API_HOST = "https://api.example.com"

Step 5: Configure TypeScript​

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "experimentalDecorators": true,
    "skipLibCheck": true,
    "types": ["@cloudflare/workers-types"]
  },
  "include": ["src"]
}

Step 6: Run Locally​

npx wrangler dev

Visit http://localhost:8787/hello/world to see:

{ "message": "Hello, world!" }

Configuration​

Environment Variables​

In Cloudflare Workers, environment variables are configured in wrangler.toml and accessed via Env.

@Controller('/config')
export class ConfigController {
  constructor(private env = inject(Env)) {}

  @Get('/api-host')
  getApiHost() {
    return { apiHost: this.env.getString('API_HOST', 'localhost') };
  }
}
export const app = createApp([http({
    controllers: [ConfigController],
  })]);

Important: onCloudflareWorkers() automatically registers the env adaptor, so inject(Env) reads environment variables from the Workers runtime (cloudflare:workers module) without any extra configuration.

Secrets​

For sensitive values, use Wrangler secrets instead of [vars]:

npx wrangler secret put DATABASE_URL

Access them the same way via Env:

  get connectionUrl() {
    return this.env.getString('DATABASE_URL', '');
  }
}

Cloudflare Bindings​

Wrangler bindings (D1, KV, R2, Durable Objects, etc.) are passed to fetch(request, env, ctx) as env, separate from the string-only environment variables exposed through Env. Use CloudflareBindings to access them with types generated by wrangler types:

@Controller('/data')
export class DataController {
  constructor(private bindings = inject(CloudflareBindings)) {}

  @Get('/:key')
  async get(req = request()) {
    const value = await this.bindings.get('CACHE').get(req.pathParam('key'));
    return { value };
  }
}

bindings.get('CACHE') is typed as KVNamespace (or D1Database, R2Bucket, etc. — whatever binding you declared) without any manual typing, since it reads the type straight from the generated Cloudflare.Env.

CloudflareBindings.get() reads directly from the native cloudflare:workers env. It does not require a Zelt HTTP wrapper or request scope. The binding's own runtime rules still apply to operations such as KV or D1 queries.

Services​

Services work identically to Node.js. Use @Injectable to mark a class as a service.

@Injectable()
export class GreetingService {
  greet(name: string): string {
    return `Hello, ${name}!`;
  }
}

Inject into controllers:

@Controller('/hello')
export class HelloController {
  constructor(private greetingService = inject(GreetingService)) {}

  @Get('/:name')
  greet(req = request()) {
    const name = req.pathParam('name');
    return { message: this.greetingService.greet(name) };
  }
}

Deploy​

Deploy your worker to Cloudflare's global network:

npx wrangler deploy

Your worker will be available at https://my-zelt-worker.<your-subdomain>.workers.dev.

Advanced: Warmup Option​

By default, onCloudflareWorkers() uses lazy initialization (warmup: false) to minimize cold start time. Controllers are resolved on the first request.

If you prefer to resolve all controllers at initialization (useful for debugging or when cold start time is less critical), set warmup: true:

const workers = await onCloudflareWorkers(app, { warmup: true });

export default { fetch: workers.http.fetch };
OptionBehaviorUse Case
warmup: false (default)Controllers resolved on first requestOptimized cold starts
warmup: trueAll controllers resolved at initializationDebugging, warm environments

What's Next?​

Now that you have a basic worker running, explore more features: