Skip to content
Prompt of the Day — Part 21 of 30

Prompt of the Day: Set Up Environment Variable Validation with Zod

Written by claude-sonnet-4 · Edited by claude-sonnet-4
zodenvironment-variablesvalidationtypescriptnextjsnodejspythont3-envsecurityprompt-of-the-day

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:

  1. Silent fallbacks mask missing production config. If DATABASE_URL is unset in production, the app connects to localhost — which doesn't exist on your cloud server. You get connection errors 30 seconds after deploy, not at startup. The fallback hid the problem.

  2. Type confusion. process.env.PORT || 3000 evaluates to the string "3000" when PORT is set (because env vars are always strings) and the number 3000 when it's not. Depending on where you use port, you get different behavior in different environments. These bugs take hours to find.

  3. '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 (or src/config/env.ts) with a Zod schema covering every variable your app reads
  • Replace all process.env.SOME_VAR references 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.example to version control with placeholder values and comments generated from your schema
  • Add .env (and .env.local, .env.production) to .gitignore if 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 .env file is being COPY'd into the image layer
  • For Next.js: split server vs. client variables using t3-env's createEnv to 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.

Copy A Prompt Next

Review and debug

If this article changed how you think about the problem, copy a prompt that turns that judgment into one safe, reviewable next step.

Matching public prompts

23

Keep the task scoped, copy the prompt, then inspect one reviewable diff before the agent continues.

Need the safest first move instead? Open the curated sample prompts before you browse the broader library.

Working With AI ToolsWorking With AI Tools

System Prompts — .cursorrules and CLAUDE.md Explained

Write system prompts that give AI persistent context about your project and preferences.

Preview
**Use this when you want the agent to draft your persistent project instructions:**
"Help me write a system prompt file for this project.
Tool target: [Cursor / Claude Code / both]
Project summary: [what the app does]
Stack: [frameworks, languages, key services]
Prompt Engineering

Turn this workflow advice into a durable operating system

Prompt and workflow posts are the quick win. The learning paths turn them into a durable operating model for tools, prompts, and agent supervision.

Best Next Path

Working With AI Tools

Explorer · Free

Turn ad hoc prompting into a repeatable workflow with better tool choice, stronger prompting, and safer day-to-day AI habits.

23 lessonsIncluded in the free Explorer plan

Need the free route first?

Start with Foundations for AI-Assisted Builders if you want the workflow and vocabulary before you dive into the deeper path above.

T

About Tom Hundley

Tom Hundley writes for builders who need stronger technical judgment around AI-assisted software work. The Guild turns production experience into public articles, copy-paste prompts, and structured learning paths that help non-software developers supervise AI agents more safely.

Do this next

Leave this article with one concrete move. Copy the matching prompt, or start with the path that teaches the safest next skill in sequence.