DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Seventeen environment variables, all of them secrets, and next build runs with none of them set

Nakodo's entire environment contract is one 45 line file, and it opens with the two rules that produced it:

// Every environment variable the app reads. They hold secrets only; settings
// are constants in code. Most are optional: a missing key switches its
// feature off (sending email, a discovery source, rate limits) rather than
// failing the app, so nothing is checked at import and `next build` works
// without them. An empty value counts as unset.
Enter fullscreen mode Exit fullscreen mode

Seventeen names in a typed union, and the whole public surface is this:

export function env(name: EnvName): string | undefined {
  return process.env[name] || undefined;
}

// For a key the caller can't work without: says which one is missing.
export function requireEnv(name: EnvName): string {
  const value = env(name);
  if (!value) throw new Error(`${name} is not set`);
  return value;
}
Enter fullscreen mode Exit fullscreen mode

No zod schema, no assertEnv() at boot, no .env.example that drifts. Each of those four decisions earns its place, and a couple of them are the opposite of the usual advice.

Rule one: the environment holds secrets, nothing else

Every one of the seventeen is a credential: a database URL, API keys, two webhook signing secrets, a cron secret. Not one of them is a setting.

Everything that is a setting is a constant in a TypeScript file, right next to the code that reads it, with a comment explaining the number. The outreach engine's config file says it outright:

// Settings for the outreach engine. Changing one is a commit.
Enter fullscreen mode Exit fullscreen mode

That is a real tradeoff and I would defend it in a review. Config in the environment is a tempting idea because it looks like flexibility: you can change behaviour without a deploy. What you actually get is behaviour that no longer lives in the repository. Nobody can diff it, nobody can review it, the test suite cannot assert on it, git log cannot explain it, and the value in the dashboard outlives everyone's memory of why it is 35 and not 60. We have 610 tests across 67 files, and a good share of them assert on numbers that would be unassertable if those numbers came from process.env.

The one structural benefit people cite for env config, changing a limit during an incident, is also available with a commit: the deploy is two minutes, and at the end of it the change is in the history. The incident where that is too slow is the one where I would want a hardcoded boolean anyway, which is exactly what our maintenance flag is.

The second benefit of this rule is subtler: if the environment contains only secrets, then "which environment variables does this app need" and "which secrets does this app hold" are the same question, with one answer, in one file, typed.

Rule two: nothing is validated at import

The usual pattern is a module that parses the whole environment at startup and throws if anything is missing, so you fail fast. We do not do that, for one concrete reason: the build.

next build compiles and pre-renders the public site. Nakodo's marketing side is 76 pages and none of them touch the database, so the build is the one process that should not need a single credential. Validate at import, and every CI job, every pnpm build on a laptop, and every preview deploy needs the full production secret set to compile static HTML. That is how teams end up with a vault in the build step and a build that cannot be run by a new contributor.

Instead, clients are built on first use, and the comment on the one that taught us this rule is still there:

// Built on first use, not at import: `new Resend(undefined)` throws, which
// would break `next build` on a machine with no key.
let client: Resend | undefined;
export function resend(): Resend {
  client ??= new Resend(requireEnv("RESEND_API_KEY"));
  return client;
}
Enter fullscreen mode Exit fullscreen mode
let client: OpenAI | undefined;
function openai(): OpenAI {
  client ??= new OpenAI({ apiKey: requireEnv("OPENAI_API_KEY") });
  return client;
}
Enter fullscreen mode Exit fullscreen mode

??= is the whole pattern: one instance per process, created the first time someone actually needs it, and requireEnv throws by name at that point rather than at import time. A module graph that reaches a third-party constructor during import is a module graph that decides whether your build works.

Rule three: a missing key switches a feature off, loudly

Failing fast is good advice because the failure mode it replaces is usually silence. We keep the fast failure for the keys that are a hard requirement at the point of use, and for everything else the feature turns itself off and reports it.

export function emailEnabled(): boolean {
  return !!env("RESEND_API_KEY");
}
Enter fullscreen mode Exit fullscreen mode
// Without UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN (or the
// KV_REST_API_* pair Vercel's Upstash integration sets) nothing is limited:
// the default locally and on previews.
Enter fullscreen mode Exit fullscreen mode
export function redisFromEnv(): Redis | null {
  const configured =
    (env("UPSTASH_REDIS_REST_URL") || env("KV_REST_API_URL")) &&
    (env("UPSTASH_REDIS_REST_TOKEN") || env("KV_REST_API_TOKEN"));
  return configured ? Redis.fromEnv() : null;
}
Enter fullscreen mode Exit fullscreen mode

