Skip to main content

Type-Safe Configuration Management in Rust

Created: February 17, 2026 CalmOps 8 min read

TL;DR: This guide covers implementing robust, type-safe configuration management in Rust applications. You’ll learn to use the config crate, validate settings at startup, handle environment-specific configs, and avoid runtime configuration errors through compile-time guarantees.


Introduction

Configuration management is critical for production applications. Unlike dynamically-typed languages where misconfigured values cause runtime errors, Rust can leverage its type system to catch configuration mistakes at compile time. This article explores patterns for type-safe configuration that scales from small CLI tools to complex microservices.


The Config Crate

The config crate provides a flexible configuration system with support for multiple sources.

Installation

[dependencies]
config = "0.14"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
thiserror = "1.0"

Basic Configuration Structure

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppConfig {
    pub server: ServerConfig,
    pub database: DatabaseConfig,
    pub logging: LoggingConfig,
    pub features: FeatureFlags,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
    pub host: String,
    pub port: u16,
    pub workers: usize,
    pub timeout_seconds: u64,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DatabaseConfig {
    pub url: String,
    pub max_connections: u32,
    pub min_connections: u32,
    pub connection_timeout: u64,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LoggingConfig {
    pub level: String,
    pub format: String,
    pub output: HashMap<String, String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct FeatureFlags {
    pub enable_cache: bool,
    pub enable_metrics: bool,
    pub enable_tracing: bool,
}

Loading Configuration

use config::{Config, Environment, File, FileFormat};
use std::path::PathBuf;

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("Configuration error: {0}")]
    Message(String),
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Parse error: {0}")]
    Parse(#[from] config::ConfigError),
}

pub fn load_config() -> Result<AppConfig, ConfigError> {
    let config_dir = PathBuf::from(std::env::var("CONFIG_DIR").unwrap_or_else(|_| ".".to_string()));
    
    let settings = Config::builder()
        .add_source(File::from(config_dir.join("default.toml")))
        .add_source(File::from(config_dir.join("local.toml")).required(false))
        .add_source(
            Environment::with_prefix("APP")
                .prefix_separator("_")
                .list_separator(",")
        )
        .build()?;
    
    let config: AppConfig = settings.try_deserialize()?;
    Ok(config)
}

Type-Safe Configuration with Validation

Add runtime validation to catch configuration errors early:

use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedConfig {
    pub server: ValidatedServerConfig,
    pub database: ValidatedDatabaseConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedServerConfig {
    pub host: String,
    pub port: u16,
    pub workers: NonZeroUsize,
    pub timeout: std::time::Duration,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedDatabaseConfig {
    pub url: Url,
    pub pool_size: NonZeroU32,
    pub connection_timeout: std::time::Duration,
}

#[derive(Debug, Error)]
pub enum ConfigValidationError {
    #[error("Invalid port: {0}. Must be between 1 and 65535")]
    InvalidPort(u16),
    #[error("Invalid database URL: {0}")]
    InvalidDatabaseUrl(String),
    #[error("Workers must be at least 1")]
    InvalidWorkerCount,
    #[error("Database URL must use postgres:// or mysql:// protocol")]
    InvalidProtocol,
}

impl ValidatedServerConfig {
    pub fn new(host: String, port: u16, workers: usize, timeout_secs: u64) 
        -> Result<Self, ConfigValidationError> 
    {
        if port == 0 || port > 65535 {
            return Err(ConfigValidationError::InvalidPort(port));
        }
        
        let workers = NonZeroUsize::new(workers)
            .ok_or(ConfigValidationError::InvalidWorkerCount)?;
        
        Ok(Self {
            host,
            port,
            workers,
            timeout: std::time::Duration::from_secs(timeout_secs),
        })
    }
}

impl ValidatedDatabaseConfig {
    pub fn new(url: String, pool_size: u32, timeout_secs: u64)
        -> Result<Self, ConfigValidationError> 
    {
        let url = Url::parse(&url)
            .map_err(|_| ConfigValidationError::InvalidDatabaseUrl(url.clone()))?;
        
        if url.scheme() != "postgres" && url.scheme() != "mysql" {
            return Err(ConfigValidationError::InvalidProtocol);
        }
        
        let pool_size = NonZeroU32::new(pool_size)
            .ok_or(ConfigValidationError::InvalidWorkerCount)?;
        
        Ok(Self {
            url,
            pool_size,
            connection_timeout: std::time::Duration::from_secs(timeout_secs),
        })
    }
}

Environment-Specific Configuration

Handle different environments with layered configuration:

use serde::{Deserialize, Serialize};
use std::env;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvironmentConfig {
    pub environment: Environment,
    pub debug: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Environment {
    Development,
    Staging,
    Production,
}

impl Environment {
    pub fn from_env() -> Self {
        match env::var("RUST_ENV").unwrap_or_default().as_str() {
            "production" | "prod" => Environment::Production,
            "staging" => Environment::Staging,
            _ => Environment::Development,
        }
    }
    
    pub fn is_production(&self) -> bool {
        matches!(self, Environment::Production)
    }
}

impl Default for Environment {
    fn default() -> Self {
        Environment::Development
    }
}

// Environment-aware configuration builder
pub struct ConfigBuilder {
    environment: Environment,
    overrides: Vec<(String, String)>,
}

impl ConfigBuilder {
    pub fn new() -> Self {
        Self {
            environment: Environment::from_env(),
            overrides: Vec::new(),
        }
    }
    
    pub fn environment(mut self, env: Environment) -> Self {
        self.environment = env;
        self
    }
    
    pub fn override_value(self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.overrides.push((key.into(), value.into()));
        self
    }
    
    pub fn load(self) -> Result<AppConfig, ConfigError> {
        let env_suffix = match self.environment {
            Environment::Development => "dev",
            Environment::Staging => "staging",
            Environment::Production => "prod",
        };
        
        let config_dir = PathBuf::from("config");
        
        let mut builder = Config::builder()
            .add_source(File::from(config_dir.join("default.toml")))
            .add_source(File::from(config_dir.join(format!("{}.toml", env_suffix))));
        
        // Add overrides (useful for testing)
        for (key, value) in self.overrides {
            builder = builder.set_override(&key, value)?;
        }
        
        let settings = builder.build()?;
        let config: AppConfig = settings.try_deserialize()?;
        
        // Validate based on environment
        if self.environment.is_production() {
            config.validate_for_production()?;
        }
        
        Ok(config)
    }
}

Secrets Management

Handle sensitive configuration values securely:

use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Secrets {
    pub database_password: Secret<String>,
    pub api_key: Secret<String>,
    pub jwt_secret: Secret<String>,
}

#[derive(Debug, Clone)]
pub struct Secret<T> {
    value: T,
    source: SecretSource,
}

#[derive(Debug, Clone, Copy)]
pub enum SecretSource {
    Environment,
    Vault,
    EncryptedFile,
}

impl<T> Secret<T> {
    pub fn new(value: T, source: SecretSource) -> Self {
        Self { value, source }
    }
    
    pub fn as_str(&self) -> &str
    where
        T: AsRef<str>,
    {
        self.value.as_ref()
    }
}

// Don't log secrets!
impl<T> std::fmt::Debug for Secret<T> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Secret")
            .field("source", &self.source)
            .finish()
    }
}

impl<'de, T> serde::Deserialize<'de> for Secret<T>
where
    T: serde::Deserialize<'de>,
{
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let value = T::deserialize(deserializer)?;
        Ok(Secret::new(value, SecretSource::Environment))
    }
}

impl<T> serde::Serialize for Secret<T>
where
    T: serde::Serialize,
{
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        // Never serialize the actual value in production
        serializer.serialize_str("<REDACTED>")
    }
}

// Loading secrets from environment
pub fn load_secrets() -> Result<Secrets, ConfigError> {
    Ok(Secrets {
        database_password: Secret::new(
            std::env::var("DATABASE_PASSWORD")
                .map_err(|_| ConfigError::Message("DATABASE_PASSWORD not set".into()))?,
            SecretSource::Environment,
        ),
        api_key: Secret::new(
            std::env::var("API_KEY")
                .map_err(|_| ConfigError::Message("API_KEY not set".into()))?,
            SecretSource::Environment,
        ),
        jwt_secret: Secret::new(
            std::env::var("JWT_SECRET")
                .map_err(|_| ConfigError::Message("JWT_SECRET not set".into()))?,
            SecretSource::Environment,
        ),
    })
}

Clap Integration for CLI Arguments

Combine with clap for CLI-driven configuration:

[dependencies]
clap = { version = "4.4", features = ["derive", "env"] }
use clap::{Parser, ValueEnum};
use serde::{Deserialize, Serialize};

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct CliArgs {
    /// Environment to run in (development, staging, production)
    #[arg(short, long, env, default_value = "development")]
    pub environment: Environment,
    
    /// Configuration file path
    #[arg(short, long, default_value = "config.toml")]
    pub config: PathBuf,
    
    /// Enable verbose logging
    #[arg(short, long, action = clap::ArgAction::Count)]
    pub verbose: u8,
    
    /// Database URL (overrides config file)
    #[arg(long, env)]
    pub database_url: Option<String>,
}

#[derive(Debug, Clone, ValueEnum)]
pub enum Environment {
    Development,
    Staging,
    Production,
}

impl Default for Environment {
    fn default() -> Self {
        Environment::Development
    }
}

// Merge CLI args with file config
pub fn merge_config(cli: &CliArgs, file_config: &AppConfig) -> Result<AppConfig, ConfigError> {
    let mut config = file_config.clone();
    
    // CLI args override file config
    if let Some(db_url) = &cli.database_url {
        config.database.url = db_url.clone();
    }
    
    // Set logging level based on verbose flag
    config.logging.level = match cli.verbose {
        0 => "info",
        1 => "debug",
        _ => "trace",
    }.to_string();
    
    Ok(config)
}

Configuration Testing

Test configuration with different environments:

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;
    use std::path::PathBuf;
    
    fn test_config() -> AppConfig {
        AppConfig {
            server: ServerConfig {
                host: "localhost".to_string(),
                port: 8080,
                workers: 4,
                timeout_seconds: 30,
            },
            database: DatabaseConfig {
                url: "postgres://localhost/test".to_string(),
                max_connections: 10,
                min_connections: 2,
                connection_timeout: 5,
            },
            logging: LoggingConfig {
                level: "debug".to_string(),
                format: "json".to_string(),
                output: HashMap::new(),
            },
            features: FeatureFlags::default(),
        }
    }
    
    #[test]
    fn test_default_config() {
        let config = test_config();
        assert_eq!(config.server.port, 8080);
        assert_eq!(config.server.workers, 4);
    }
    
    #[test]
    fn test_config_validation() {
        // Test port validation
        let result = ValidatedServerConfig::new(
            "localhost".to_string(),
            0,  // Invalid port
            4,
            30,
        );
        assert!(result.is_err());
        
        // Test valid config
        let result = ValidatedServerConfig::new(
            "localhost".to_string(),
            8080,
            4,
            30,
        );
        assert!(result.is_ok());
    }
    
    #[test]
    fn test_environment_parsing() {
        std::env::set_var("RUST_ENV", "production");
        let env = Environment::from_env();
        assert_eq!(env, Environment::Production);
        std::env::remove_var("RUST_ENV");
    }
}

