Skip to content

Add layered configuration to a Rust CLI

By the end you'll have a working command-line binary whose settings come from three places at once — defaults baked into the executable, a YAML file a user can edit, and environment variables for a single run — and you'll have watched each layer override the one beneath it.

Allow about fifteen minutes. Everything happens in a scratch project you can delete afterwards.

Before you start

You need a Rust toolchain — a current stable is safest, and rtb-config itself declares 1.82 as its minimum — plus network access the first time, to fetch crates. No other setup.

Create the project

cargo new mytool
cd mytool

Add the two dependencies:

cargo add rtb-config
cargo add serde --features derive

cargo add prints the optional features rtb-config offers — hot-reload and mutable. Leave both off; neither is needed here.

Describe the settings as a struct

rtb-config has no keys of its own. The keys are the fields of a struct you declare, and the compiler checks every use of them.

Put this in src/main.rs:

use rtb_config::Config;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct MyConfig {
    greeting: String,
    http: Http,
}

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

http is a nested struct rather than a pair of flat http_host and http_port fields. That matters later — nesting is what makes a setting reachable from an environment variable.

Ship the defaults inside the binary

Create defaults.yaml next to Cargo.toml:

greeting: Hello
http:
  host: localhost
  port: 8080

This file gets compiled into the executable, so it doesn't need to exist on the machine that runs your tool. It's the answer to "what does this setting mean if nobody has said otherwise".

Load the layers and print the result

Add a main that registers all three sources:

fn main() -> Result<(), rtb_config::ConfigError> {
    let config = Config::<MyConfig>::builder()
        .embedded_default(include_str!("../defaults.yaml"))
        .user_file("mytool.yaml")
        .env_prefixed("MYTOOL_")
        .build()?;

    let cfg = config.get();
    println!("{}, serving on {}:{}", cfg.greeting, cfg.http.host, cfg.http.port);
    Ok(())
}

Run it:

cargo run
Hello, serving on localhost:8080

mytool.yaml doesn't exist yet, and that's fine — a missing config file contributes no keys and is not an error. It's what lets a tool register a file path it hopes for rather than one it demands.

Let a config file override one setting

Create mytool.yaml in the project directory with just the one key you want to change:

http:
  port: 9090
cargo run
Hello, serving on localhost:9090

The port changed and host didn't. Nested maps merge key by key, so a config file only has to mention what it changes — it doesn't have to restate the whole http section.

Override a setting for one run

Now set an environment variable, without touching the file:

MYTOOL_HTTP_PORT=3000 cargo run
Hello, serving on localhost:3000

The environment beat the file, which beat the embedded default. That order is fixed: it doesn't depend on the order you called the builder methods in.

Read the variable name from the inside out. The prefix MYTOOL_ is stripped, the rest is lower-cased, and each remaining underscore is a level of nesting — so MYTOOL_HTTP_PORT sets http.port.

That's also the catch worth knowing now rather than later: a field whose own name contains an underscore can't be set from the environment at all. Had you declared a flat http_port field, MYTOOL_HTTP_PORT would have been read as the nested key http.port, matched nothing, and been ignored in silence. Nesting the struct is what keeps the setting reachable.

Try a single-word key too:

MYTOOL_GREETING="Good morning" cargo run
Good morning, serving on localhost:9090

See what a bad value does

Configuration fails at load, not at use. Give the port something that isn't a number:

MYTOOL_HTTP_PORT=eighty cargo run
Error: Parse("invalid type: found string \"eighty\", expected u16 for key \"HTTP.PORT\" in `MYTOOL_` environment variable(s)")

The process exits non-zero and never reaches your code. The message names the key and the layer it came from, which is usually enough to find it.

A misspelled key behaves differently, and it's worth seeing once. Add a line to mytool.yaml:

http:
  port: 9090
prot: 1234
cargo run
Hello, serving on localhost:9090

No error. Keys the struct doesn't have are ignored, so a typo silently does nothing. Delete that line before moving on. If your tool owns its config file exclusively you can turn typos into errors by putting #[serde(deny_unknown_fields)] on the struct — at the cost of rejecting keys a newer version of the tool might add.

Make the error message readable

That Error: Parse(...) line is the Debug formatting Rust uses for an error returned from main. Every rtb-config error also carries a diagnostic code and a help line, and miette renders them properly:

cargo add miette --features fancy

Change the signature of main:

fn main() -> miette::Result<()> {

Leave the body alone; ? converts the error for you. Run the failing case again:

MYTOOL_HTTP_PORT=eighty cargo run
Error: rtb::config::parse

  × configuration error: invalid type: found string "eighty", expected u16 for
  │ key "HTTP.PORT" in `MYTOOL_` environment variable(s)
  help: check your config file and environment variables against the schema

Same failure, considerably more useful to whoever hits it.

What you built

Three layers, merged into one typed value, with the compiler checking every field access and bad input rejected at start-up:

Layer Set in Wins over
Embedded default defaults.yaml, compiled in nothing
User file mytool.yaml on disk the embedded default
Environment MYTOOL_* both

Where to go next