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_DOMIANsurfaces as a password reset email that silently never arrives, - a missing
NEXTAUTH_SECRETsurfaces as a cryptic error on the first sign-in attempt, - an empty
DATABASE_URLsurfaces 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/dbis a valid URL as far as the WHATWG parser is concerned, soz.string().url()catches the classic "pasted only the hostname" mistake.- Validate the shape you rely on.
MAILGUN_FROM_EMAILis checked with.email()because Mailgun rejects a malformed sender. A.min(1)there would letno-replythrough. - Why list every key instead of passing
process.env? For server-only variables you could writeserverEnvSchema.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.