Example: Complete Configuration Setup

Putting it all together:

use std::path::PathBuf;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("Configuration error: {0}")]
    Config(#[from] ConfigError),
    #[error("Validation error: {0}")]
    Validation(String),
}

pub type Result<T> = std::result::Result<T, AppError>;

pub fn initialize_app() -> Result<AppConfig> {
    // Load CLI arguments
    let cli = CliArgs::parse();
    
    // Determine config directory
    let config_dir = if cli.config.exists() {
        cli.config.parent().unwrap().into()
    } else {
        PathBuf::from("config")
    };
    
    // Build configuration
    let config = ConfigBuilder::new()
        .environment(cli.environment.into())
        .load()?;
    
    // Merge CLI overrides
    let config = merge_config(&cli, &config)?;
    
    // Log configuration (without secrets)
    tracing::info!(
        server.host = %config.server.host,
        server.port = config.server.port,
        workers = config.server.workers,
        "Configuration loaded"
    );
    
    Ok(config)
}

// Run the application
#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt::init();
    
    let config = initialize_app()?;
    
    // Start your application with the config
    run_server(config).await?;
    
    Ok(())
}

Best Practices

1. Use Strong Types

// ❌ Weak typing - easy to make mistakes
pub struct Config {
    pub timeout: u64,
    pub max_connections: u32,
}

