Ahmed Fathallah
WritingAboutContact
Sign in
WritingAboutContact
Sign inSign up

Ahmed Fathallah

Engineering notes on type-safe full-stack development, modern back ends and automated delivery.

WritingAboutContactSitemap

© 2026 Ahmed Fathallah

    Validating Next.js Environment Variables with Zod at Build Time and Runtime

    October 11, 2026·6 min read

    Docker
    Next.js
    TypeScript
    Zod
    Validating Next.js Environment Variables with Zod at Build Time and Runtime

    Here's a failure mode that no health check will catch: the build passes, the container reports healthy, every page returns 200, and production is still serving a placeholder value. Nothing crashed. A configuration value was simply wrong, and because it was a NEXT_PUBLIC_ variable, Next.js baked it into the JavaScript bundle at build time.

    Scenarios like this are why environment variables deserve more engineering rigour than they usually get. They're untyped strings, they come from outside your codebase, and in Next.js they behave differently depending on when and where they're read. This post covers how I validate them with Zod in a Dockerized Next.js app, at build time and at runtime, and the NEXT_PUBLIC_ rules that make it trickier than it looks.

    The problem with process.env.WHATEVER

    Out of the box, every environment variable is string | undefined, and nothing tells you that one is missing until the line that reads it runs. In practice that means:

    • a typo in MAILGUN_DOMIAN surfaces as a password reset email that silently never arrives,
    • a missing NEXTAUTH_SECRET surfaces as a cryptic error on the first sign-in attempt,
    • an empty DATABASE_URL surfaces as a 500 on the first page that touches the database.

    All of these are configuration errors, and configuration errors should fail at startup, loudly, with the name of the variable in the message.

    A typed, validated serverEnv

    The core of the approach is one module per side of the server/client boundary. Here's the server one:

    // lib/env/server.ts
    import { z } from "zod";
    
    const serverEnvSchema = z.object({
        NEXTAUTH_URL: z.string().url(),
        NEXTAUTH_SECRET: z.string().min(1),
        DATABASE_URL: z.string().url(),
        DIRECT_URL: z.string().url(),
        MAILGUN_API_KEY: z.string().min(1),
        MAILGUN_DOMAIN: z.string().min(1),
        MAILGUN_FROM_EMAIL: z.string().email(),
    });
    
    const parsed = serverEnvSchema.safeParse({
        NEXTAUTH_URL: process.env.NEXTAUTH_URL,
        NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
        DATABASE_URL: process.env.DATABASE_URL,
        DIRECT_URL: process.env.DIRECT_URL,
        MAILGUN_API_KEY: process.env.MAILGUN_API_KEY,
        MAILGUN_DOMAIN: process.env.MAILGUN_DOMAIN,
        MAILGUN_FROM_EMAIL: process.env.MAILGUN_FROM_EMAIL,
    });
    
    if (!parsed.success) {
        throw new Error(
            `Invalid server environment variables: ${parsed.error.issues
                .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
                .join(", ")}`
        );
    }
    
    export const serverEnv = parsed.data;
    

    Everything else in the app imports serverEnv instead of touching process.env. You get autocompletion, serverEnv.MAILGUN_FROM_EMAIL is typed as string rather than string | undefined, and a bad value throws the moment the module is first imported, with a message like:

    Invalid server environment variables: MAILGUN_FROM_EMAIL: Invalid email
    

    A few details worth calling out:

    • .url() works for database URLs. postgresql://user:pass@host:5432/db is a valid URL as far as the WHATWG parser is concerned, so z.string().url() catches the classic "pasted only the hostname" mistake.
    • Validate the shape you rely on. MAILGUN_FROM_EMAIL is checked with .email() because Mailgun rejects a malformed sender. A .min(1) there would let no-reply through.
    • Why list every key instead of passing process.env? For server-only variables you could write serverEnvSchema.safeParse(process.env). For public ones you can't, and keeping both files in the same style makes the difference harder to forget. More on that next.

    Keep it out of client bundles

    If a client component ever imports lib/env/server.ts, the bundler pulls it into browser code. On the client, process.env.DATABASE_URL is undefined, so the module throws in the user's browser. That's actually the good failure mode; the bad one is a schema with defaults that quietly "works". The robust fix is to install the server-only package and add one line at the top of the file:

    import "server-only";
    

    With that import, importing the module from a client component becomes a build error instead of a runtime surprise.

    NEXT_PUBLIC_ variables play by different rules

    Variables prefixed with NEXT_PUBLIC_ are exposed to the browser. The mechanism matters: next build finds every occurrence of process.env.NEXT_PUBLIC_SOMETHING in your source and replaces it with the literal value at build time. There's no process.env object in the browser to read from later.

    Three consequences follow, and each one shows up regularly in real projects:

    1. Dynamic access doesn't work. The replacement is a textual find-and-replace on the exact expression, so this is fine:

    const title = process.env.NEXT_PUBLIC_BLOG_TITLE; // inlined at build time
    

    but these are not:

    const key = "NEXT_PUBLIC_BLOG_TITLE";
    const title = process.env[key]; // undefined in the browser
    
    const { NEXT_PUBLIC_BLOG_TITLE } = process.env; // also undefined in the browser
    

    That's why the public env module spells each key out:

    // lib/env/public.ts
    import { z } from "zod";
    
    const publicEnvSchema = z.object({
        NEXT_PUBLIC_BLOG_TITLE: z.string().min(1),
    });
    
    const parsed = publicEnvSchema.safeParse({
        NEXT_PUBLIC_BLOG_TITLE: process.env.NEXT_PUBLIC_BLOG_TITLE,
    });
    
    if (!parsed.success) {
        throw new Error(`Invalid public environment variables: ...`);
    }
    
    export const publicEnv = parsed.data;
    

    2. The value is frozen into the image. If you build a Docker image once and promote it through staging and production, every environment gets the staging value. You can set NEXT_PUBLIC_API_URL in production's env file as much as you like; the compiled JavaScript already contains the old string. Either build per environment, or keep environment-specific values server-side and pass them down as props.

    3. Validation can't catch "valid but wrong". This is the placeholder scenario from the introduction. z.string().min(1) happily accepts a placeholder. If a value has a known placeholder you'd like to block, say so in the schema:

    NEXT_PUBLIC_BLOG_TITLE: z
        .string()
        .min(1)
        .refine((v) => !/^(changeme|todo|tbd|your-value-here)$/i.test(v), {
            message: "still set to a placeholder",
        }),
    

    Validating in Docker: build time and runtime

    Module-level validation only fires when a module is imported. To fail before the app does anything at all, I run a small standalone script at two points: inside the Docker build, and as the first thing the container does on start.

    // scripts/validate-env.mjs
    import { z } from "zod";
    
    const buildEnvSchema = z.object({
        NEXTAUTH_URL: z.string().url(),
        NEXT_PUBLIC_BLOG_TITLE: z.string().min(1),
        DATABASE_URL: z.string().url(),
        NEXTAUTH_SECRET: z.string().min(1),
        MAILGUN_FROM_EMAIL: z.string().email(),
        // ...
    });
    const runtimeEnvSchema = buildEnvSchema;
    
    const mode = process.argv[2] ?? "runtime";
    const schema = mode === "build" ? buildEnvSchema : runtimeEnvSchema;
    const parsed = schema.safeParse(process.env);
    
    if (!parsed.success) {
        const details = parsed.error.issues
            .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
            .join("\n");
        console.error(`Environment validation failed for ${mode}:\n${details}`);
        process.exit(1);
    }
    

    Plain .mjs with no TypeScript and no framework imports means it runs with bare node anywhere. Separate build and runtime schemas let you require different things in each phase, for example a runtime-only SENTRY_DSN. Today mine are the same object, but the seam is there.

    In the Dockerfile it gates the build:

    RUN --mount=type=secret,id=DATABASE_URL \
        --mount=type=secret,id=NEXTAUTH_SECRET \
        sh -c 'export DATABASE_URL=$(cat /run/secrets/DATABASE_URL) && \
               export NEXTAUTH_SECRET=$(cat /run/secrets/NEXTAUTH_SECRET) && \
               node scripts/validate-env.mjs build && \
               pnpm run build'
    

    and the container start:

    CMD ["sh", "-c", "node ./scripts/validate-env.mjs runtime && node server.js"]
    

    If the runtime check fails, the container exits immediately with the list of bad variables in docker logs, and restart: unless-stopped turns that into an obvious restart loop instead of a "healthy" container serving errors.

    One gotcha with Next.js standalone output: the app's own use of zod gets bundled into the server chunks, so the trimmed node_modules in .next/standalone doesn't necessarily contain zod as a package. The validation script runs outside the bundle, so copy the package into the runtime image yourself:

    COPY --from=builder /app/scripts/validate-env.mjs ./scripts/validate-env.mjs
    COPY --from=builder /app/node_modules/zod ./node_modules/zod
    

    And once more in CI

    The last line of defence is the cheapest one: before the Docker build even starts, the GitHub Actions workflow checks that every variable and secret it's about to pass in is non-empty.

    - name: Validate required build configuration
      env:
        NEXT_PUBLIC_BLOG_TITLE: ${{ vars.NEXT_PUBLIC_BLOG_TITLE }}
        DATABASE_URL: ${{ secrets.DATABASE_URL }}
        # ...
      run: |
        set -eu
        for name in NEXT_PUBLIC_BLOG_TITLE DATABASE_URL; do
          if [ -z "${!name:-}" ]; then
            echo "Missing required GitHub Actions variable/secret: $name" >&2
            exit 1
          fi
        done
    

    This catches the most common deployment mistake, a secret that was never created in a new repo, in about two seconds rather than three minutes into a build.

    Summary

    • Read environment variables in exactly one place per boundary (serverEnv, publicEnv) and validate them with Zod there.
    • Mark the server module with import "server-only".
    • Reference NEXT_PUBLIC_* variables literally. They're replaced at build time and frozen into the bundle.
    • Add a framework-free validation script and run it in the Docker build and before node server.js.
    • Schemas catch missing and malformed values, not wrong ones. If a placeholder is plausible, reject it explicitly.

    Configuration is part of your application's interface with the outside world. Validating it with the same rigour as user input turns a whole class of silent production issues into loud, early and cheap failures.

    Comments

    Related Articles

    How This Blog Ships to Production with Docker, GHCR and Caddy

    How This Blog Ships to Production with Docker, GHCR and Caddy

    The fully automated pipeline behind this site: a multi-stage Next.js Dockerfile with BuildKit secrets, immutable SHA-tagged images on GHCR, workflow_run deployments with instant rollbacks, and Caddy as a hardened reverse proxy.

    Docker
    Next.js
    DevOps
    CI/CD
    Laravel-Style Authorization Policies for tRPC Procedures

    Laravel-Style Authorization Policies for tRPC Procedures

    Bringing Laravel's policy pattern to a Next.js + tRPC + Prisma app: permission-based policy classes, one authorize() call per procedure, compile-time checked action names, and Prisma scopes for lists.

    Next.js
    TypeScript
    tRPC
    Prisma
    PHP Backed Enums as the Single Source of Truth in a Laravel API

    PHP Backed Enums as the Single Source of Truth in a Laravel API

    Using PHP 8.1 backed enums so each set of statuses is defined once: string columns, Eloquent casts, Rule::enum validation, state transitions, value/label API responses and generated TypeScript types.

    PHP
    TypeScript
    Laravel