Env vars for Rust: validate with a composable rule set, load .env into std::env, and optionally derive a typed Config.
- Validate env vars against a
Schemaof composable rules — works on anyHashMap, a.envfile, or the live process environment - Load a
.envinto the process with an explicit override policy, or parse it to a map with no side effects - Derive a typed
Configwith#[derive(Envro)]— field types are coerce targets;#[envro(...)]attrs are the rules; values still load at runtime - Defaults for optional vars —
Field::default_value("…")/#[envro(default = "…")](required forOption<T>) - Compose derived values with
${VAR}substitution, then validate each part - Small
.envdialect — comments, quotes, multiline, duplicate keys rejected - Encryption — optional
encryptionfeature;Encrypted[AGE:b64:…]in.envviaENVRO_AGE_IDENTITY_FILE+#[envro(secret)]; CI can inject plaintext (docs/encryption.md)
cargo add envro
# with encrypted secrets in .env (age):
cargo add envro --features encryptionTypical service setup: optional .env for local dev, then validate and coerce the process environment into a struct.
use std::env;
use envro::{load_dotenv_in_env_vars, Envro, EnvroConfig, EnvroError};
#[derive(Envro, Debug)]
struct Config {
#[envro(from = "APP_NAME", min_len = 1, max_len = 64)]
app_name: String,
#[envro(from = "APP_PORT", port)]
app_port: u16,
#[envro(from = "DATABASE_URL", min_len = 1, starts_with = "pg://")]
database_url: String,
#[envro(from = "DB_POOL_SIZE", positive_integer)]
db_pool_size: i64,
#[envro(from = "LOG_LEVEL", default = "info", one_of("debug", "info", "warn", "error"))]
log_level: String,
#[envro(from = "FEATURE_METRICS", boolean)]
feature_metrics: bool,
}
fn main() -> Result<(), EnvroError> {
let env_file = env::current_dir()?.join(".env");
if env_file.is_file() {
load_dotenv_in_env_vars(&env_file, false)?;
}
let config = Config::from_env()?;
println!("{config:?}");
Ok(())
}On failure, EnvroError::Validation lists every failing rule:
// example/src/bin/getting_started_error.rs — cargo run --bin getting_started_error
match Config::from_dotenv(&env_file) {
Ok(config) => println!("{config:?}"),
Err(err) => eprintln!("{err}"),
// VALIDATION_ERROR APP_NAME[required]: ...; APP_PORT[port]: 70000 not in 1..=65535; ...
}In CI/CD, inject process env vars as plaintext. #[envro(secret)] still applies — process plaintext is allowed; only a .env file requires Encrypted[…]. Compose with ${VAR} via Config::from_env():
use envro::{Envro, EnvroConfig};
#[derive(Envro, Debug)]
struct Config {
#[envro(from = "PG_USER", min_len = 1, max_len = 63, alphanumeric)]
pg_user: String,
#[envro(from = "PG_PASS", secret, min_len = 6)]
pg_pass: String,
#[envro(from = "PG_HOST", min_len = 1, max_len = 253)]
pg_host: String,
#[envro(from = "PG_PORT", port)]
pg_port: u16,
#[envro(from = "PG_DB", min_len = 1, alphanumeric)]
pg_db: String,
#[envro(from = "PG_SSLMODE", one_of("disable", "require", "verify-full"))]
pg_sslmode: String,
// e.g. DATABASE_URI=pg://${PG_USER}:${PG_PASS}@${PG_HOST}:${PG_PORT}/${PG_DB}?sslmode=${PG_SSLMODE}
#[envro(from = "DATABASE_URI", starts_with = "pg://")]
database_uri: String,
#[envro(from = "DB_POOL_SIZE", positive_integer)]
db_pool_size: i64,
}
fn main() -> Result<(), envro::EnvroError> {
// optional local .env — skipped in CD when vars are already injected
let env_file = std::env::current_dir()?.join(".env");
if env_file.is_file() {
envro::load_dotenv_in_env_vars(&env_file, false)?;
}
let config = Config::from_env()?;
println!("{}", config.database_uri);
Ok(())
}Runnable version: cd example && cargo run --bin example.
See docs/dotenv-format.md for ${VAR} rules.
Same rules as a hand-written Schema when you only need validation (maps, tests, no struct):
use envro::*;
let schema = Schema::new()
.field("APP_PORT", Field::required().port())
.field(
"LOG_LEVEL",
Field::default_value("info").one_of(&["debug", "info", "warn", "error"]),
);
validate_env(&schema)?;
// or: load_dotenv_validated(&path, &schema)?; validate(&vars, &schema)?;| Doc | Contents |
|---|---|
| docs/api.md | Public APIs and types (functions, Schema / Field, derive) |
| docs/validation.md | Rule reference (secret, …), semantics, error inspection |
| docs/dotenv-format.md | .env dialect, ${VAR}, encrypted values |
| docs/encryption.md | Age secrets, identity file, PQ keys, Compose / CI |
| docs/comparison.md | Comparison with dotenvy, dotenv-ng, and related crates |
| docs.rs/envro | Generated rustdoc |
- No multi-file layering — one path per call (CUE on inheritance, Angular LIFT Flat)
- No macros that bake env values into the binary —
#[derive(Envro)]encodes types and rules only; values always load at runtime
- Never commit
.env— add.envto.gitignore; commit.env-sample/ docs with placeholder values only - Validate at the boundary — load optional local
.env, thenConfig::from_env()/validate_envso missing or bad vars fail fast - Mark secrets —
#[envro(secret)]/Field::secret()so plaintext secrets in a.envare rejected (docs/encryption.md) - Don't use
.envto prod — inject config at deploy time via CI/containers as process env (Zero Trust: protect identities) - One identity per project — keep age private keys outside the repo (
ENVRO_AGE_IDENTITY_FILE); never reuse one key across all apps - Prefer compose over duplication — build URLs with
${VAR}and validate parts + the composed value - Fail closed in prod — required fields, tight
one_of/ length / format rules; defaults only where a safe fallback exists
MIT © 2024-2026 Simone Sanfratello