The part that makes this honest rather than lazy is that the off state is visible where it matters. The admin page that sends email tells the operator, in the UI, that this deployment cannot:

{!emailEnabled() && (
  <p className="text-sm text-danger">
    RESEND_API_KEY isn&apos;t set on this deployment, so nothing can be sent from here. The preview still works.
  </p>
)}
Enter fullscreen mode Exit fullscreen mode

And the action refuses with the same sentence rather than throwing:

if (!emailEnabled()) {
  return { error: "RESEND_API_KEY isn't set on this deployment, so nothing can be sent." };
}
Enter fullscreen mode Exit fullscreen mode

Naming the variable in a message a human will read is the cheapest debugging aid in the codebase. "Sending is not configured" sends somebody into the code; "RESEND_API_KEY isn't set on this deployment" sends them to the dashboard.

The same shape repeats for the data sources behind the pipeline: a source with no key is simply a source that is not searched, and the run log in the admin panel shows which did work today. That is the design that makes local development pleasant. A laptop with a database URL and one AI key can run almost the whole product, and nothing it does can reach a real inbox.

Rule four: empty counts as unset

return process.env[name] || undefined;
Enter fullscreen mode Exit fullscreen mode

|| not ??, which is a deliberate inversion of the habit most TypeScript codebases have. An environment variable set to the empty string is the single most common half-configured state in the wild: a dashboard field someone cleared, a KEY= line left behind in a CI config, a secret that was rotated into an empty value by a failed script.

With ?? you get "", which is truthy enough to pass an existence check and useless for everything else, and your error arrives later from inside a vendor SDK. With || the empty string is the same thing as the variable not existing, and every caller's "is this configured" check is correct for free.

The two deployment questions that are not the same question

The last two exports exist because "is this production" has two different answers and conflating them is a real bug:

// The production deployment on Vercel. Only it acts on the outside world:
// outreach emails creators for real and search engines are pinged. Previews
// share its database, so they must not.
export const isLiveDeployment = () => process.env.VERCEL_ENV === "production";

// A production build (`next build`, previews included), as opposed to the
// dev server: links in emails point at the live site.
export const isProductionBuild = () => process.env.NODE_ENV === "production";
Enter fullscreen mode Exit fullscreen mode

Preview deployments share the production database. That is convenient and it is also a loaded gun: every preview has real campaigns, real creators and real email addresses in reach, and NODE_ENV on a preview is "production". So anything that acts on the outside world is gated on VERCEL_ENV, not on NODE_ENV:

export function outreachLive(): boolean {
  return SEND_TO_CREATORS && isLiveDeployment();
}
Enter fullscreen mode Exit fullscreen mode

While isProductionBuild answers a completely different question, which is what URL to put in a link:

// The app's origin for links in emails: the live site from production builds
// (previews included, which share its database), and the local dev server
// otherwise, so test emails sent from a laptop open there.
export function appUrl(): string {
  return isProductionBuild() ? SITE_URL : "http://localhost:3000";
}
Enter fullscreen mode Exit fullscreen mode

Two one-line functions, two different variables, and the comments exist because the next person to need one of them will reach for whichever they remember.

What it costs

Honesty about the downside: with no startup validation, a missing key on production is not an outage, it is a quiet gap. The mitigations are the visible off states above and an admin run log that shows what each source and each AI task did today, but there is no single screen that says "these three secrets are missing", and I would add one before this app has a second operator.

Outside src/lib/env.ts, the whole app contains exactly one other process.env read: a NODE_ENV check in the database module that keeps one pooled client across dev-server reloads. Everything else goes through the typed union, which means the answer to "what does this deployment need" is a file you can read in a minute, and the answer to "what happens without it" is written next to each key.

If you want the product that sits on top of all this, how Nakodo works is the pipeline those keys are for, and the pricing page lists the per-plan limits that are constants in code rather than environment variables, which is the whole argument above applied to the numbers customers actually see.

Top comments (0)