// ✅ Strong typing - catches errors at compile time
pub struct Config {
    pub timeout: std::time::Duration,
    pub max_connections: NonZeroU32,
}

2. Validate Early

pub fn load_config() -> Result<Config, ConfigError> {
    let config = load_raw_config()?;
    
    // Validate immediately, fail fast
    config.validate()?;
    
    Ok(config)
}

3. Don’t Expose Secrets in Logs

impl Serialize for Config {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        let mut s = serializer.serialize_struct("Config", 3)?;
        s.serialize_field("host", &self.host)?;
        s.serialize_field("port", &self.port)?;
        s.serialize_field("api_key", &"<REDACTED>")?;  // Always redact
        s.end()
    }
}

4. Use Configuration Schemas

// config.schema.json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["server", "database"],
  "properties": {
    "server": {
      "type": "object",
      "required": ["host", "port"],
      "properties": {
        "host": { "type": "string" },
        "port": { "type": "integer", "minimum": 1, "maximum": 65535 }
      }
    }
  }
}

Configuration Comparison

Approach Pros Cons
Environment Variables Easy, 12-factor standard No validation, typos
config crate Multiple sources, validation Runtime errors possible
Strong Types + Validation Compile-time safety More setup required
Clap + Config CLI overrides, type-safe More code

Conclusion

Type-safe configuration in Rust catches errors at compile time and provides excellent developer experience. Key takeaways:

  1. Use the config crate for flexible configuration from multiple sources
  2. Create strong types that validate at construction
  3. Separate secrets from regular configuration
  4. Use environment variables following 12-factor app principles
  5. Test configuration with different environments

Invest in configuration early—it pays dividends as your application grows.


External Resources


Resources

Comments

Share this article

Scan to read on mobile