.env files
framework.NewApp() reads .env files at the start of construction,
before any options run, so plugins, batteries, and option callbacks
that peek at os.Environ see the merged values.
Defaults
Files are probed in this order (earlier wins on conflict):
.env.local: gitignored; per-developer secrets..env.<APP_ENV>: only whenAPP_ENVis set (e.g..env.production)..env: committed defaults shared by all environments.
Missing files are silently skipped; a malformed file fails fast with the file name and line number.
Precedence
Existing os.Environ always wins over file values. Operators expect
their explicit DATABASE_URL=... to override anything in a dotfile,
and the loader honors that. The framework calls os.Setenv only for
keys not already present.
File format
A strict subset of the de facto dotenv spec:
# comments and blank lines are allowed FOO=bar # bareword value QUOTED="hello world" # double-quoted: \n \t \" \\ interpreted LITERAL='hello\nworld' # single-quoted: VERBATIM, no escapes export PORT=8080 # optional `export` prefix tolerated PATH_TPL="${HOME}/bin" # ${VAR} expansion (double-quoted only) ESCAPED="\${not expanded}" # \$ blocks expansion at that position
Hard rules:
- Keys must start with a letter or underscore; the rest is
[A-Za-z0-9_]. Anything else is a parse error. - Multi-line values are not supported. Use
\ninside a double-quoted value. - Inline comments after an UNQUOTED value are preserved as part of
the value. Write a quoted value if you need to embed
#. - The parser fails fast on malformed input rather than skipping the bad line (loud-by-default).
Variable expansion ${VAR}
Only inside double-quoted values. Bracket form only: bare $VAR
is left verbatim (less ambiguous, fewer footguns).
Lookup order for ${NAME} when Load (and so NewApp) reads the
files is the order Apply keeps values in, so a value built from a
name reads the same value the app runs with:
os.Environ.Applynever overwrites a set variable, so withDB_HOST=prodin the environment,DB_HOSTisprodandDATABASE_URL="postgres://${DB_HOST}/app"is built onprodtoo.- The value an earlier file in the precedence chain set. That is the
value
Loadkeeps, so a.envlineDATABASE_URL="postgres://${DB_HOST}/app"follows theDB_HOSTfrom.env.local, not the one.envitself shadowed. - Keys parsed earlier in the same file.
- Empty string (no error, matching shell behaviour).
dotenv.Parse on its own reads no environment: it expands against the
keys earlier in the same input only.
Before v0.96 the environment came third, after the files' own keys, so
a dotfile value built from ${DB_HOST} used the file's host while the
environment's DB_HOST was the one applied.
A value read from an earlier file or the environment counts as input
for the expansion budget once, so one ${PATH} in a short file is
fine; the budget still refuses a line that repeats it many times.
Each value is substituted as stored and never expanded a second time,
the way a shell substitutes a variable. A single-quoted literal or an
escaped \${...} stays literal when another line references it, and an
environment value holding ${x} goes in as the text ${x}:
PW='a${b}c' DSN="postgres://u:${PW}@h/db" # postgres://u:a${b}c@h/db
- No cycles.
A=${A}reads A before this line sets it (empty the first time). Since a looked-up value is never re-expanded, no chain can loop or compound through lookups. \${...}escape. Escapes and${...}are read in one pass, so\$is a literal$that starts no lookup, and\\${DIR}is a literal backslash followed by DIR's value.- Malformed
${...(no closing brace) is left verbatim. The parser never silently drops bytes.
Disabling auto-load
Set GOFASTR_DOTENV=off in the process environment (not in a
.env file, that's chicken-and-egg) before NewApp runs. Useful when:
- You ship custom paths via
dotenv.LoadAndApply("ops/secrets.env")beforeNewAppand don't want the default probe to also fire. - You run integration tests that need a hermetic env.
Using the package directly
import "github.com/DonaldMurillo/gofastr/core/dotenv" // Parse without touching os.Environ: vals, err := dotenv.Load(".env.local", ".env") // Apply (existing env wins): loaded, skipped := dotenv.Apply(vals) // One-shot: err := dotenv.LoadAndApply(".env")
dotenv.Expand(s, local, envFn) is exposed for callers that want to
run the same ${VAR} substitution against a custom lookup (e.g. when
loading a non-.env config and wanting consistent expansion rules).
Common mistakes
- Expecting a
.envvalue to override the real environment. Precedence is the other way:Applyonly callsos.Setenvfor keys not already present. An operator'sDATABASE_URL=…always beats the dotfile, by design. - Putting
GOFASTR_DOTENV=offin a.envfile. Chicken-and-egg: the loader would have to read the file to learn it shouldn't read the file. The kill switch only works as a process env var set beforeNewAppruns. - Using bare
$VARexpansion. Only the bracket form${VAR}expands, and only inside double-quoted values. Bare$VAR, single-quoted strings, and unquoted values are left verbatim. - Appending
# commentafter an unquoted value. Inline comments after an UNQUOTED value are preserved as part of the value. Quote the value if the comment (or a literal#) shouldn't be in it.
Reading a variable before NewApp
framework.NewApp loads the dotenv files itself, so most code never has to.
Code that must read a variable earlier, say a main.go opening its database
before constructing the app, has to load them first, and must load the same
set in the same order:
_ = dotenv.LoadAndApply(framework.DefaultDotEnvPaths()...) db, err := sql.Open(driver, getEnv("DATABASE_URL", "file:app.db"))
DefaultDotEnvPaths() returns .env.local, then .env.<APP_ENV> when
APP_ENV is set, then .env.
Use it rather than a hand-written list. dotenv.Apply never overwrites a
variable that is already set, so loading a shorter list first pins the
lower-precedence file's value, and NewApp's later load of
.env.<APP_ENV> can no longer win: a deployment using tiered env files
silently gets its base .env value.
The scaffold gofastr init emits does exactly this.