Skip to content

dotenv: one process flag makes Bun.build, macros and HTML routes follow --no-env-file - #45445

Open
robobun wants to merge 4 commits into
mainfrom
robobun/333f58fd/env-opt-out-covers-builds
Open

robobun wants to merge 4 commits into
mainfrom
robobun/333f58fd/env-opt-out-covers-builds

Conversation

@robobun

@robobun robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Under --no-env-file, bunfig env = false or --env-file, a process still reads default .env files: in a macro, in Bun.build() with env: "inline" or a prefix, in an HTML route. The values reach bundles, child processes and Workers.
  • Cause: these paths run Transpiler::configure_defines with default options on the process's env loader (js_bundle_completion_task.rs:1250).

Fix

  • run_env_loader records the switch in one process flag in bun_dotenv. Loader::load, the only opener of default .env files, checks it.
  • Under a switch, bun build --app's second loader is a copy of the command's: page macros get --env-file, not .env.
  • A bun test --parallel worker always skips env files at start. That skip is now its own option, and the coordinator forwards the switch.
  • Verified: test/cli/run/no-envfile.test.ts (21 new tests, 15 fail without the fix), production.test.ts, js-plugins.test.ts, bundler_compile_autoload.test.ts. Self-reviewed: 32 concerns raised, 18 addressed, 12 in Notes, 2 rejected (both keep a boot-time close).

Background

  • The env loader (src/dotenv/env_loader.rs) is the map behind process.env, child processes and Workers.
  • Considered the flag in each option bag, or a bit per loader. Both fail open for a forgotten one, like bun build --app's second loader.

Downsides

  • Behaviour change: under --env-file=X, bun as node, or an executable compiled with dotenv autoload off, an in-process build no longer inlines a key that only .env has.
  • Not fixed: the no-switch read of default files on the bundle thread (a race). Not reached: a bun started by a package script, an executable compiled with autoload on.
  • Release binary text: +1280 B.
Notes

No change with no switch. Each of these gives the same output on main and on this branch: process.chdir(pkg) then Bun.build({ env: "inline" }) (inlines pkg/.env), a Worker after a key was appended to .env (sees the key), NODE_ENV=production set only in .env (a build also inlines .env.production), and NODE_ENV=development bun test (a build inlines .env.local). In a bun test --parallel worker: a build after process.chdir(pkg) (inlines pkg/.env), and a Worker after a key was appended to .env (does not see the key). The counts of default-file opens with no switch are also the same in every row of the table below.

Default .env opens after startup (inotify IN_OPEN, release builds, linux x64)

Entry point, under --no-env-file main this branch
Bun.build({ env: "inline" }) 3 0
a build macro, env: "disable" 3 0
Bun.Transpiler#transform with a macro 3 0
HTML route, development: false 3 0
HTML route, development: true 3 0
page macro of bun build --app 2 0
a build in each of 2 bun test --parallel workers 2 0

With no switch: 3 at boot and 0 after in the first five rows, 9 for 3 Workers, 3 for bun build --app, 2 for the 2 bun test --parallel workers. Same on both builds.

The switches in the tests: bun --no-env-file, bun run --no-env-file, BUN_OPTIONS, bunfig env = false and [env] file = false, bun invoked as node, bun test --no-env-file, --env-file (only that file), an executable with autoloadDotenv: false, the VM of a bun lint plugin, and bun test --parallel with --no-env-file and with --env-file. Each of the first eight runs against seven places: env: "inline", env: "PREFIX_*", a build macro, a transform macro, a child process, a Worker, and the first read of process.env. The HTML-route rows have a no-switch control, so a [serve.static] env key that does not apply cannot pass them.

--env-file. A file passed with --env-file reaches Bun.build() and its macros, before and after this change: its values are in the loader's map. What changes is that the default files no longer come with it. Under bun --env-file=custom.env build.ts, main inlines a key that only .env has (console.log("key-from-custom-env", "only-from-dot-env")). This branch gives console.log("key-from-custom-env", process.env.ONLY), which is what bun build --env-file=custom.env --env=inline gives on both.

