DEV Community

Cover image for Stop fixing env vars one failed deploy at a time
Stijn
Stijn

Posted on AI-assisted

Stop fixing env vars one failed deploy at a time

You know this loop:

  1. Deploy.
  2. App crashes: DATABASE_URL is not defined.
  3. Add it, redeploy.
  4. App crashes: STRIPE_KEY is not defined.
  5. Add it, redeploy.
  6. App boots, but PORT is "3000 " with a trailing space and something somewhere quietly turns it into NaN.

Every round trip costs a build, a deploy and a bit of your sanity. I hit this often enough that I wrote a small library to make it go away: envella. This post is about the problem more than the library, though. Even if you never install it, the ideas apply.

process.env lies to you

As far as TypeScript knows, every environment variable is string | undefined:

const port = process.env.PORT; // string | undefined
Enter fullscreen mode Exit fullscreen mode

So you write something like this:

const port = Number(process.env.PORT) || 3000;
const debug = process.env.DEBUG === 'true';
const db = process.env.DATABASE_URL!;
Enter fullscreen mode Exit fullscreen mode

Every line has a problem:

  • Number("abc") is NaN, and NaN || 3000 silently gives you 3000. A typo in your config becomes a default you never asked for.
  • DEBUG=1 or DEBUG=yes is false.
  • The ! doesn't check anything. It just tells the compiler to stop worrying, and then your app crashes the first time a request actually touches the database. That might be minutes after startup, or only on one code path.

The fix is old advice: parse your config once, at startup, and fail loudly if it's wrong. After that, the rest of your code gets real types and never touches process.env again.

Validate everything up front

With envella you describe the variables once:

// env.ts
import { createEnv, url, port, bool, oneOf, str } from 'envella';

export const env = createEnv({
  DATABASE_URL: url({ description: 'Postgres connection string' }),
  PORT: port({ default: 3000 }),
  NODE_ENV: oneOf(['development', 'production', 'test'], { default: 'development' }),
  DEBUG: bool({ default: false }),
  STRIPE_KEY: str({ secret: true }),
  SENTRY_DSN: url({ optional: true }),
});
Enter fullscreen mode Exit fullscreen mode

And the types follow from the schema:

env.PORT;       // number
env.DEBUG;      // boolean
env.NODE_ENV;   // "development" | "production" | "test"
env.SENTRY_DSN; // string | undefined
Enter fullscreen mode Exit fullscreen mode

default removes undefined from the type, optional keeps it. No casting, no !.

The parsing is env-friendly too. bool() accepts true/false, 1/0, yes/no and on/off. port() only accepts integers from 1 to 65535. And an empty string counts as missing, so a PORT= line in your .env falls back to the default instead of failing.

Report every problem at once

This is the part that kills the deploy loop. When something is wrong, you don't get the first problem, you get all of them:

Invalid environment variables:
  x DATABASE_URL  missing, expected url (Postgres connection string)
  x PORT          "abc" is invalid: expected a port (1-65535)
  x STRIPE_KEY    missing, expected string
Enter fullscreen mode Exit fullscreen mode

One failed boot, one list, one fix. The description you wrote in the schema shows up right next to the error, so whoever is fixing it (future you, a teammate, the person on call at 2 AM) knows what the variable is for.

Don't leak secrets in your error logs

Showing the bad value is really useful: "abc" is invalid tells you much more than PORT is invalid. But if you do that naively, the first time someone pastes the wrong thing into STRIPE_KEY, your actual secret ends up in your logs, your error tracker and maybe a Slack alert.

So anything marked secret: true gets masked. Say someone forgets the https:// on a webhook URL that carries a token:

SLACK_WEBHOOK_URL: url({ secret: true }),
Enter fullscreen mode Exit fullscreen mode
Invalid environment variables:
  x SLACK_WEBHOOK_URL  **** is invalid: expected a URL
Enter fullscreen mode Exit fullscreen mode

You still see which variable is broken, just not its contents.

If you'd rather exit cleanly than throw a stack trace, there's a hook for that:

const env = createEnv(schema, {
  onError(error) {
    console.error(error.message);
    process.exit(1);
  },
});
Enter fullscreen mode Exit fullscreen mode

error.issues gives you { key, message }[] if you want to format things yourself.

Keep .env.example honest

Every project has a .env.example. Almost every project's .env.example is out of date, because nobody remembers to update it when they add a variable.

The schema already knows every variable, its type, whether it's required, whether it's secret, and what it's for. So it can write the file for you:

# Postgres connection string (url, required)
DATABASE_URL=

# (port, optional)
PORT=3000

# (string, required, secret)
STRIPE_KEY=
Enter fullscreen mode Exit fullscreen mode

To make that work from the command line, export the schema (not the result of createEnv) from its own file:

// env.schema.ts
import { port, str, url } from 'envella';

export const schema = {
  DATABASE_URL: url({ description: 'Postgres connection string' }),
  PORT: port({ default: 3000 }),
  STRIPE_KEY: str({ secret: true }),
};
Enter fullscreen mode Exit fullscreen mode

Then:

npx envella example env.schema.ts          # writes .env.example
npx envella example env.schema.ts --check  # fails if it's out of date
Enter fullscreen mode Exit fullscreen mode

Catch it in CI, not in production

Startup validation is great, but it still means a deploy has to happen before you find out. The same CLI can check the environment before you ship:

npx envella check env.schema.ts --env-file .env.production
# ✓ 3 environment variables valid
Enter fullscreen mode Exit fullscreen mode

In GitHub Actions that's two extra steps:

- run: npx envella check env.schema.ts
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
    STRIPE_KEY: ${{ secrets.STRIPE_KEY }}

- run: npx envella example env.schema.ts --check
Enter fullscreen mode Exit fullscreen mode

The first one fails the pipeline when a secret is missing or malformed. The second fails a PR that adds a variable without updating .env.example.

TypeScript schema files need Node 22.18+ (native type stripping), Bun (bunx envella) or Deno.

No process.env? Pass the binding

Not every runtime has process.env. Envella reads process.env on Node and Bun, Deno.env on Deno, and on Cloudflare Workers you pass the env binding yourself:

export default {
  fetch(request, workerEnv) {
    const env = createEnv(schema, { source: workerEnv });
    // ...
  },
};
Enter fullscreen mode Exit fullscreen mode

The same goes for anything else that hands you an object, like Vite's import.meta.env.

Why not envalid, t3-env or Zod?

They're all good, and envella borrows ideas from them. Quick honest version:

  • envalid is the closest in spirit. Envella adds secret masking, the .env.example generator and the CLI, and it's smaller (~2 kB vs ~7 kB gzipped).
  • t3-env is the better pick if you use Next.js or Vite and need to stop server variables from leaking into the client bundle. Envella doesn't do that split.
  • Zod on its own works fine if your whole app already speaks Zod. You'll just write the coercion ("yes" to true, port ranges, lists) yourself.

If you want something tiny, dependency-free, that runs anywhere and documents itself, that's the gap envella fills.

Try it

npm install envella
# or
deno add jsr:@stijnwtf/envella
Enter fullscreen mode Exit fullscreen mode

Zero dependencies, ~2 kB gzipped, MIT licensed. Code, docs and the full validator list are on GitHub: github.com/stijnwtf/envella.

I'd love feedback on two things in particular: does the CLI convention (export the schema, not the parsed env) feel reasonable, and which validators would you expect to be built in? And if it saves you a broken deploy, a ⭐ on the repo helps others find it.

Top comments (0)