05. Configuration
📋 Jump to Takeaways🎁 Configuration is just reading environment variables. You don't need a config crate for that — but you do need a clean way to fail fast when something required is missing.
dotenvy
[dependencies]
dotenvy = "0.15"dotenvy loads a .env file into the process environment at startup. In production where env vars come from the platform, it's a no-op.
Config Struct
Define all configuration in one struct so it's visible in one place:
// src/config.rs
#[derive(Clone)]
pub struct Config {
pub database_url: String,
pub port: u16,
pub log_level: String,
}
impl Config {
pub fn from_env() -> Result<Self, String> {
Ok(Config {
database_url: require("DATABASE_URL")?,
port: require("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.map_err(|_| "PORT must be a number".to_string())?,
log_level: std::env::var("LOG_LEVEL")
.unwrap_or_else(|_| "info".to_string()),
})
}
}
fn require(key: &str) -> Result<String, String> {
std::env::var(key).map_err(|_| format!("missing required env var: {}", key))
}DATABASE_URL is required — the service shouldn't start without it. PORT and LOG_LEVEL have sensible defaults.
.env File
Create .env for local development:
DATABASE_URL=postgres://postgres:password@localhost:5432/urlshortener
PORT=3000
LOG_LEVEL=debugAdd .env to .gitignore — it contains local credentials. Commit .env.example with placeholder values instead.
Loading at Startup
// src/main.rs
use crate::config::Config;
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok(); // load .env if present, ignore if missing
let config = Config::from_env().unwrap_or_else(|e| {
eprintln!("configuration error: {}", e);
std::process::exit(1);
});
println!("starting on port {}", config.port);
// ...
}dotenv().ok() silently ignores a missing .env — in production you set env vars directly on the platform and there's no .env file. The service starts regardless.
Failing fast with process::exit(1) on bad config is intentional. A service that starts with missing config will fail later in a harder-to-diagnose way. Fail early, fail loudly.
Passing Config to Handlers
Don't use global state. Pass config through Axum's state system (covered in the Shared State lesson). For now, pass what you need to axum::serve:
let listener = TcpListener::bind(format!("0.0.0.0:{}", config.port))
.await
.unwrap();Key Takeaways
dotenvy::dotenv().ok()loads.envin dev; production uses platform env vars- Group all config in one struct with a
from_env()constructor - Required vars fail fast with a clear message; optional vars have defaults
- Never commit
.env— commit.env.examplewith placeholder values instead - Fail at startup on bad config, not later when the first request hits
🎁 Every request hits your handlers, but some concerns — logging, request IDs, CORS — should apply to all of them without touching each handler. Next up: middleware and how Axum's layer system composes them.