.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):

  1. .env.local: gitignored; per-developer secrets.
  2. .env.<APP_ENV>: only when APP_ENV is set (e.g. .env.production).
  3. .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:

7 lines
# 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 \n inside 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:

  1. os.Environ. Apply never overwrites a set variable, so with DB_HOST=prod in the environment, DB_HOST is prod and DATABASE_URL="postgres://${DB_HOST}/app" is built on prod too.
  2. The value an earlier file in the precedence chain set. That is the value Load keeps, so a .env line DATABASE_URL="postgres://${DB_HOST}/app" follows the DB_HOST from .env.local, not the one .env itself shadowed.
  3. Keys parsed earlier in the same file.
  4. 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}:

2 lines
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") before NewApp and don't want the default probe to also fire.
  • You run integration tests that need a hermetic env.

Using the package directly

10 lines
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 .env value to override the real environment. Precedence is the other way: Apply only calls os.Setenv for keys not already present. An operator's DATABASE_URL=… always beats the dotfile, by design.
  • Putting GOFASTR_DOTENV=off in a .env file. 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 before NewApp runs.
  • Using bare $VAR expansion. Only the bracket form ${VAR} expands, and only inside double-quoted values. Bare $VAR, single-quoted strings, and unquoted values are left verbatim.
  • Appending # comment after 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:

3 lines
_ = 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.