Configuration
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against config 0.15.27, figment 0.10.19, envy 0.4.2 and dotenvy 0.15.7 with axum 0.8.9 on Rust 1.99
(edition 2024), checked on 2026-10-06. Bookshelf runs on laptops, in CI, in staging and in production with different
addresses, pool sizes and secrets. Configuration is read once in main, turned into a typed Settings value, checked,
and placed into AppState; nothing else reads environment variables.
Principles
-
Typed and validated at start-up. A missing or malformed value stops the process with a clear message before it accepts traffic, rather than failing on the first request that needs it.
-
Twelve-factor: configuration in the environment. The same build artefact (container image) runs everywhere; only the environment differs. Files hold non-secret defaults; environment variables override them.
-
Secrets never in the image or the repository. They come from the environment, populated by the platform (Kubernetes Secrets, cloud secret managers, Vault) at runtime.
-
One place. Handlers receive
Settings(or the pieces they need) through state, which also makes tests easy.
Layered configuration with the config crate
Defaults live in config/default.toml, environment-specific overrides in config/<environment>.toml, and environment
variables with a prefix win over both:
# config/default.toml
[server]
host = "127.0.0.1"
port = 3000
[database]
max_connections = 10
acquire_timeout_secs = 3
# config/production.toml
[server]
host = "0.0.0.0"
[database]
max_connections = 30
Source: config 0.15.27 and TOML v1.0.0
use std::fmt;
use config::{Config, Environment, File};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Settings {
pub environment: String,
pub server: Server,
pub database: Database,
pub jwt: Jwt,
}
#[derive(Debug, Deserialize)]
pub struct Server {
pub host: String,
pub port: u16,
}
#[derive(Debug, Deserialize)]
pub struct Database {
pub url: Secret,
pub max_connections: u32,
pub acquire_timeout_secs: u64,
}
#[derive(Debug, Deserialize)]
pub struct Jwt {
pub secret: Secret,
}
/// A string that never appears in logs or Debug output.
#[derive(Deserialize)]
#[serde(transparent)]
pub struct Secret(String);
impl Secret {
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret(***)")
}
}
impl Settings {
pub fn load() -> Result<Self, config::ConfigError> {
let environment = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());
Config::builder()
.set_default("environment", environment.clone())?
.add_source(File::with_name("config/default"))
.add_source(File::with_name(&format!("config/{environment}")).required(false))
// BOOKSHELF__DATABASE__URL -> database.url, BOOKSHELF__SERVER__PORT -> server.port
.add_source(Environment::with_prefix("BOOKSHELF").prefix_separator("__").separator("__"))
.build()?
.try_deserialize()
}
/// Checks that types alone cannot express.
pub fn validate(&self) -> Result<(), String> {
if self.jwt.secret.expose().len() < 32 {
return Err("jwt.secret must be at least 32 bytes".into());
}
if !self.database.url.expose().starts_with("postgres://") {
return Err("database.url must be a postgres:// URL".into());
}
if self.environment == "production" && self.server.host == "127.0.0.1" {
return Err("server.host 127.0.0.1 is unreachable inside a container".into());
}
Ok(())
}
}
fn main() {
// local development only: load .env if present (never in production images)
let _ = dotenvy::dotenv();
let settings = match Settings::load().map_err(|e| e.to_string()).and_then(|s| s.validate().map(|()| s)) {
Ok(settings) => settings,
Err(err) => {
eprintln!("invalid configuration: {err}");
std::process::exit(2);
}
};
println!("{settings:#?}"); // safe: secrets print as Secret(***)
}
Source: config 0.15.27 and dotenvy
$ BOOKSHELF__DATABASE__URL="postgres://bookshelf:$DB_PASSWORD@localhost/bookshelf" \
BOOKSHELF__JWT__SECRET="$(openssl rand -hex 32)" APP_ENV=production cargo run
Settings {
environment: "production",
server: Server {
host: "0.0.0.0",
port: 3000,
},
database: Database {
url: Secret(***),
max_connections: 30,
acquire_timeout_secs: 3,
},
jwt: Jwt {
secret: Secret(***),
},
}
$ BOOKSHELF__JWT__SECRET=short cargo run
invalid configuration: missing configuration field "database.url"
Source: config 0.15.27 and dotenvy
-
Nested keys map to environment variables through the separator:
BOOKSHELF__DATABASE__MAX_CONNECTIONS=50overridesdatabase.max_connections. A double underscore keeps single underscores available inside key names. -
try_deserializeperforms type conversion and reports missing or malformed fields by their path. -
The hand-written
DebugforSecretkeeps credentials out of logs and panic messages; thesecrecycrate offers the same with zeroization on drop. -
Exit with a non-zero status on bad configuration: a crash-looping pod with a clear log line is far easier to diagnose than a service that starts and fails later.
Alternatives: figment and envy
figment (used by Rocket) merges providers — TOML/JSON/YAML files, environment variables, struct defaults — and reports precisely which source a bad value came from. envy deserialises environment variables directly into a struct, which is enough for small services configured only through the environment:
use figment::{
Figment,
providers::{Env, Format, Serialized, Toml},
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct Server {
host: String,
port: u16,
}
#[derive(Debug, Deserialize, Serialize)]
struct AppConfig {
server: Server,
}
#[derive(Debug, Deserialize)]
struct Flat {
port: u16,
#[serde(default)]
log_json: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// figment: defaults < file < environment, with provenance in error messages
let defaults = AppConfig { server: Server { host: "127.0.0.1".into(), port: 3000 } };
let config: AppConfig = Figment::from(Serialized::defaults(defaults))
.merge(Toml::file("config/default.toml"))
.merge(Env::prefixed("BOOKSHELF_").split("__")) // BOOKSHELF_SERVER__PORT -> server.port
.extract()?;
println!("{config:?}");
// envy: PORT=8080 LOG_JSON=true -> Flat { port: 8080, log_json: true }
match envy::from_env::<Flat>() {
Ok(flat) => println!("{flat:?}"),
Err(err) => println!("envy: {err}"),
}
Ok(())
}
Source: figment 0.10.19 and envy 0.4.2
Secrets
-
Locally: a
.envfile loaded by dotenvy, listed in.gitignore, with throwaway values. -
In CI: the CI system’s secret store, injected as environment variables.
-
In production: Kubernetes Secrets (
envFrom: secretRef), or the cloud secret manager through its CSI driver or SDK; rotate them without rebuilding the image. -
Never
COPY .envinto a Docker image or bake secrets intoENVlines: every layer of an image is readable by anyone who can pull it (see Deployment). -
Prefer files over environment variables for very sensitive values when the platform supports it (mounted secret files are not inherited by child processes or shown in
/proc/<pid>/environ); read the path from the environment.
Where the books are outdated
Yoon (Beginning Axum, chapter 6) loads settings with dotenvy in every environment and copies the .env file into the
Docker image, which puts the database password and JWT secret into the image layers. Use dotenvy only for local
development and inject secrets at runtime. Material that uses dotenv (without the y) refers to an unmaintained crate
that dotenvy replaced.