You know this loop:
- Deploy.
- App crashes:
DATABASE_URL is not defined. - Add it, redeploy.
- App crashes:
STRIPE_KEY is not defined. - Add it, redeploy.
- App boots, but
PORTis"3000 "with a trailing space and something somewhere quietly turns it intoNaN.
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
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!;
Every line has a problem:
-
Number("abc")isNaN, andNaN || 3000silently gives you3000. A typo in your config becomes a default you never asked for. -
DEBUG=1orDEBUG=yesisfalse. - 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 }),
});
And the types follow from the schema:
env.PORT; // number
env.DEBUG; // boolean
env.NODE_ENV; // "development" | "production" | "test"
env.SENTRY_DSN; // string | undefined
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
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 }),
Invalid environment variables:
x SLACK_WEBHOOK_URL **** is invalid: expected a URL
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);
},
});
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=
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 }),
};
Then:
npx envella example env.schema.ts # writes .env.example
npx envella example env.schema.ts --check # fails if it's out of date
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
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
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 });
// ...
},
};
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.examplegenerator 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"totrue, 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
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)