Bun's automatic .env loading vs the dotenv package
Published February 26, 2027
declare module "bun" {
interface Env {
DATABASE_URL: string;
DEBUG?: string;
}
}
// No `import "dotenv/config"` — Bun already parsed .env, .env.local, etc. before this ran.
const config = {
databaseUrl: Bun.env.DATABASE_URL,
debug: Bun.env.DEBUG === "true",
port: Number(process.env.PORT ?? 3000),
};
console.log(config);What
Bun reads .env, .env.{development,production,test} (based on NODE_ENV), and .env.local
automatically before your entrypoint runs, in that increasing order of precedence — no import,
no setup call, just process.env.WHATEVER already populated.
Why it matters
In Node, reading .env files requires the dotenv package and a call to import "dotenv/config" (or require("dotenv").config()) before anything that reads process.env —
easy to forget, and easy to get the import order wrong so a variable is undefined the one time
it's read before dotenv finishes loading. Bun folds this into the runtime itself: files are
parsed before your code starts executing at all, so there's no load-order bug to have in the
first place, and one fewer dependency in a package.json that's otherwise dependency-free. Bun
even supports the same $VAR expansion syntax dotenv-expand adds on top of dotenv, natively.
How it works
declare module "bun" { interface Env { ... } }is TypeScript's interface-merging escape hatch — it adds typed keys toBun.env/process.env, which are otherwise typed asRecord<string, string | undefined>with no autocomplete at all.- Once merged,
Bun.env.DATABASE_URLis typed asstring(notstring | undefined) because the interface says so — that's a compile-time promise this variable exists, not a runtime guarantee, so it's still worth validating it actually does at startup. Bun.envandprocess.envare the same underlying object —Bun.env(andimport.meta.env) are just aliases, useful mainly for parity with browser-side tooling that expectsimport.meta.env.- Nothing here imports anything env-related; that absence is the entire point of the example.
Gotchas
--no-env-file(orenv = falseinbunfig.toml) disables Bun's automatic loading entirely — worth knowing about since it means "no.envsupport" isn't always adotenv-vs-Bun question, it can also just be disabled deliberately for a production/CI environment.- The interface-merging trick makes TypeScript believe a variable exists; it does nothing at
runtime. A missing
.envvalue is stillundefinedat runtime even though the type saysstring— validate required variables explicitly rather than trusting the type alone. --env-file=.env.customoverrides which files load, replacing (not adding to) the default set — useful for a one-off script that shouldn't pick up the project's usual.env.local.
Related
Bun.build({ env: "PUBLIC_*" })inlines matching environment variables into a browser bundle at build time — a different mechanism from runtime.envloading, worth not confusing with it.bunfig.toml'senvkey controls loading project-wide, as an alternative to passing--env-file/--no-env-fileon every invocation.