Configuration & Env
Layer CLI flags, environment variables, and config files with a clear precedence order.
Recipe
use clap::Parser;
use figment::{providers::{Env, Format, Serialized, Toml}, Figment};
use serde::Deserialize;
#[derive(Parser)]
struct Cli {
#[arg(short, long)]
config: Option<std::path::PathBuf>,
#[arg(long)]
port: Option<u16>,
}
#[derive(Debug, Deserialize)]
struct Settings {
port: u16,
host: String,
}
fn load(cli: &Cli) -> anyhow::Result<Settings> {
let mut figment = Figment::new()
.merge(Serialized::defaults(Settings { port: 8080, host: "127.0.0.1".into() }))
.merge(Toml::file(cli.config.clone().unwrap_or_else(|| "config.toml".into())))
.merge(Env::prefixed("APP_").split("_"));
if let Some(port) = cli.port {
figment = figment.merge(Serialized::from(("port", port)));
}
Ok(figment.extract()?)
}When to reach for this: Any CLI deployed to multiple environments where operators need overrides without editing files.
Working Example
use clap::Parser;
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "worker")]
struct Cli {
/// Path to TOML config (default: ./worker.toml)
#[arg(short, long, env = "WORKER_CONFIG")]
config: Option<PathBuf>,
/// Override log level
#[arg(long, env = "RUST_LOG")]
log_level: Option<String>,
/// Override concurrency (wins over config file)
#[arg(long)]
jobs: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct FileConfig {
jobs: usize,
queue_url: String,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let path = cli.config.unwrap_or_else(|| PathBuf::from("worker.toml"));
let file: FileConfig = toml::from_str(&std::fs::read_to_string(&path)?)?;
let jobs = cli.jobs.unwrap_or(file.jobs);
eprintln!("running with {jobs} jobs against {}", file.queue_url);
Ok(())
}What this demonstrates:
- Flags override file config (explicit operator intent)
env =on clap fields binds environment variables- TOML file holds stable defaults
- Precedence: flags > env > config file > built-in defaults
Deep Dive
Recommended Precedence
- CLI flags (highest)
- Environment variables
- Config file (TOML, YAML, or JSON)
- Built-in defaults (lowest)
figment vs config Crate
| Crate | Strength |
|---|---|
figment | Composable providers, merge order is explicit |
config | Hierarchical keys, good for large apps |
clap alone | Fine when you only need flags + env |
Secrets
// Never read secrets from config files committed to git
let api_key = std::env::var("API_KEY")
.context("set API_KEY in the environment")?;Gotchas
- Env var naming collisions -
PORTmay conflict with other tools. Fix: Prefix with your app name (APP_PORT). - Silent config file misses - Missing file falls through to defaults unexpectedly. Fix: Log which file loaded; fail if required file absent.
- Type coercion surprises - Env vars are always strings. Fix: Parse explicitly with clear error messages.
- Relative config paths - Paths resolve from CWD, not the binary location. Fix: Document CWD expectations or resolve relative to config file parent.
- Printing config at startup - Debug logs may leak secrets. Fix: Redact sensitive fields before logging.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Flags only | Single-binary tools with few settings | Many tunables across environments |
dotenvy for dev | Local .env files during development | Production secret management |
| Remote config (Consul, SSM) | Fleet of services with live updates | Simple single-host CLIs |
FAQs
What config format should I use?
TOML is the Rust ecosystem default. YAML works when your ops team already standardizes on it.
Should flags mirror config keys?
Yes. Use the same names (--port maps to port in the file) to reduce cognitive load.
How do I support XDG config dirs?
Use dirs::config_dir() and fall back to ./config.toml for development.
Can clap read a config file directly?
clap 4 does not load files natively. Merge a file provider before or after parse().
How do I test config loading?
Write temp TOML files in tests and pass paths via Cli::parse_from.
Multiple environments?
Use config.dev.toml / config.prod.toml selected by --env or APP_ENV.
Validate config at startup?
Deserialize into typed structs with serde. Fail fast with anyhow::Context on missing fields.
Boolean env vars?
Use env = "FLAG" with Option<bool> or parse "true" / "false" strings explicitly.
Config file hot reload?
Rare for CLIs. If needed, watch the file with notify and reload on change.
Document precedence?
Print a --help note and a startup log line showing active config sources.
Related
- CLI Basics - fundamentals
- clap - flag and env binding
- Error Reporting - config error messages
- Packaging & Distribution - shipping defaults
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.