Skip to Content
Course content

206: Config File Handling with config crate

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first start building a real application—something beyond a CLI tool or a small library—you'll inevitably hit the "how do I store these settings?" problem. Usually, it starts with a few constants at the top of your main.rs, but quickly evolves into a settings.toml or config.json file. Most developers start with a very manual approach to loading these files, and while it works on their local machine, it usually falls apart the moment the code hits a CI/CD pipeline or a Docker container.

The fragile way: Manual file reading

The naive approach is to treat a configuration file like any other text file. You use std::fs::read_to_string, pass that string into a deserializer like toml or serde_json, and map it to a struct. It looks something like this:

struct AppConfig {
    port: u16,
    database_url: String,
}

fn load_config() -> AppConfig {
    let content = std::fs::read_to_string("config.toml").expect("Config file missing!");
    toml::from_str(&content).expect("Invalid config format")
}

On the surface, this is clean. It's explicit and easy to follow. But I've seen this approach cause countless headaches during deployment. The biggest issue here is rigidity. If the file is missing, the app panics. If you want to change the database_url for a staging environment versus a production environment, you're forced to either maintain multiple physical files or write your own logic to check which file to load based on an environment variable.

Where the manual approach breaks

The real friction starts when you move to the cloud. In a modern Kubernetes or Docker setup, you rarely want to bake your secrets (like database passwords) directly into a .toml file inside your image. Instead, you inject them as environment variables. If you're using the manual method, you now have to write a bunch of boilerplate: "Check if the environment variable exists; if it does, use it; otherwise, fall back to the value in the file."

Suddenly, your load_config function is 50 lines of if let Some(...) blocks. You're essentially rebuilding a configuration management system from scratch, which is a waste of your time and a source of bugs. You want your config to be a layered system, not a single point of failure.

Layering sources with the config crate

This is where the config crate comes in. Instead of reading a file and parsing it once, the config crate allows you to build a configuration object by stacking sources. Think of it like a CSS cascade: the last source added wins. You can define a set of defaults, override them with a file, and finally override those with environment variables.

Here is how I usually set this up for a production service:

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

#[derive(Debug, Deserialize)]
struct Settings {
    port: u16,
    database_url: String,
}

fn setup_config() -> Result {
    let s = Config::builder()
        // Start with a default file
        .add_source(File::with_name("config/default"))
        // Add an environment-specific override (e.g., config/production.toml)
        .add_source(File::with_name("config/local").required(false))
        // Finally, look for environment variables prefixed with "APP_"
        // e.g., APP_PORT=8080 will override the port setting
        .add_source(Environment::with_prefix("APP").separator("__"))
        .build()?;

    s.try_deserialize()
}

Why this is the professional choice

The beauty of this pattern is that it decouples your application logic from your deployment environment. Your developers can have a config/local.toml (which is git-ignored) for their specific DB setups, while your production server relies on config/default.toml for non-sensitive basics and environment variables for the secrets.

Note the .required(false) call. This is crucial. It tells the crate, "Try to find this file, but if it's not there, don't crash." This allows the same binary to run in an environment where only the default config and environment variables are present. I've found that using a separator like __ in Environment::with_prefix is also a lifesaver when you start dealing with nested configuration structs, as it allows you to map APP__DATABASE__PORT to settings.database.port.




📋 Practical Task

Exercise: Building a Layered API Configuration Loader

Your task is to implement a configuration loader for a hypothetical "Telemetry Service". The service requires three settings: api_key (String), log_level (String), and timeout_seconds (u64).

Implement a module that does the following:

  • Creates a Settings struct that derives Deserialize.
  • Uses the config crate to load settings in this specific order of priority (lowest to highest):
    1. A file named settings.toml (required).
    2. A file named settings.local.toml (optional).
    3. Environment variables starting with the prefix TELEMETRY_.
  • Ensure that an environment variable named TELEMETRY_TIMEOUT_SECONDS will override any value found in the TOML files.

Test your implementation: Create a settings.toml with a timeout of 30, then run your program with the environment variable TELEMETRY_TIMEOUT_SECONDS=60. Your program should print the value 60.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.