rust 114 lines · 3 tabs

Layered Config Loading From TOML File and Environment Variables in Rust

Shared by codesnips Aug 2026
3 tabs
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
    #[serde(default)]
    pub server: ServerConfig,
    #[serde(default)]
    pub database: DatabaseConfig,
    #[serde(default = "default_log_level")]
    pub log_level: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
    #[serde(default = "default_host")]
    pub host: String,
    #[serde(default = "default_port")]
    pub port: u16,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
    #[serde(default)]
    pub url: String,
    #[serde(default = "default_max_connections")]
    pub max_connections: u32,
}

impl Default for ServerConfig {
    fn default() -> Self {
        ServerConfig { host: default_host(), port: default_port() }
    }
}

impl Default for DatabaseConfig {
    fn default() -> Self {
        DatabaseConfig { url: String::new(), max_connections: default_max_connections() }
    }
}

fn default_host() -> String { "127.0.0.1".to_string() }
fn default_port() -> u16 { 8080 }
fn default_log_level() -> String { "info".to_string() }
fn default_max_connections() -> u32 { 10 }
3 files · rust Explain with highlit

This snippet shows the twelve-factor pattern of building application configuration from multiple overlapping layers: hard-coded defaults, an optional TOML file, and process environment variables, with later layers overriding earlier ones. The goal is a single strongly-typed struct that the rest of the program reads, without scattering env::var calls or file parsing across the codebase.

In config.rs, the shape of the configuration is a plain serde Deserialize struct. AppConfig nests ServerConfig and DatabaseConfig so related keys stay grouped, which maps cleanly onto TOML tables and onto prefixed environment keys like APP_SERVER__PORT. The #[serde(default = ...)] attributes attach per-field default functions such as default_port, so a partial file or a missing variable still deserializes into a valid value rather than an error. This is the crucial trade-off of layered config: defaults live in code so the app boots even with no file and no env at all.

The layering itself lives in loader.rs and uses the figment crate. AppConfig::load composes a Figment by merging providers in priority order: Serialized::defaults seeds the baseline, Toml::file adds file values if the path exists, and Env::prefixed("APP_") pulls in environment variables last so they win. The split("__") call teaches figment to map a double-underscore into nested table access, which is how APP_SERVER__PORT reaches server.port. Calling .extract() turns the merged figment into the typed AppConfig, and any type mismatch surfaces as a figment::Error instead of a runtime panic later.

After extraction, validate enforces invariants that types alone cannot express, such as rejecting a zero max_connections or an empty database URL. Doing validation once at startup means the rest of the program can trust the config and fail fast with a clear message.

In main.rs, AppConfig::load is called from a fallible main that returns a Result, so a bad config prints a readable error and exits nonzero. Because everything funnels through one typed struct, callers read cfg.server.port directly. This approach shines for services that must run identically across local development and containerized environments, where files supply defaults and env vars override per deployment.


Related snips

Share this code

Here's the card — post it anywhere.

Layered Config Loading From TOML File and Environment Variables in Rust — share card
Link copied