Build-Time vs Runtime Validation

2026-06-24·Ctrotech·
guidepatterns

Build-Time vs Runtime Validation

Environment variable validation can happen at three different stages. Each catches different classes of problems.

The Three Stages

StageWhenToolCatches
CIBefore deployctroenv checkMissing keys, unused vars
BuildDuring compilationctroenvPlugin(), withCtroEnv()Missing/invalid values
RuntimeApp startupdefineEnv()Missing values, type errors

Stage 1: CI Validation

Run ctroenv check in CI to catch problems before the build starts:

# .github/workflows/env-check.yml
name: Environment Check
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx ctroenv check --source .env.example

This parses your .env.example and compares keys against the schema. No imports, no build — it runs in under a second. If a developer adds a new required var to the schema but forgets to add it to .env.example, CI fails immediately.

With --strict, it also validates values:

- run: npx ctroenv check --source .env.staging --strict

Stage 2: Build Validation

Framework adapters validate during the build step:

// Vite — ctroenvPlugin catches issues during vite build
ctroenvPlugin({ schema: "./src/env.ts", failOnError: true })
// Next.js — withCtroEnv validates during next build
export default withCtroEnv(schema, nextConfig)

Build validation catches problems that CI missed — for example, environment-specific values that are correct types but wrong for the target environment.

If validation fails, the build exits with code 1. No broken artifacts are produced.

Stage 3: Runtime Validation

defineEnv() validates at import time — the first time your app loads the env module:

// env.ts
export const env = defineEnv(schema)  // validates here

// app.ts
import { env } from "./env"           // error thrown here

Runtime validation catches everything CI and build didn't — primarily environment-specific values that only exist in production.

Which Stage Do You Need?

App typeCIBuildRuntime
Library✅❌✅
Web app (Vite)✅✅✅
Web app (Next.js)✅✅✅
CLI tool✅❌✅
Serverless function✅❌✅
Monorepo package✅❌✅

Libraries and CLI tools don't have a build step for the consumer — runtime validation is the only option. Web apps with bundlers can add build-time validation with the appropriate adapter.

Combining Stages

The three stages are complementary, not redundant:

CI says: DATABASE_URL is missing from .env.example
Build says: DATABASE_URL="not-a-url" fails URL validation
Runtime says: DATABASE_URL is set correctly, but the database is down

CI catches the missing key. Build catches the invalid URL. Runtime catches the operational issue.

Minimal Setup

The simplest setup that covers most cases:

# CI
npx ctroenv check --source .env.example

# Build (add to vite.config.ts or next.config.ts)
ctroenvPlugin({ schema: "./src/env.ts" })

# Runtime
const env = defineEnv(schema)

Three lines of configuration, three layers of protection.