Configuration & Env
Layer CLI flags, environment variables, and config files with a clear precedence order.
Search across all documentation pages
Layer CLI flags, environment variables, and config files with a clear precedence order.
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.
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:
env = on clap fields binds environment variables| 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 |
// Never read secrets from config files committed to git
let api_key = std::env::var("API_KEY")
.context("set API_KEY in the environment")?;PORT may conflict with other tools. Fix: Prefix with your app name (APP_PORT).| 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 |
TOML is the Rust ecosystem default. YAML works when your ops team already standardizes on it.
Yes. Use the same names (--port maps to port in the file) to reduce cognitive load.
Use dirs::config_dir() and fall back to ./config.toml for development.
clap 4 does not load files natively. Merge a file provider before or after parse().
Write temp TOML files in tests and pass paths via Cli::parse_from.
Use config.dev.toml / config.prod.toml selected by --env or APP_ENV.
Deserialize into typed structs with serde. Fail fast with anyhow::Context on missing fields.
Use env = "FLAG" with Option<bool> or parse "true" / "false" strings explicitly.
Rare for CLIs. If needed, watch the file with notify and reload on change.
Print a --help note and a startup log line showing active config sources.
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+.
Reviewed by Chris St. John·Last updated Jul 19, 2026