Environment variables are not application values yet.
Operating systems and deployment platforms hand an application a bag of optional strings. PORT might be absent, non-numeric, or outside the platform range. An email mode might
be misspelled. A database URL can be required in production but intentionally replaced by a test
dependency.
If each handler reads the bag itself, every caller invents a default and every log risks printing the wrong object. The process may start successfully and fail only when a route or worker reaches the unusual path.
Make startup the boundary: raw values enter once, validated settings leave once.
Read the scattered readsTypeScript · a fallback can hide a deployment error
// Raw environment values are strings and can be absent. Reading them in every handler spreads policy.
export function unsafePort(env: RawEnv): number {
return Number(env.PORT) || 3000;
}
export function unsafeEmailMode(env: RawEnv): string {
return env.EMAIL_MODE ?? 'console';
}
export function unsafeDatabaseUrl(env: RawEnv): string {
return env.DATABASE_URL ?? 'sqlite://./local.db';
} Number(env.PORT) || 3000 turns “web” into a working-looking local port. A missing
database becomes SQLite, and an unknown email mode passes through as a string no caller has
agreed to handle. The code is short; the policy is invisible.
Separate raw input, validation, runtime config, and public config.
These stages have different responsibilities. Keeping them separate prevents a secret from leaking into browser code or an unchecked string from reaching a client.
Raw input
Record<string, string | undefined>; untrusted, optional, string-shaped.
Parser
Names keys, normalizes whitespace, checks ranges, and collects issues.
Runtime value
Typed, read-only settings passed from the composition root to routes and workers.
Public projection
Only browser-safe fields; secrets become a presence flag or stay server-only.
| Decision | Owner | Evidence |
|---|---|---|
| Required in production? | Startup parser | Named issue and non-zero boot failure |
| Can tests replace it? | Composition root | Explicit test config or dependency override |
| Can it change live? | Runtime policy owner | Refresh, cache, fallback, and audit rules |
| Can the browser see it? | Public projection | Allow-list, never a secret-bearing environment dump |
Read the Go boundaryGo · map strings become a typed struct
var digitsOnly = regexp.MustCompile(`^[0-9]+$`)
func addIssue(issues *[]Issue, key, message string) {
*issues = append(*issues, Issue{Key: key, Message: message})
}
func parseConfig(env RawEnv) ConfigResult {
issues := []Issue{}
environment := env["NODE_ENV"]
if environment != "development" && environment != "test" && environment != "production" {
addIssue(&issues, "NODE_ENV", "must be development, test, or production")
environment = "development"
}
// Digits only: Atoi would also accept a sign such as "+8080".
port, err := strconv.Atoi(env["PORT"])
if !digitsOnly.MatchString(env["PORT"]) || err != nil || port < 1 || port > 65535 {
addIssue(&issues, "PORT", "must be an integer from 1 through 65535")
port = 0
}
emailMode := env["EMAIL_MODE"]
if emailMode != "console" && emailMode != "smtp" {
addIssue(&issues, "EMAIL_MODE", "must be console or smtp")
emailMode = "console"
}
databaseURL := strings.TrimSpace(env["DATABASE_URL"])
if databaseURL == "" {
addIssue(&issues, "DATABASE_URL", "is required")
}
publicOrigin := strings.TrimSpace(env["PUBLIC_ORIGIN"])
if publicOrigin == "" {
addIssue(&issues, "PUBLIC_ORIGIN", "is required")
}
emailAPIKey := strings.TrimSpace(env["EMAIL_API_KEY"])
if environment == "production" && emailMode == "smtp" && emailAPIKey == "" {
addIssue(&issues, "EMAIL_API_KEY", "is required when production email mode is smtp")
}
if len(issues) > 0 {
return ConfigResult{Issues: issues}
}
return ConfigResult{Config: &Config{Environment: environment, Port: port, DatabaseURL: databaseURL, PublicOrigin: publicOrigin, EmailMode: emailMode, EmailAPIKey: emailAPIKey}}
} Go’s package can keep parsing helpers private and return a Config only after validation.
The struct is not a permission to log every field: redaction remains an application policy.
See the difference between a late fallback and an early rejection.
Choose a configuration design and an environment state. Follow the value from raw strings to process boot. A production secret, bad port, or unknown mode should be visible before the first request.
Choose how the process discovers its settings.
Route handlers, jobs, and components each read raw strings.
No explicit redaction policy is guaranteed.
Watch for A fallback that keeps the process alive can turn a deployment mistake into a partial outage.
Choose a configuration policy with a lifetime.
Decide whether the setting is static startup input, an explicit test dependency, or a dynamic runtime policy.
Make the composition root the only place that knows the raw shape.
The image processor’s parser returns AppConfig: a number port, known
environment and email mode, required URLs, and a nullable key whose presence is enough for
diagnostics. Routes do not parse numbers. Workers do not choose a database fallback. Client
code receives an allow-listed public projection.
Test the boundary with missing, blank, malformed, out-of-range, and unknown values. Test both the accepted value and the rejected issue list. Also test that logs and public config never contain secrets.
Raw environment reads are strings, omissions, and scattered fallback policies.
// Raw environment values are strings and can be absent. Reading them in every handler spreads policy.
export function unsafePort(env: RawEnv): number {
return Number(env.PORT) || 3000;
}
export function unsafeEmailMode(env: RawEnv): string {
return env.EMAIL_MODE ?? 'console';
}
export function unsafeDatabaseUrl(env: RawEnv): string {
return env.DATABASE_URL ?? 'sqlite://./local.db';
} func unsafePort(env RawEnv) int {
port, _ := strconv.Atoi(env["PORT"])
if port == 0 {
return 3000
}
return port
}
func unsafeEmailMode(env RawEnv) string {
if env["EMAIL_MODE"] == "" {
return "console"
}
return env["EMAIL_MODE"]
}
func unsafeDatabaseURL(env RawEnv) string {
if env["DATABASE_URL"] == "" {
return "sqlite://./local.db"
}
return env["DATABASE_URL"]
} Shape and requirements
Types, defaults, ranges, known alternatives, and the complete issue list.
Lifetime and overrides
Startup creation, test replacement, dependency wiring, and shutdown ownership.
Live change
Refresh cadence, stale values, fallback behavior, rollout, and audit for dynamic settings.
A browser-safe projection is not the server environment.
Build frontends?Pass the smallest safe configuration projection to the page.
Where it already is in your components
Every import.meta.env value a component reads is configuration crossing into the
browser. Once it is in the bundle, anyone who opens the page can read it.
When you have to own it
When a page needs an origin or a mode from configuration, own the projection it receives. The textbook components receive a public origin and an application facade. The wild components read raw environment values, invent a port fallback, and put an email key in the DOM. A build-time public variable is still public once it reaches the browser.
The browser receives only a safe public projection and calls an application facade.
type PublicConfig = {
publicOrigin: string;
emailMode: 'console' | 'smtp';
};
type ImageApp = {
createPreviewUrl(assetId: string): string;
};
export function PreviewCard({
config,
app,
assetId
}: {
config: PublicConfig;
app: ImageApp;
assetId: string;
}) {
const preview = app.createPreviewUrl(assetId);
return (
<img
src={preview}
alt={`Preview from ${config.publicOrigin}`}
data-email-mode={config.emailMode}
/>
);
}
Configuration bugs are usually lifetime or ownership bugs.
Production fallback
Defaulting a required setting can make a broken deployment look healthy until traffic reaches it.
Secret-shaped logs
Do not serialize the complete config for debugging. Log keys, safe summaries, and presence flags.
Global test mutation
Changing process.env after startup creates order-dependent tests. Pass an explicit config or dependency.
Dynamic flags
A value that changes live needs an owner for refresh, cache, stale reads, and audit; startup parsing alone is not that policy.
Configuration is not a singleton by definitionOne read can still produce multiple deliberate instances
Reading configuration once per process is a useful default, not a requirement that every test or tenant share a global object. A test can parse a fixture. A worker can receive a process-owned value. A request-specific policy can be a separate dependency with an explicit lifetime.
Validate at startup when a setting defines whether the process can operate.
Keep a local default when it is intentional, safe, and documented for that environment. Reject startup when a missing or malformed value would produce false health, data loss, insecure behavior, or a partial outage. Keep dynamic configuration separate when its lifetime is shorter than the process.
Write the boundary in one place: raw keys in, typed value or named issues out, redacted diagnostics, and an explicit override path for tests.
Keep this questionUse it before adding another process.env read.
Is this a startup setting, a public projection, or a live policy—and who owns its lifetime?
Configuration is the first contract your process meets.
Read the outside world once; make the inside world speak in trustworthy values.
Connections to follow nextRelated lessons
- Validation at the edge checks incoming request data.
- Parse, don’t validate keeps a successful parse meaningful.
- Factory can assemble an object only after its required configuration is valid.
- Module boundaries keeps raw environment access at the application edge instead of spreading it through features.
- Why
- A missing or malformed setting should stop the process before traffic arrives, not fail one route later.
- What
- One startup parser turns raw environment strings into a typed config value or named issues with redacted diagnostics.
- Constraint
- Secrets never appear in errors, logs, or the browser projection.
- Fallback
- Only intentional, documented defaults; anything else rejects startup with the key name.
- Reconsider when
- A setting must change while the process runs, which makes it a live policy with its own owner.