10LOC
Bunintermediate

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 to Bun.env/process.env, which are otherwise typed as Record<string, string | undefined> with no autocomplete at all.
  • Once merged, Bun.env.DATABASE_URL is typed as string (not string | 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.env and process.env are the same underlying object — Bun.env (and import.meta.env) are just aliases, useful mainly for parity with browser-side tooling that expects import.meta.env.
  • Nothing here imports anything env-related; that absence is the entire point of the example.

Gotchas

  • --no-env-file (or env = false in bunfig.toml) disables Bun's automatic loading entirely — worth knowing about since it means "no .env support" isn't always a dotenv-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 .env value is still undefined at runtime even though the type says string — validate required variables explicitly rather than trusting the type alone.
  • --env-file=.env.custom overrides 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 .env loading, worth not confusing with it.
  • bunfig.toml's env key controls loading project-wide, as an alternative to passing --env-file/--no-env-file on every invocation.