baukit-config 0.4.0

Layered, validated application configuration and secret-handling conventions.
Documentation

baukit-config

baukit-config loads a service's configuration from defaults, an optional file, and environment variables, validates the whole document before the process starts, and keeps secrets out of logs. Products define their own fields and invariants; the crate supplies the standard sections and the loading order.

Fail at startup, not at 3am

The failure this crate is built to prevent is a service that boots happily with a missing or nonsensical setting and only discovers it when the first request needs it. ConfigLoader::load deserializes and validates everything up front, so a bad value stops the process while a human is still watching the deploy.

Validate returns every problem at once rather than the first one. Fixing five typos across five restarts is a worse loop than fixing five typos once.

use baukit_config::{BaukitConfig, ConfigLoader, Environment, Validate, ValidationErrors};
use serde::Deserialize;

#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct ProductConfig {
    catalog_url: Option<String>,
}

impl Validate for ProductConfig {
    fn validate(&self) -> Result<(), ValidationErrors> {
        Ok(())
    }
}

let config: BaukitConfig<ProductConfig> =
    ConfigLoader::new("myapp", Environment::Local)?.load()?;
# Ok::<(), baukit_config::LoadError>(())

Product fields flatten into the top level of the document, next to the standard http, ops, database, rate_limit, telemetry, and shutdown sections. database is Option, so a service with no database omits the section instead of configuring one it never opens.

Layering

Three layers apply in order, each overriding the last:

  1. serde defaults from the config types
  2. an optional local file, config/local.toml unless you change it
  3. environment variables

Environment variables use the application prefix and a double underscore for nesting, so MYAPP__HTTP__PORT=8080 sets http.port. The prefix is derived from the application name, with - becoming _; a name with characters outside ASCII letters, digits, _, and - is a LoadError::InvalidPrefix rather than a silently mangled prefix.

A .env file is read only when the loader's environment is Environment::Local. Deployed processes get their environment from the orchestrator, and a stray .env on a production image must not be able to change what the service does.

Collections use JSON array syntax:

MYAPP__HTTP__CORS_ALLOWED_ORIGINS='["https://app.example.com"]'

http.cors_allowed_origins is registered for you. Declare product collection fields with ConfigLoader::environment_collection, otherwise the value arrives as a string.

Scalar environment values keep their exact source strings, including ones that look numeric. That matters for secrets: an API key of 0123 must not be parsed into 123 on its way to the provider.

Durations in the standard sections deserialize from integer seconds.

Secrets

Wrap a secret in Secret<T>. Its Debug and Display both print [redacted], and the inner value is zeroized on drop.

use baukit_config::Secret;

let token = Secret::new(String::from("s3cr3t"));
assert_eq!(format!("{token:?}"), "[redacted]");
let _exposed = token.expose();

Reading the value is spelled expose on purpose. secret.expose() is greppable in review; secret.get() or a Deref would not be. The point is that revealing a secret has to be a visible choice in the diff rather than something a {:?} in a log line does by accident.

Scope

The crate does not fetch from a secret manager, watch for changes, or reload at runtime. Configuration is read once at startup and stays fixed for the life of the process, which is what makes it safe to validate once. Environment and log-format vocabulary comes from baukit-core and is re-exported here, so configuration and telemetry cannot disagree about what production means.