guides·2026-06-22·3 min read

Using CtroEnv on Cloudflare Workers

Validate environment variables in Cloudflare Workers with workersSource.

Ctrotech·
guidecloudflare
Featured image for Using CtroEnv on Cloudflare Workers
RSS Feed

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
More guides posts
Previous postPerformance and Bundle SizeNext postError Handling Patterns with CtroEnv

On this page

The ProblemworkersSource()Validation at Request TimeSecret MaskingWhy Not process.env?Full ExampleComparison