Prompt of the Day: Set Up Environment Variable Validation with Zod
Part 21 of 30 — Prompt of the Day Series
The Deployment That Should Have Failed Louder
June 4, 2025. 11:14 UTC. The Be My Eyes engineering team deploys a new environment variable to production. Four minutes later, Be My AI chat is completely down, volunteer calls are unreachable, and the incident report reads: "An incorrectly formatted environment variable in our production environment caused application errors and degraded service availability."
Four minutes to detect, four minutes to roll back. Not catastrophic — but entirely preventable. The app booted fine. It accepted traffic fine. It only blew up when the code finally reached the line that tried to use that malformed variable.
That's the process.env trap. By default, your app will happily start with missing or garbage environment variables, then explode in the middle of serving real users.
Now layer on a February 2026 finding from Mysterium VPN: over 12 million IP addresses publicly serving accessible .env files, exposing database credentials, JWT signing keys, and API tokens to anyone who knows to request /.env. And Snyk's 2026 research found 28.65 million hardcoded secrets added to public GitHub repositories in 2025 alone — a 34% year-over-year increase.
Environment variable hygiene isn't a nice-to-have. It's one of the most consistently exploited surface areas in production systems. Today's prompt gets you Zod validation that catches problems at startup — not at 3 AM.
The Prompt
Set up environment variable validation for my [Node.js/TypeScript/Next.js] project using Zod.
Requirements:
1. Create a dedicated `src/env.ts` (or `src/config/env.ts`) file that defines a Zod schema for all required environment variables.
2. Include these variables in the schema: [LIST YOUR ACTUAL ENV VARS, e.g. DATABASE_URL, NEXTAUTH_SECRET, STRIPE_SECRET_KEY, NODE_ENV].
3. Use z.coerce.number() for PORT and similar numeric values, z.string().url() for connection strings and webhook URLs, z.enum(["development", "staging", "production"]) for NODE_ENV, and z.string().min(1) for required secret strings.
4. Call .parse(process.env) at the top of the file so the app throws and exits immediately at startup if any variable is missing or invalid — not at runtime.
5. Export the validated env object as the default export so all other files import from this module instead of accessing process.env directly.
6. Add a descriptive .describe() message to each field explaining what it's used for, so the error output tells engineers exactly which service is broken.
7. If using Next.js, separate server-side variables (under a `server` key) from client-side variables (NEXT_PUBLIC_ prefix, under a `client` key) using t3-env's createEnv pattern.
Here is my current .env.example file:
[PASTE YOUR .env.example]
Why It Works
This prompt produces a fail-fast validation layer — the single most important architectural upgrade you can make to a production Node.js app's configuration story. Here's what each requirement buys you:
Requirement 3 — typed coercions. process.env returns everything as a string. Without coercion, PORT=3000 is the string "3000", and passing it to a function expecting a number silently produces NaN. z.coerce.number() converts it correctly and fails loudly if the value can't be coerced.
Requirement 4 — startup crash instead of runtime crash. This is the core insight. The Be My Eyes incident would have been caught in CI if their pipeline validated env vars before the app started accepting traffic. .parse() throws a ZodError at module load time; your process manager, Docker health check, or CI step catches it before a single request is served.
Requirement 5 — single source of truth. When every file imports env from src/env.ts instead of hitting process.env directly, you get TypeScript autocompletion, refactoring safety, and a natural choke point for auditing what your app actually reads. Scattered process.env.SOME_KEY || 'fallback' patterns hide missing variables behind silent defaults — exactly the kind of thing that shows up in post-mortems.
Requirement 6 — human-readable error messages. A bare ZodError tells you the field name and the violation. Adding .describe("Stripe secret key used for payment processing — rotate via Stripe dashboard") means the on-call engineer reading the startup log knows immediately which external service needs attention, not just which variable name is missing.
Requirement 7 — the Next.js split. Client-side code gets bundled and sent to browsers. Any variable referenced in client-side code is effectively public. The t3-env createEnv pattern enforces this boundary at the type level, so you can't accidentally expose DATABASE_URL to the browser bundle.
A Concrete Example
Here's what the AI should produce for a Next.js app:
// src/env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z
.string()
.url()
.describe("PostgreSQL connection string — obtain from Supabase dashboard"),
NEXTAUTH_SECRET: z
.string()
.min(32)
.describe("Random secret for NextAuth session signing — generate with: openssl rand -base64 32"),
STRIPE_SECRET_KEY: z
.string()
.startsWith("sk_")
.describe("Stripe secret key — use sk_test_ in dev, sk_live_ in production"),
NODE_ENV: z
.enum(["development", "staging", "production"])
.default("development"),
},
client: {
NEXT_PUBLIC_APP_URL: z
.string()
.url()
.describe("Public base URL of the app — used for OAuth redirects"),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
});
Install the dependency first:
# For Next.js
npm install @t3-oss/env-nextjs zod
# For any other Node.js project (no framework-specific features)
npm install zod
For a plain Node.js or Express app without t3-env, the AI should produce:
// src/config/env.ts
import { z } from "zod";
const envSchema = z.object({
PORT: z.coerce.number().min(1024).max(65535).default(3000)
.describe("HTTP port the server listens on"),
DATABASE_URL: z.string().url()
.describe("PostgreSQL connection string"),
JWT_SECRET: z.string().min(32)
.describe("HS256 signing secret — at least 256 bits of entropy"),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("❌ Invalid environment variables:");
console.error(JSON.stringify(parsed.error.flatten().fieldErrors, null, 2));
process.exit(1);
}
export const env = parsed.data;
Using safeParse instead of parse lets you print a formatted error message before exiting — much friendlier than a raw ZodError stack trace in production logs.
For Python teams, Pydantic's BaseSettings covers the same ground:
# config/settings.py
from pydantic import AnyUrl, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
database_url: AnyUrl = Field(
..., description="PostgreSQL connection string"
)
jwt_secret: str = Field(
..., min_length=32, description="HS256 signing secret"
)
port: int = Field(default=8000, ge=1024, le=65535)
environment: str = Field(
default="development",
pattern="^(development|staging|production)$"
)
# Instantiate at module load — raises ValidationError on startup if invalid
settings = Settings()
The Anti-Prompt
Here's what I see in vibe-coded projects all the time:
Add environment variable support to my app.
Why it fails: The AI will generate something like this:
// What you get with the anti-prompt
const dbUrl = process.env.DATABASE_URL || 'postgresql://localhost/dev';
const port = process.env.PORT || 3000;
const secret = process.env.JWT_SECRET || 'changeme';
This pattern has three quiet time bombs:
Silent fallbacks mask missing production config. If
DATABASE_URLis unset in production, the app connects tolocalhost— which doesn't exist on your cloud server. You get connection errors 30 seconds after deploy, not at startup. The fallback hid the problem.Type confusion.
process.env.PORT || 3000evaluates to the string"3000"whenPORTis set (because env vars are always strings) and the number3000when it's not. Depending on where you useport, you get different behavior in different environments. These bugs take hours to find.'changeme'in production. I've pulled that exact string from production logs more times than I'd like to admit. A fallback secret that works in development creates a false sense that missing config is fine. It is not.
The || 'fallback' pattern is a courtesy to developers and a landmine for production. Zod validation removes all of it.
Variations
Variation 1 — Validate in CI before deploy:
Using the env.ts schema I already have, generate a standalone Node.js script at scripts/validate-env.ts that I can run in CI (e.g., GitHub Actions) to fail the pipeline if any required environment variable is missing from the repo's environment secrets. The script should exit with code 1 and print a table of missing variables.
Variation 2 — Document your env vars automatically:
Read my src/env.ts Zod schema and generate an updated .env.example file. For each variable, use the .describe() message as an inline comment. Mark optional variables with a comment and include a placeholder value that makes the format obvious without exposing real secrets.
Variation 3 — Validate at the edge (Cloudflare Workers / Vercel Edge):
Adapt my existing Zod env schema for Cloudflare Workers, where environment variables are accessed from the `env` parameter of the fetch handler, not from process.env. The schema should validate the env object at the top of the handler before any other logic runs.
Variation 4 — Add runtime env checks to Docker:
Generate a Docker HEALTHCHECK and an entrypoint shell script that validates required environment variables before starting the Node.js process. The script should print each missing variable name and exit with code 1 if any are absent, so `docker run` fails visibly instead of starting a broken container.
Your Checklist
- Create
src/env.ts(orsrc/config/env.ts) with a Zod schema covering every variable your app reads - Replace all
process.env.SOME_VARreferences in your codebase with imports from your env module - Add
.describe()to every field with a plain-English explanation - Verify the app crashes with a clear error if you start it with a variable missing from
.env - Add
.env.exampleto version control with placeholder values and comments generated from your schema - Add
.env(and.env.local,.env.production) to.gitignoreif not already there - Add an env validation step to your CI pipeline that runs before the build step
- Audit your Docker images to ensure no
.envfile is beingCOPY'd into the image layer - For Next.js: split server vs. client variables using t3-env's
createEnvto prevent secret leakage into the browser bundle
Ask The Guild
What's the worst environment variable incident you've been part of — a missing secret that crashed prod, a DATABASE_URL pointing to the wrong environment, or something you'd rather forget? Drop it in the thread. Bonus points if you share how you validated your env after cleaning up the mess.