Using CtroEnv on Cloudflare Workers

2026-06-22·Ctrotech·
guidecloudflare

Using CtroEnv on Cloudflare Workers

Cloudflare Workers get environment variables through a binding object, not process.env. CtroEnv's workersSource() adapter bridges the gap.

The Problem

In Cloudflare Workers, env vars are passed as the second argument to the fetch handler:

export default {
  async fetch(request, env, ctx) {
    // env.DATABASE_URL exists here, but:
    // - typeof env.DATABASE_URL is string | undefined
    // - No validation at startup
    // - Error surfaces when first used, not when deployed
  },
}

workersSource()

workersSource() wraps the Workers env binding as an EnvSource:

import { defineEnv, string, number, workersSource } from "@ctroenv/core"

interface EnvBindings {
  DATABASE_URL: string
  JWT_SECRET: string
  PORT: string
}

export default {
  async fetch(request, env: EnvBindings, ctx) {
    const config = defineEnv(
      {
        DATABASE_URL: string().url(),
        JWT_SECRET: string().min(32).secret(),
        PORT: number().port().default(3000),
      },
      { source: workersSource(env as unknown as Record<string, string | undefined>) },
    )

    // config.DATABASE_URL — validated URL
    // config.JWT_SECRET — masked at runtime
    // config.PORT — parsed number, defaults to 3000
  },
}

Validation at Request Time

Unlike Node.js where you validate once at startup, Workers need to validate on each request (or at cold start):

let config: ReturnType<typeof createConfig> | null = null

function createConfig(env: EnvBindings) {
  return defineEnv(schema, {
    source: workersSource(env as Record<string, string | undefined>),
  })
}

export default {
  async fetch(request, env, ctx) {
    try {
      config ??= createConfig(env)
    } catch (e) {
      return new Response("Configuration error", { status: 500 })
    }
    // handle request with config
  },
}

The nullish coalescing (??=) means validation runs once per isolate lifetime. Subsequent requests reuse the cached result.

Secret Masking

Secrets work the same as other adapters:

const config = defineEnv(
  { JWT_SECRET: string().secret() },
  { source: workersSource(env) },
)

config.JWT_SECRET           // "********"
config.meta.get("JWT_SECRET") // actual value

Why Not process.env?

Workers don't have process.env. Using workersSource() is explicit — you pass the binding object directly. If you forget, defineEnv() falls back to detectSource() which will check for import.meta.env or process.env and likely fail, giving you a clear error message.

Full Example

import { defineEnv, string, number, pick, workersSource } from "@ctroenv/core"

const schema = {
  DATABASE_URL: string().url(),
  JWT_SECRET: string().min(32).secret(),
  ENVIRONMENT: pick(["development", "production"] as const).default("production"),
  PORT: number().port().default(8787),
} as const

export default {
  async fetch(request, env, ctx) {
    const config = defineEnv(schema, {
      source: workersSource(env as Record<string, string | undefined>),
    })

    const url = new URL(request.url)

    if (url.pathname === "/health") {
      return Response.json({ status: "ok", env: config.ENVIRONMENT })
    }

    return new Response("Hello from CtroEnv on Workers!")
  },
}

Comparison

FeatureRaw Workers bindingsCtroEnv + workersSource
Type validationNoneFull schema validation
Typesstring | undefinedExact inferred types
Secret maskingNone.secret() Proxy masking
Default valuesManual.default()
Error messagesGeneric undefined errorsGrouped, descriptive errors