A production service is configured by its environment, not by code. The same compiled binary must run unchanged in development, staging, and production, with only the surrounding environment variables differing. This is the heart of the Twelve-Factor App methodology, and Rust’s type system lets you turn loosely-typed environment strings into a validated, typed configuration object at startup, failing loudly the moment something is missing or malformed.
The 12-factor principle is simple: store configuration in the environment, keep it strictly separate from code, and never commit secrets to your repository. In Node.js you reach for process.env (often paired with dotenv and a schema validator like zod); in Rust you read std::env::var, layer in dotenvy for local development, and parse everything into a typed struct. The big win in Rust is that validation happens once, at startup: if DATABASE_URL is missing, the process refuses to boot instead of crashing on the first request three hours later.
Note: This page focuses specifically on the environment as a config source: the 12-factor model, loading a .env file in development with dotenvy, and validating required variables when the program starts. For richer layered configuration (file + environment + defaults merged into typed settings via the config/figment crates) see configuration.md, and for failing-but-staying-alive readiness signals see health-checks.md.
In a Node.js service, environment variables arrive as process.env, where every value is either a string or undefined. The idiomatic modern approach loads a .env file in development and validates the result against a schema so the app fails fast.
config.ts
1
import"dotenv/config";// loads .env into process.env (dev convenience)
// config.PORT is `number`, config.LOG_LEVEL is the union, etc.
Running this against an environment where DATABASE_URL is unset and PORT is out of range prints real zod errors and exits:
1
Invalid environment configuration:
2
- DATABASE_URL: Invalid input: expected string, received undefined
3
- PORT: Too big: expected number to be <=65535
This pattern works, but it relies on discipline: nothing in the language forces you to validate. Plenty of Node services read process.env.PORT directly, get a string, and quietly pass "3000" where a number was expected — or worse, Number(process.env.PORT) silently becomes 0 or NaN when the variable is empty or malformed.
Rust reads the same environment with std::env::var, which returns a Result<String, VarError> — the type system makes the “this might be missing” case impossible to ignore. We load a .env file in development with the dotenvy crate, then parse everything into a typed Config struct, returning a structured error if anything is wrong.
Add the dependencies in a fresh project (cargo new selects the latest stable toolchain — currently Rust 1.96.0 on the 2024 edition):
Err(env::VarError::NotUnicode(_))=>println!("set, but not valid UTF-8"),
9
}
10
}
The return type is Result<String, VarError>. You cannot accidentally use the value as if it were always present — the compiler forces you to handle the Err arm. VarError even distinguishes “not present” from “present but not valid Unicode”, a case Node hides from you entirely.
In development you don’t want to type DATABASE_URL=... PORT=... cargo run every time. The dotenvy crate reads a .env file and injects its keys into the process environment, exactly like Node’s dotenv package.
Note: Use dotenvy, not the older dotenv crate. The original dotenv is unmaintained; dotenvy is its actively-maintained fork and the community standard. The crate is named dotenvy and so is the import path.
Given a .env file in your project root:
1
DATABASE_URL=postgres://localhost/myapp
2
PORT=8080
3
# comments and blank lines are allowed
4
LOG_LEVEL=debug
You load it once, as early as possible in main:
1
fnmain(){
2
// Load `.env` into the process environment. In production there is usually
3
// no `.env` file — the platform injects real variables — so a missing file
4
// is not an error.
5
matchdotenvy::dotenv(){
6
Ok(path)=>println!("loaded env from {}",path.display()),
7
Err(e)ife.not_found()=>println!("no .env file, using real environment"),
dotenvy::dotenv() never overrides variables that are already set. A real environment variable always wins over a .env entry. This is correct: your container’s injected DATABASE_URL should beat whatever is in a stray .env file.
A missing .env file returns Err, and e.not_found() lets you treat that as benign. This is the key to making the same code path work in development (file present) and production (no file). Only a genuine I/O or parse error should abort startup.
Tip: Commit a .env.example with placeholder values to document required variables, and add .env to your .gitignore. Never commit real secrets. This mirrors the convention every 12-factor Node project already uses.
Environment values are always strings. The parsed helper above uses the FromStr trait — the same machinery behind "3000".parse::<u16>() — to turn "3000" into a real u16, and to reject"70000" (out of u16 range) or "abc" (not a number) with a descriptive error instead of a silent NaN. Compare this to the JavaScript footgun where Number(process.env.PORT) yields 0 for an empty string and NaN for garbage, both of which sail past the type checker.
Config::from_env() is called once in main, before the server binds a port or opens a connection pool. If any required variable is missing or malformed, the process prints the error and calls std::process::exit(1). The binary either starts fully configured or not at all — there is no half-configured intermediate state to debug at 3 a.m.
leak unless you redact — but you can enforce it with a newtype
The deepest difference is when you find out something is wrong. A Node service with an unvalidated process.env.STRIPE_KEY boots happily and fails on the first payment. The Rust pattern collapses that gap: a missing key is a startup failure, surfaced before any traffic arrives.
Note: Unlike TypeScript, Rust does not validate the environment for you just because you declared a typed struct. The struct’s fields are typed, but the bridge from String env values into those fields is code you write — from_env. The payoff is that once that bridge runs successfully, the rest of your program manipulates real u16s and validated Strings, never raw Option<String>.
On the current stable toolchain (2024 edition), std::env::set_var and remove_var are unsafe functions, because changing the environment is not thread-safe — another thread could be reading it. Writing the obvious code fails to compile:
1
fnmain(){
2
std::env::set_var("KEY","value");// does not compile (error[E0133])
3
}
The real compiler error is:
1
error[E0133]: call to unsafe function `set_var` is unsafe and requires unsafe block
2
--> src/main.rs:2:5
3
|
4
2 | std::env::set_var("KEY", "value");
5
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
6
|
7
= note: consult the function's documentation for information on how to avoid undefined behavior
The lesson: don’t mutate the environment at runtime. Read it once into your Config at startup and pass that struct around. If you genuinely must set a variable (for example in a test, before any threads spawn), wrap it in an unsafe block — but treat that as a smell, not a habit. dotenvy::dotenv() does the setting for you, before your threads exist, which is why it is safe to use.
It is tempting to write env::var("DATABASE_URL").unwrap(). This compiles, but at runtime a missing variable produces a panic with a stack trace instead of a readable message:
1
thread 'main' panicked at src/main.rs:3:42:
2
called `Result::unwrap()` on an `Err` value: NotPresent
NotPresent tells you nothing about which variable was missing — you’d have to read the line number. Use a helper like required() that names the key, or expect("DATABASE_URL must be set") at the very least. For configuration, a structured error and process::exit(1) is far better than a panic.
Pitfall 3: Assuming .env overrides the real environment
Developers sometimes set PORT=8080 in .env, then export PORT=3000 in their shell, and are surprised the app uses 3000. That is correct and intentional: dotenvy::dotenv() only fills in variables that are not already set. If you truly want the file to win (rarely a good idea), dotenvy offers dotenv_override() — but in production the platform-injected variable should always take precedence.
Pitfall 4: Logging the whole config, secrets included
println!("{config:?}") on a struct containing a jwt_secret: String will happily print your secret to stdout, where it lands in your log aggregator forever. Derive Debug only on configs without secrets, or wrap secret fields in a newtype with a redacting Debug impl (shown in the real-world example below). This is the same hazard as console.log(config) in Node, but Rust gives you a clean way to make leaks impossible by construction.
Read the environment exactly once, at startup, into a typed Config. Pass that struct (often inside an Arc) to the rest of the app; never sprinkle env::var calls throughout your code.
Fail fast and loudly. A missing required variable should print a clear message and exit non-zero, so orchestrators restart and surface the problem immediately.
Distinguish required from optional. Required variables have no default and abort on absence; optional ones get a sensible default via unwrap_or_else or a default in your parse helper.
Use dotenvy for development only. Load it at the top of main, tolerate a missing file with e.not_found(), and let the platform inject real variables in production.
Validate values, not just presence. A PORT of "70000" is “present” but invalid; parse into u16 so the range check is free.
Keep secrets out of Debug output with a redacting newtype.
Prefer one prefix for your app’s variables (e.g. APP_DATABASE_URL) so they don’t collide with system or library variables — the envy crate (below) makes this ergonomic.
Document every variable in .env.example and .gitignore the real .env.
Tip: For deserializing the whole environment into a struct in one call — much like zod’s safeParse(process.env) — the envy crate maps environment variables onto a serde-derived struct. Add serde = { version = "1", features = ["derive"] } and envy = "0.4", then:
1
useserde::Deserialize;
2
3
#[derive(Debug,Deserialize)]
4
structConfig{
5
database_url:String,
6
#[serde(default ="default_port")]
7
port:u16,
8
#[serde(default ="default_log_level")]
9
log_level:String,
10
}
11
12
fndefault_port()->u16{8080}
13
fndefault_log_level()->String{"info".into()}
14
15
fnmain(){
16
matchenvy::prefixed("APP_").from_env::<Config>(){
17
Ok(config)=>println!("{config:?}"),
18
Err(e)=>{
19
eprintln!("configuration error: {e}");
20
std::process::exit(1);
21
}
22
}
23
}
With APP_DATABASE_URL and APP_PORT set, this prints
Config { database_url: "postgres://localhost/app", port: 3000, log_level: "info" }.
With APP_DATABASE_URL unset, envy returns the error
missing value for field database_url and the process exits 1. This is the closest analog to the zod approach — concise, but it stops at the first error. The next example shows how to collect all of them.
A production service should do three things on boot: load .env in dev, validate every required variable (reporting all problems at once, not one at a time), and keep secrets out of its logs. Here is a self-contained version that does all three. It uses thiserror for the aggregate error and a Secret newtype whose Debug impl redacts the value.
self.errors.push(format!(" - {key} is invalid: {e}"));
69
None
70
}
71
},
72
Err(_)=>Some(default),
73
}
74
}
75
}
76
77
implConfig{
78
fnfrom_env()->Result<Self,ConfigErrors>{
79
letmutb=Builder::new();
80
81
letapp_env=matchenv::var("APP_ENV").as_deref(){
82
Ok("production")=>AppEnv::Production,
83
Ok("staging")=>AppEnv::Staging,
84
Ok("development")|Err(_)=>AppEnv::Development,
85
Ok(other)=>{
86
b.errors.push(format!(
87
" - APP_ENV `{other}` is not one of development|staging|production"
88
));
89
AppEnv::Development
90
}
91
};
92
93
letdatabase_url=b.required("DATABASE_URL");
94
letjwt_secret=b.required("JWT_SECRET");
95
letport=b.parsed::<u16>("PORT",8080);
96
97
if!b.errors.is_empty(){
98
returnErr(ConfigErrors(b.errors));
99
}
100
101
Ok(Config{
102
app_env,
103
database_url:Secret(database_url.unwrap()),
104
jwt_secret:Secret(jwt_secret.unwrap()),
105
port:port.unwrap(),
106
})
107
}
108
}
109
110
fnmain(){
111
// Development convenience: load `.env` if present, ignore if absent.
112
ifletErr(e)=dotenvy::dotenv(){
113
if!e.not_found(){
114
eprintln!("failed to read .env: {e}");
115
std::process::exit(1);
116
}
117
}
118
119
letconfig=Config::from_env().unwrap_or_else(|e|{
120
eprintln!("{e}");
121
eprintln!("\nrefusing to start with invalid configuration");
122
std::process::exit(1);
123
});
124
125
// Safe to log: the `Secret` Debug impl redacts the values.
126
println!("starting service with {config:?}");
127
128
// Real code would hand `config.database_url.expose()` to the connection
129
// pool and `config.jwt_secret.expose()` to the auth layer here.
130
let_=config.database_url.expose();
131
}
A fully-configured run logs the config with secrets hidden:
1
starting service with Config { app_env: Production, database_url: "***REDACTED***", jwt_secret: "***REDACTED***", port: 8443 }
A misconfigured run — DATABASE_URL unset, JWT_SECRET empty, APP_ENV=prod (a typo), PORT=70000 (out of u16 range) — reports all four problems at once and exits non-zero:
1
invalid configuration:
2
- APP_ENV `prod` is not one of development|staging|production
3
- DATABASE_URL is required and must be non-empty
4
- JWT_SECRET is required and must be non-empty
5
- PORT is invalid: number too large to fit in target type
6
7
refusing to start with invalid configuration
Notice number too large to fit in target type — that is the real <u16 as FromStr>::Err message, not a fabricated one. A developer fixing the deployment sees every issue in one pass instead of redeploying four times.
Note: Collecting all errors (the Builder pattern here) versus stopping at the first (? propagation, or envy) is a genuine design choice. Aggregating is friendlier for human-facing startup config, where you want one trip to fix everything; short-circuiting is fine when failures are rare or independent.
Objective: Practice reading a required environment variable and reporting a clear, named error instead of panicking.
Instructions: Write a function fn api_key() -> Result<String, String> that reads the API_KEY environment variable. On success return the value; if it is missing, return Err("API_KEY is required".to_string()). In main, call it and either print Using key: <key> or print the error and exit with code 1. Verify that running without API_KEY set exits non-zero and prints the message.
Solution
1
usestd::env;
2
3
fnapi_key()->Result<String,String>{
4
env::var("API_KEY").map_err(|_|"API_KEY is required".to_string())
5
}
6
7
fnmain(){
8
matchapi_key(){
9
Ok(key)=>println!("Using key: {key}"),
10
Err(e)=>{
11
eprintln!("configuration error: {e}");
12
std::process::exit(1);
13
}
14
}
15
}
Running with API_KEY=abc123 cargo run prints Using key: abc123. Running with API_KEY unset prints configuration error: API_KEY is required and exits with status 1.
Exercise 2: An optional, validated numeric variable
Objective: Provide a default for an optional variable while still rejecting malformed values.
Instructions: Write fn max_connections() -> Result<u32, String> that reads MAX_CONNECTIONS. If the variable is unset, return Ok(10). If it is set but does not parse as a u32, return an Err containing both the key name and the parse error. Test three cases: unset (returns 10), MAX_CONNECTIONS=50 (returns 50), and MAX_CONNECTIONS=lots (returns an error).
Solution
1
usestd::env;
2
3
fnmax_connections()->Result<u32,String>{
4
matchenv::var("MAX_CONNECTIONS"){
5
Ok(raw)=>raw
6
.parse::<u32>()
7
.map_err(|e|format!("MAX_CONNECTIONS is invalid: {e}")),
8
Err(_)=>Ok(10),
9
}
10
}
11
12
fnmain(){
13
matchmax_connections(){
14
Ok(n)=>println!("max connections: {n}"),
15
Err(e)=>{
16
eprintln!("configuration error: {e}");
17
std::process::exit(1);
18
}
19
}
20
}
Unset: prints max connections: 10.
MAX_CONNECTIONS=50: prints max connections: 50.
MAX_CONNECTIONS=lots: prints configuration error: MAX_CONNECTIONS is invalid: invalid digit found in string and exits 1. (invalid digit found in string is the real <u32 as FromStr>::Err message.)
Exercise 3: Validate-everything config with .env support
Objective: Build a small typed Config that loads .env in development and reports all configuration problems in a single startup pass.
Instructions: Using dotenvy = "0.15", write a Config struct with fields database_url: String, port: u16, and workers: u32. Implement Config::from_env() that loads .env (tolerating a missing file via e.not_found()), then validates: DATABASE_URL is required and non-empty; PORT defaults to 8080 but must parse as u16; WORKERS defaults to 4 but must parse as u32. Collect all errors into a Vec<String> and return them together rather than stopping at the first. In main, print the config on success or all errors and exit 1 on failure.
Solution
Cargo.toml
1
[dependencies]
2
dotenvy="0.15"
1
usestd::env;
2
3
#[derive(Debug)]
4
structConfig{
5
database_url:String,
6
port:u16,
7
workers:u32,
8
}
9
10
implConfig{
11
fnfrom_env()->Result<Self,Vec<String>>{
12
// Load `.env` in development; a missing file is fine.
13
ifletErr(e)=dotenvy::dotenv(){
14
if!e.not_found(){
15
returnErr(vec![format!("failed to read .env: {e}")]);
16
}
17
}
18
19
letmuterrors=Vec::new();
20
21
letdatabase_url=matchenv::var("DATABASE_URL"){
22
Ok(v)if!v.trim().is_empty()=>Some(v),
23
_=>{
24
errors.push("DATABASE_URL is required and must be non-empty".to_string());
25
None
26
}
27
};
28
29
letport=matchenv::var("PORT"){
30
Ok(raw)=>matchraw.parse::<u16>(){
31
Ok(p)=>Some(p),
32
Err(e)=>{
33
errors.push(format!("PORT is invalid: {e}"));
34
None
35
}
36
},
37
Err(_)=>Some(8080),
38
};
39
40
letworkers=matchenv::var("WORKERS"){
41
Ok(raw)=>matchraw.parse::<u32>(){
42
Ok(w)=>Some(w),
43
Err(e)=>{
44
errors.push(format!("WORKERS is invalid: {e}"));
45
None
46
}
47
},
48
Err(_)=>Some(4),
49
};
50
51
if!errors.is_empty(){
52
returnErr(errors);
53
}
54
55
Ok(Config{
56
database_url:database_url.unwrap(),
57
port:port.unwrap(),
58
workers:workers.unwrap(),
59
})
60
}
61
}
62
63
fnmain(){
64
matchConfig::from_env(){
65
Ok(config)=>println!("loaded: {config:?}"),
66
Err(errors)=>{
67
eprintln!("invalid configuration:");
68
forein&errors{
69
eprintln!(" - {e}");
70
}
71
std::process::exit(1);
72
}
73
}
74
}
With DATABASE_URL=postgres://db PORT=9000 WORKERS=8 cargo run it prints
loaded: Config { database_url: "postgres://db", port: 9000, workers: 8 }.
With DATABASE_URL unset and PORT=99999 WORKERS=many it reports all three problems at once:
1
invalid configuration:
2
- DATABASE_URL is required and must be non-empty
3
- PORT is invalid: number too large to fit in target type
4
- WORKERS is invalid: invalid digit found in string