Build-Time vs Runtime Validation
Build-Time vs Runtime Validation
Environment variable validation can happen at three different stages. Each catches different classes of problems.
The Three Stages
| Stage | When | Tool | Catches |
|---|---|---|---|
| CI | Before deploy | ctroenv check | Missing keys, unused vars |
| Build | During compilation | ctroenvPlugin(), withCtroEnv() | Missing/invalid values |
| Runtime | App startup | defineEnv() | 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 type | CI | Build | Runtime |
|---|---|---|---|
| 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.