My Rust Backend Refuses to Start With a Bad Config File, On Purpose

One TOML file, read once at startup, and the backend doesn't start if a single key is wrong. Why I picked TOML, how serde does most of the validation, why the log level is an enum, and the error message that printed my whole config file.

Simone Negro, Backend & AI Engineer
7 min read

Most configuration bugs don’t crash anything. A key with a typo gets ignored, a default fills the gap, and the program runs with a value nobody chose. I’m building a backend in Rust while learning the language, and I wanted the opposite: one TOML file, read once at startup, and if anything in it is wrong the backend doesn’t start at all.

This post covers why I picked TOML, how serde does most of the checking, what is left to check by hand, the test helper that passed for the wrong reason, and an error message that printed my entire config file. The examples are a trimmed-down version of what I run, small enough to copy, with the same ideas.

Why one TOML file

First, why a single file and not environment variables. A file lives in git, so every change has an author and a date. An environment variable can be changed on a server and leave no trace, and it can quietly override a value that was set for a reason. So the backend reads one file per environment, and nothing overrides it.

Then, why TOML for that file:

  • It’s what Rust already uses. Cargo.toml is TOML, so anyone who has touched a Rust project can read it, and the toml crate plugs straight into serde.
  • It allows comments. JSON doesn’t, and a config file is exactly where you want to write why a value is what it is.
  • Types are explicit. port = 5432 is a number and port = "5432" is a string, so a wrong type is an error instead of a silent conversion.
  • Fewer traps than YAML. No meaningful indentation, and no implicit conversions like the YAML 1.1 rule that turns a bare no into false.

Here is the example file for this post:

# Keep this first: a key written after a [section] belongs to that section.
log_level = "info"

[database]
host = "127.0.0.1"
port = 5432
user = "app_user"
password_file = "secrets/db_password"

The password is not in it, only the path to the file that holds it. And the file only contains what changes between environments: addresses, ports, the log level. Whatever is the same everywhere stays in the code.

The path is a required argument

The program doesn’t look for its config in a default location. The path is the first command-line argument, and without it the program stops:

fn run(args: Vec<String>) -> Result<(), Box<dyn Error>> {
    let path = args.get(1).ok_or("usage: app <config.toml>")?;
    let config = load(path)?;
    // ...start the server with `config`
    Ok(())
}

A default path sounds convenient until it loads the wrong file and nobody notices. Typing the path is the price, and in development a Cargo alias in .cargo/config.toml pays it for me:

[alias]
dev = "run -- config/development.toml"

One catch: cargo run keeps the folder you launched it from, so a relative path in the alias only works from the project folder. Anywhere else, the program can’t find the file and says so.

Letting serde reject what it doesn’t recognize

Each part of the file becomes a struct, and serde fills it in:

use serde::Deserialize;
use std::path::PathBuf;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct AppConfig {
    log_level: LogLevel,
    database: DatabaseConfig,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DatabaseConfig {
    host: String,
    port: u16,
    user: String,
    password_file: PathBuf,
}

By default serde ignores keys it doesn’t know. #[serde(deny_unknown_fields)] turns that into an error, and it’s the attribute that matters most here. A typo in a key:

TOML parse error at line 5, column 1
  |
5 | hots = "127.0.0.1"
  | ^^^^
unknown field `hots`, expected one of `host`, `port`, `user`, `password_file`

A missing key gives missing field port, and a port written as a string gives invalid type: string "5432", expected u16. The type does some of the work too: since port is a u16, a negative port or one above 65535 never makes it into the struct.

What serde can’t check

Some rules are about values, not types. An empty host is a valid String, and port 0 is a valid u16. Those checks run right after parsing:

fn parse(text: &str) -> Result<AppConfig, Box<dyn Error>> {
    let config: AppConfig = toml::from_str(text)?;
    let db = &config.database;

    if db.host.trim().is_empty() {
        return Err("database.host is required".into());
    }
    if db.port == 0 {
        return Err("database.port must be greater than 0".into());
    }
    if db.user == "postgres" {
        return Err("database.user can't be postgres: a superuser skips row-level security".into());
    }

    Ok(config)
}

The last check is the one I care about most. A Postgres superuser bypasses row-level security, so a backend that connects as postgres makes every RLS policy pointless. The config refuses that user before any connection is opened. Why the database has several roles, each with a different job, is the topic of a later post.

A log level is an enum, not a string

My first idea was a String and a check against a list of allowed values. An enum is shorter and stricter:

#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum LogLevel {
    Error,
    Warn,
    Info,
    Debug,
    Trace,
}

rename_all = "lowercase" makes serde accept "info" for LogLevel::Info. Anything else is rejected without a single line of checking code:

unknown variant `verbose`, expected one of `error`, `warn`, `info`, `debug`, `trace`

"INFO" gets the same answer. I kept it that way on purpose: one accepted spelling means the same spelling in every file. This is what Alexis King calls “parse, don’t validate”. Once the value is a LogLevel, nothing later in the program can receive an invalid one, so nothing has to check it again.

Paths relative to the config file, not to the shell

password_file is relative, and the question is: relative to what? If it were relative to the folder the program was launched from, the same config would work or break depending on where I typed the command. So it’s resolved from the folder of the config file:

fn load(path: &str) -> Result<AppConfig, Box<dyn Error>> {
    let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))?;
    let mut config = parse(&text)?;

    let folder = Path::new(path).parent().ok_or("the config path has no folder")?;
    config.database.password_file = folder.join(&config.database.password_file);

    Ok(config)
}

Path::join has a property that makes this work on a server for free: joining an absolute path replaces the base. So an absolute password_file, like the paths secrets get mounted at inside a container, is kept exactly as written.

Where it bit me: a test that changed two fields

My config has 32 tests. Most of them take a correct TOML, break one thing, and check that it’s rejected for the right reason. The helper that breaks one thing started out like this:

fn correct_toml_with(from: &str, to: &str) -> String {
    assert!(CORRECT_TOML.contains(from));
    CORRECT_TOML.replace(from, to)
}

Then I added a second section that also had host = "127.0.0.1". replace changes every occurrence, so the test meant to empty the database host emptied both hosts. It still passed, because the database section is checked first. It just no longer proved what its name said.

The fix is to require that the text appears exactly once, and to give the two sections different values in the test file:

/// Fails unless `from` is in CORRECT_TOML exactly once: if missing, the test
/// would run on the correct TOML; if repeated, it would change more than one field.
fn correct_toml_with(from: &str, to: &str) -> String {
    let count = CORRECT_TOML.matches(from).count();
    assert_eq!(1, count, "`{from}` found {count} times in CORRECT_TOML");
    CORRECT_TOML.replace(from, to)
}

Now an ambiguous replacement can’t pass quietly: the test stops and says how many times it found the text.

The second surprise was TOML itself. A key written after a [section] header belongs to that section until the next header, and there is no way to close a section. That’s why the comment at the top of the example file exists. Move log_level to the end and the program refuses to start:

TOML parse error at line 8, column 1
  |
8 | log_level = "info"
  | ^^^^^^^^^
unknown field `log_level`, expected one of `host`, `port`, `user`, `password_file`

That case is a test as well, so I don’t learn it twice.

The error that printed my whole config file

While writing this post I broke the config on purpose to copy the error messages, and found something I hadn’t noticed. At the time, main returned a Result:

fn main() -> Result<(), Box<dyn Error>> {
    run(std::env::args().collect())
}

When main returns an Err, Rust prints it with its Debug format. For a TOML error, Debug includes the entire input:

Error: Error { message: "unknown field `hots`, expected one of `host`, `port`, `user`, `password_file`", input: Some("# Keep this first: a key written after a [section] belongs to that section.\nlog_level = \"info\"\n\n[database]\nhots = \"127.0.0.1\"\nport = 5432\nuser = \"app_user\"\npassword_file = \"secrets/db_password\"\n"), keys: ["database"], span: Some(107..111) }

Nothing in my file is secret, so this was noise rather than a leak. But a config file is exactly where someone will paste something sensitive one day, and an error message is exactly what gets copied into a chat or an issue. So main now handles the error itself and returns an ExitCode:

use std::process::ExitCode;

fn main() -> ExitCode {
    if let Err(e) = run(std::env::args().collect()) {
        eprintln!("{e}");
        return ExitCode::FAILURE;
    }
    ExitCode::SUCCESS
}

{e} prints the error with Display, the format meant for people, and for TOML that is the short message with the broken line and a marker under it, the same output shown in the sections above. The exit code is still 1, and every function below main still uses ?, because the work moved into run, which returns a Result. Only the last step, from error to exit code, is written by hand.

Strict at startup, quiet afterwards

The backend can’t run with a configuration it doesn’t fully understand. A wrong key, a wrong type or a dangerous value costs me one failed start and a message that points at the line. I’d rather pay that than find out in production from a default I never chose. I’m not sure every setting deserves to be mandatory, and the log level is the one I’d make optional first. For now, nothing is.

If you build APIs in another stack, the same idea shows up in a different shape in the post about an [Authorize] attribute that didn’t protect anything: configuration that looks right and is quietly wrong.

References

Get new posts by email

No hype, unsubscribe anytime. · Powered by Buttondown

Or follow along

Shorter takes, half-finished ideas, and whatever I'm building or breaking this week.