Native readers. The map is also what native code reads. With HTTP_PROXY set only in .env, a fetch under bun --no-env-file goes to the origin before Bun.build({ env: "inline" }) and to the proxy after it on main. On this branch it goes to the origin both times. The child-process place in the tests reads the same map, so there is no separate test for fetch.

bun build (the CLI) boots no VM. Its macro VM gets the options of the command, as before. What that macro starts did not: a Bun.Transpiler#transform with a macro of its own read .env under --no-env-file. The process flag covers it.

bun build --app --env-file=X changes for page macros: before they got the default .env and not X, now X and not .env, the same as the configuration and bun build --env-file. The copy is made with Loader::clone_for_worker, which does not read the file again, so an --env-file that is a pipe still works. The app now has every value of the file: a BUN_ENV=development in it gives the development bundle, as BUN_ENV in the process environment does on main.

bun test --parallel. A worker gets the environment of the coordinator, which read the env files. So a worker reads none when it starts (#40837). It said so with disable_default_env_files = true, with or without a switch. The first revision of this PR took that for a switch and set the flag in every worker: a test that did process.chdir(pkg) and then Bun.build({ env: "inline" }) got pkg/.env under bun test and not under bun test --parallel. A review comment found it. Now the skip of a worker is env_files_loaded_by_parent, an option that only configure_defines reads, and the coordinator adds --no-env-file to the arguments of a worker when its own options exclude the default files. Four bun test rows in no-envfile.test.ts cover both directions.

The race that is not fixed here. With .env holding only NODE_ENV=production and a .env.production of 20,000 keys, Bun.build({ env: "inline" }) loads the second file on the bundle thread while the JS thread spawns children. On main f2405c3 a child process got a part of the 20,000 keys in 5 of 6 runs (16380, 19916, 8189, 12285, and in one run 2045, 7680 and 18763). This branch does the same: it does not touch that path. Two fixes exist. #37784 gives each build a copy of the loader. The other is to close the default files of a VM's loader when it has booted, which stops the read. That one also ends each behaviour in "No change with no switch" above, so it is a change of default and is not in this PR.

Self-review. The review ran before this PR was opened. It asked for a smaller change than the version it read, which closed the default files of every VM's loader after boot. Addressed: the close is gone, the flag is per process and not per loader, bun build --app --env-file gives page macros the file, the docs sentence says only what is tested, the --app rows are in production.test.ts, the HTML rows have controls, the lint VM has a test, and a skip of the cwd listing copy is gone (about 3 microseconds, not part of a bug fix). Rejected: two concerns that asked to keep the boot-time close, one for the race above and one because main already has that rule for the security scanner, the script hop and the debugger VM. Both are arguments for the separate change, not for this one. Listed and not changed: the items in "Not covered" and "Also noted" below, and the race above. A second check of the final diff ran both release builds against each other from four angles. It raised 5 more points. Fixed: the new tests did not clear API_KEY from the environment they pass on. Listed: the two compiled-executable gaps and the --parallel cases in "Not covered", and the switches that no user types (bun as node, an executable compiled with autoload off, the VM of a bun lint plugin), which the Downsides name. The bun build --app rows need --timeout on a debug build, as the older React tests of that file do. CI passes one.

Not covered

  • bun --no-env-file run build, where the script is bun build.ts: the inner bun is a new process with no switch. bunfig env = false does reach it.
  • An executable compiled with dotenv autoload on reads .env under BUN_OPTIONS=--no-env-file, under --compile-exec-argv=--no-env-file and under a bunfig env = false in the working directory. One compiled with autoload off does not read an explicit --env-file. The flags of the executable overwrite the options (src/bun.js.rs:16). Same on main.
  • A bun test --parallel worker also takes the switch from its own bunfig and BUN_OPTIONS. That differs from the coordinator in two cases. --config=<file> is not passed to the workers, so they load ./bunfig.toml (on main too: its [test] preload runs in each worker). A default .env file that sets BUN_OPTIONS reaches the workers and not the coordinator. In both, an env = false or --no-env-file that only the workers see now stops their builds from reading a default .env.
  • Bun.build inlines the loader's map, not the live process.env (Bun.build: inline the live process.env for env: "inline" and env: "PREFIX_*" #41927).

Also noted

  • No issue reports this. The bun init React templates set [serve.static] env = "BUN_PUBLIC_*", so a server made from a template is on the HTML-route path with no Bun.build call of its own.
  • --no-macros is another process switch that an in-process build does not follow: bun --no-macros build.ts runs the macros of a Bun.build() call, where bun build --no-macros gives error: Macros are disabled. That is a separate switch and a separate change.
  • Considered: move .env discovery out of configure_defines, into the VM boot and bun build. The macro VM of bun build and the transpilers of bun build --app discover .env after boot today, so that shape changes them or needs two more discovery sites. Not done here.

Measured costs

Release builds of main f0c6cbc and of this branch on top of it, linux x64, from one build directory.

  • size: text 106486320 -> 106487600 (+1280 B), data and bss unchanged. Loader gets no field. The flag is one static AtomicBool. TransformOptions and options::Env get one bool each.
  • Instructions (objdump, a static count): Loader::load 80 -> 82, run_env_loader 519 -> 525, configure_defines 1249 -> 1252, run_as_coordinator (the worker arguments are inlined in it) 3114 -> 3131. perf is not installed here, so there is no dynamic count.
  • Per call with no switch: one atomic load in Loader::load, one two-field test in run_env_loader, one more bool test in configure_defines. No allocation, system call or file read is added.

Inside Transpiler::run_env_loader, for one Bun.build({ env: "inline" }) in a directory of 23 entries (gdb breakpoints on the mi_* entry points, gdb catch syscall). These counts are from main f2405c3 and the first revision of this PR on top of it. run_env_loader and Loader::load have the same sizes on the current pair:

main this branch
no switch, boot: allocations / system calls 56 / 6 56 / 6
no switch, build thread 24 / 0 24 / 0
--no-env-file, boot 47 / 0 47 / 0
--no-env-file, build thread 35 / 6 24 / 0

Other suites run on the release build of this branch: test/cli/run/env.test.ts, test/cli/test/bun-test.test.ts, test/bundler/{bundler_env,bun-build-api,bundler_edgecase,bundler_html}.test.ts, test/bundler/transpiler/macro-test.test.ts, test/bake/dev/{bundle,html,esm,production}.test.ts, test/js/bun/http/bun-serve-html{,-entry}.test.ts, test/js/web/workers/worker.test.ts, test/js/node/worker_threads/worker_threads.test.ts, test/js/node/process/process.test.js, test/cli/lint/{config-files,js-plugins}.test.ts, test/cli/install/bun-install-env-file.test.ts, test/cli/run/{run-autoinstall,filter-workspace}.test.ts, test/regression/issue/test_env_loader_threading.test.ts. All pass. test/cli/test/parallel.test.ts passes 45 of 45 on the debug build. On a release build --parallel --dots prints one status character per test fails in about half of the runs, on main as well (3 of 6 on main, 4 of 6 here).


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/lint/js-plugins.test.ts, test/bake/dev/production.test.ts

… routes and Workers

A Bun.build(), a macro VM, a Bun.serve() HTML route, the dev server and a
Bun.Transpiler with a macro run Transpiler::configure_defines with default
options on the env loader of the process. So they read the default .env
files when the process was told not to: --no-env-file, bunfig env = false,
--env-file, bun as node, an executable without dotenv autoload, and the VM
of a bun lint plugin.

The switch is a fact about the process. run_env_loader now records it in
bun_dotenv when the options of a transpiler exclude the default files, and
Loader::load reads no default file after that, for any loader.

bun build --app gives its transpilers a second loader that nothing fills
when the default files are excluded. It now starts as a copy of the
command's loader, so a page macro gets --env-file and not .env.

Nothing changes when no switch is set.
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 0545cd79-d0b5-47fc-8866-9c740d838ee4
📥 Commits

Reviewing files that changed from the base of the PR and between cd69d75 and f62e762.

📒 Files selected for processing: 1
  • src/bundler/options.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

Default .env loading now follows process and build settings. The changes propagate that policy to production builds and parallel test workers. Tests cover runtime, build, macro, Worker, and HTML-route behavior, including explicit env files.

Changes

Default Environment File Loading

Layer / File(s) Summary
Define and enforce the environment-file policy
src/options_types/schema.rs, src/bundler/options.rs, src/dotenv/*, src/bundler/transpiler.rs, test/cli/run/no-envfile.test.ts, test/bundler/bundler_compile_autoload.test.ts, docs/runtime/environment-variables.mdx
Environment options record whether a parent loaded environment files. The dotenv loader can exclude default .env files process-wide. Tests cover default loading and opt-outs across runtime and build contexts, including explicit env files. The docs state that env = false applies process-wide.
Propagate environment-file settings to builds and workers
src/runtime/bake/production/mod.rs, src/runtime/cli/test/..., test/bake/dev/production.test.ts, test/cli/lint/js-plugins.test.ts
Production builds use the transpiler’s environment loader when default files are disabled. Parallel test workers receive env-file arguments based on the coordinator’s setting, and worker mode records parent-loaded values. Tests cover production macros and lint-plugin Workers and builds.

Suggested reviewers: jarred-sumner, alii

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to f62e7

Parent-loaded workers avoid a duplicate startup read, and later builds retain their intended default-file behavior. No actionable merge risk remains in the reviewed change.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly summarizes the main change: a process-level dotenv setting makes Bun.build, macros, and HTML routes follow --no-env-file.
Description check Passed The description explains the problem, fix, scope, behavior changes, limitations, and verification results. It does not use the template headings exactly, but it provides the required content in equiva…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description explains the problem, fix, scope, behavior changes, limitations, and verification results. It does not use the template headings exactly, but it provides the required content in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:37 AM PT - Oct 18th, 2026

✅ @robobun, your commit f62e76253bf80510d92baaf9d48d39de3bb8380d passed in Build #128330! 🎉


🧪   To try this PR locally:

bunx bun-pr 45445

That installs a local version of the PR into your bun-45445 executable, so you can run:

bun-45445 --bun

@robobun

robobun commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How it was reproduced (main f2405c3, release build, linux x64):

mkdir /tmp/r && cd /tmp/r
printf 'API_KEY=key-from-dot-env\n' > .env
echo 'console.log(process.env.API_KEY);' > entry.ts
cat > build.ts <<'END'
const built = await Bun.build({ entrypoints: ["./entry.ts"], env: "inline" });
console.log(await built.outputs[0].text());
END
bun --no-env-file build.ts
bun build --no-env-file --env=inline entry.ts
bun --no-env-file build.ts bun build --no-env-file --env=inline entry.ts
main console.log("key-from-dot-env"); console.log(process.env.API_KEY);
this branch console.log(process.env.API_KEY); console.log(process.env.API_KEY);

The same holds for bun run --no-env-file, BUN_OPTIONS=--no-env-file, bunfig env = false, bunfig [env] file = false and --env-file. The tests in test/cli/run/no-envfile.test.ts run each of them.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, I also checked the changed DOTENV_SINGLETON seeding in src/runtime/bake/production.rs — clone_for_worker copies the already-loaded map rather than re-reading files, so an --env-file pipe still works and the no-switch path is unchanged — and the --env-file=X-only case in src/dotenv/env_loader.rs, where dropping .env matches what bun build --env-file=X already does.

Extended reasoning...

The change adds a sticky process-global AtomicBool in src/dotenv/env_loader.rs that Loader::load consults, set from Transpiler::run_env_loader whenever the current options do not load default files, plus a reseeded loader for bun build --app and new tests across four files. It touches no auth, crypto or injection surface, but it does alter which env values reach bundles, Workers and child processes. The inline finding on --test-worker regressing bun test --parallel is a real behavior change, and the process-global flag is a design choice a maintainer should weigh, so this is not an approve.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/bundler/transpiler.rs
Comment thread test/bake/dev/production.test.ts Outdated
A `bun test --parallel` worker reads no env file when it starts, because
the coordinator read them. It said so with `disable_default_env_files`,
which `run_env_loader` now takes for the switch of the process. So with
no switch, a `Bun.build()` in a worker stopped reading a default `.env`.

The skip is now `env_files_loaded_by_parent`, which only
`configure_defines` reads. The coordinator forwards `--no-env-file` to
the workers when its own options exclude the default files.

Tests: `bun test` and `bun test --parallel` rows with and without a
switch. The fixtures no longer depend on `API_KEY` being unset in the
environment of the test run, and the `bun build --app` rows have no
timeout of their own.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants