How to Use the config Crate for Application Configuration

Use the `config` crate to build a flexible, layered configuration system that merges defaults, environment variables, and file-based settings (like YAML, JSON, or TOML) into a single structured Rust type.

Hardcoding config breaks your app

You wrote a web server. It runs on port 8080. You hardcode that number in the source. Your teammate wants to run two instances locally to test a feature. They copy the binary, start it, and the second instance crashes with a "address already in use" error. You realize the port needs to come from somewhere else. Maybe a file? Maybe an environment variable?

Hardcoding configuration is a trap. It forces recompilation for every environment change. It leaks secrets into version control. It makes testing harder because you can't swap databases without touching the code. You need a system that layers defaults, files, and environment variables so the binary stays the same but the behavior adapts to where it runs.

The stack of overlays

The config crate treats settings like a stack of transparent sheets. The bottom sheet holds your hard-coded defaults. The next sheet might be a YAML file in your project folder. The top sheet could be environment variables set by the OS.

When you ask for a value, the crate checks the top sheet first. If the value is there, you get it. If the top sheet is blank in that spot, the crate looks down to the next sheet. This continues until it finds a value or hits the bottom. The result is a single merged configuration that respects priority. Defaults provide safety. Files provide structure. Environment variables provide runtime flexibility.

This pattern matches how production systems work. You ship a binary with sensible defaults. The deployment system sets environment variables to flip switches or point to production databases. Developers can keep a local file for tweaks without committing secrets or breaking the build.

Minimal example

Start with a struct that derives Deserialize. The config crate uses serde to turn the merged configuration into your Rust types. Define the fields you need, then build the configuration using the builder pattern.

use config::{Config, ConfigError, File};
use serde::Deserialize;

/// Application settings loaded from multiple sources.
#[derive(Debug, Deserialize)]
struct Settings {
    /// The port the server listens on.
    port: u16,
    /// Whether to enable verbose logging.
    debug: bool,
}

fn load() -> Result<Settings, ConfigError> {
    // Start the builder. This creates an empty configuration state.
    let builder = Config::builder()
        // Set a default port. This is the fallback if nothing else provides a value.
        .set_default("port", 8080)?
        // Set a default debug flag.
        .set_default("debug", false)?
        // Add a YAML file source. If the file doesn't exist, ignore it.
        .add_source(File::with_name("config").required(false))
        // Add environment variables. Prefix "APP_" maps to keys.
        .add_source(config::Environment::with_prefix("APP_"));

    // Build the config object, resolving all sources.
    let config = builder.build()?;

    // Deserialize the merged config into our struct.
    config.try_deserialize()
}

fn main() {
    // Load the config and print it.
    match load() {
        Ok(settings) => println!("Config loaded: {:?}", settings),
        Err(e) => eprintln!("Configuration error: {}", e),
    }
}

Create a config.yaml file in the project root to test the file source.

port: 9000
debug: true

Run the binary. The output shows port: 9000 and debug: true because the file overrides the defaults. Now set an environment variable and run again.

export APP_PORT=3000
cargo run

The output changes to port: 3000. The environment variable sits on top of the stack and wins. The debug flag stays true because the environment didn't override it.

Convention aside: The config crate defaults to treating environment variables as strings. You almost always want .try_parsing(true) on the environment source. Without it, APP_PORT=9000 becomes the string "9000". The deserializer then fails because it expects a u16. Enable parsing to let the crate coerce types automatically.

How the builder works

The builder collects sources in the order you add them. Each call to set_default or add_source appends a new layer. When you call build(), the crate reads every source and merges them into a flat map of key-value pairs. Later sources override earlier ones. If two sources provide the same key, the last one added wins.

The build() method returns a Config object. This object holds the merged data but hasn't converted it to your struct yet. Calling try_deserialize() triggers the conversion. The crate uses serde to walk the map and populate your struct. If a required field is missing, or if a type doesn't match, try_deserialize returns a ConfigError.

This two-step process separates loading from parsing. You can inspect the raw config if needed, or deserialize into multiple structs. It also means errors happen at the end, giving you a clear signal that the configuration is invalid before your app starts.

Keep the builder chain readable. One source per line helps. cargo fmt handles the indentation. Don't fight the formatter; argue logic, not style.

Realistic setup with nested config

Real applications have nested settings. A database config has a host and a port. A server config has a bind address and a timeout. The config crate handles nested structures by flattening keys with dots. A struct field database.host maps to the key database.host.

use config::{Config, ConfigError, Environment, File};
use serde::Deserialize;

/// Top-level application configuration.
#[derive(Debug, Deserialize)]
struct AppConfig {
    /// Server binding address.
    bind: String,
    /// Database connection details.
    database: DatabaseConfig,
}

/// Database specific settings.
#[derive(Debug, Deserialize)]
struct DatabaseConfig {
    /// Hostname of the database server.
    host: String,
    /// Port for the database connection.
    port: u16,
}

fn load_realistic() -> Result<AppConfig, ConfigError> {
    Config::builder()
        // Defaults act as the safety net.
        .set_default("bind", "127.0.0.1:8080")?
        .set_default("database.host", "localhost")?
        .set_default("database.port", 5432)?
        
        // Base config file. Required for the app to run.
        .add_source(File::with_name("config").required(true))
        
        // Local overrides. Optional, ignored if missing.
        // Useful for developers to tweak settings without committing changes.
        .add_source(File::with_name("config.local").required(false))
        
        // Environment variables override everything.
        // Use double underscore to separate nested keys.
        .add_source(Environment::with_prefix("APP_")
            .separator("__")
            .try_parsing(true))
        
        .build()?
        .try_deserialize()
}

The config.local file is a common pattern. Developers create this file to override settings on their machine. They add it to .gitignore so it never gets committed. The base config file lives in version control and defines the standard structure. This keeps secrets out of the repo and allows per-machine tweaks.

Environment variables use a separator to represent nesting. The key database.host becomes APP__DATABASE__HOST with the double underscore separator. If you set APP_DATABASE_HOST, the crate won't find it. You must match the separator. The convention is double underscore for nested keys.

Put config.local in .gitignore. Never commit secrets.

Pitfalls and errors

Configuration errors often hide until runtime. The compiler can't check environment variables or file contents. You need to watch for specific traps.

If you forget #[derive(Deserialize)] on your struct, the compiler rejects the code with E0277 (the trait bound Deserialize is not satisfied). The config crate needs serde to parse the data. Without the derive macro, Rust doesn't know how to convert the configuration map into your struct. Add the derive and import serde::Deserialize.

Order matters in the builder. Sources added later override sources added earlier. If you put set_default after File, the default wins over the file. That defeats the purpose. Always add defaults first. The builder is a stack. The last item pushed is the first item seen.

Type mismatches cause runtime crashes if try_parsing is disabled. If APP_PORT is "9000" and try_parsing is false, the config holds a string. try_deserialize fails because it expects a u16. The error message points to the field, but the root cause is the environment source configuration. Enable try_parsing on all environment sources.

Nested keys in environment variables require the separator. If your struct has database.host, the environment variable must be APP__DATABASE__HOST with the default separator. Using single underscores breaks the mapping. The crate treats APP_DATABASE_HOST as a flat key, not a nested one. Check the separator setting if overrides aren't applying.

Enable try_parsing. Strings masquerading as integers cause runtime crashes.

When to use config versus alternatives

Pick the tool that matches your complexity. Don't use a sledgehammer for a nail.

Use the config crate when you need a layered system that merges defaults, files, and environment variables. It handles the priority logic and type coercion for you. This is the standard choice for most Rust applications.

Use the toml crate directly when your configuration lives in a single TOML file and you don't need environment variable overrides or complex merging. It's lighter and simpler for flat setups. You lose the layering, but you gain simplicity.

Reach for std::env::var when you only need a few environment variables and want zero dependencies. It's built-in, but you lose type safety and default handling. You have to parse strings manually and handle missing variables yourself.

Use a custom parser when you have a proprietary config format that doesn't fit YAML, JSON, or TOML. The config crate supports custom sources, but writing a parser from scratch gives you full control over the syntax. This is rare. Most apps benefit from standard formats.

Trust the layers. The top wins.

Where to go next