Una aplicación lista para producción necesita configuración flexible: valores distintos para desarrollo, staging y producción, variables de entorno para secretos y ficheros TOML o YAML para la configuración base. Manejar esto a mano con std::env::var() disperso por todo el código es propenso a errores y difÃcil de mantener.
Los crates config y figment ofrecen una solución estructurada: cargar configuración desde múltiples fuentes, combinarlas con prioridades y deserializarlas a structs tipadas con serde.
El crate config
[dependencies]
config = "0.14"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
Define la struct de configuración:
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct ConfigApp {
pub servidor: ConfigServidor,
pub base_datos: ConfigDB,
pub log: ConfigLog,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ConfigServidor {
pub host: String,
pub puerto: u16,
pub workers: Option<usize>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ConfigDB {
pub url: String,
pub max_conexiones: u32,
pub min_conexiones: u32,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ConfigLog {
pub nivel: String,
pub json: bool,
}
Fichero config/default.toml:
[servidor]
host = "0.0.0.0"
puerto = 3000
workers = 4
[base_datos]
url = "postgresql://localhost/mi_app_dev"
max_conexiones = 10
min_conexiones = 2
[log]
nivel = "info"
json = false
Fichero config/production.toml:
[servidor]
puerto = 8080
[log]
json = true
Cargar la configuración:
use config::{Config, ConfigError, File, Environment};
pub fn cargar_config() -> Result<ConfigApp, ConfigError> {
let entorno = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());
let config = Config::builder()
// Carga valores por defecto
.add_source(File::with_name("config/default"))
// Sobreescribe con valores del entorno
.add_source(File::with_name(&format!("config/{}", entorno)).required(false))
// Sobreescribe con variables de entorno (APP__SERVIDOR__PUERTO=8080)
.add_source(Environment::with_prefix("APP").separator("__"))
.build()?;
config.try_deserialize()
}
fn main() {
let config = cargar_config().expect("Error cargando configuración");
println!("Servidor: {}:{}", config.servidor.host, config.servidor.puerto);
}
Variables de entorno como fuente
El prefijo APP y el separador __ permiten usar variables de entorno para cualquier campo de la configuración:
# Sobreescribe servidor.puerto
APP__SERVIDOR__PUERTO=8080
# Sobreescribe base_datos.url
APP__BASE_DATOS__URL=postgresql://prod-server/mi_app
# Sobreescribe log.nivel
APP__LOG__NIVEL=debug
figment: otra aproximación más flexible
figment es una alternativa a config con una API diferente y más soporte para validación:
[dependencies]
figment = { version = "0.10", features = ["toml", "env"] }
serde = { version = "1", features = ["derive"] }
use figment::{Figment, providers::{Toml, Env, Format}};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Config {
puerto: u16,
db_url: String,
debug: bool,
}
let config: Config = Figment::new()
.merge(Toml::file("config.toml"))
.merge(Env::prefixed("APP_").split("_"))
.extract()?;
figment tiene mejor soporte para combinar fuentes con prioridad explÃcita y permite usar los metadatos de origen para diagnósticos (saber de qué fichero o variable vino cada valor).
Validación con el crate validator
Una vez cargada la configuración, puedes validarla con el crate validator:
[dependencies]
validator = { version = "0.18", features = ["derive"] }
use serde::Deserialize;
use validator::Validate;
#[derive(Debug, Deserialize, Validate)]
pub struct ConfigServidor {
#[validate(length(min = 1))]
pub host: String,
#[validate(range(min = 1024, max = 65535))]
pub puerto: u16,
#[validate(range(min = 1, max = 32))]
pub workers: Option<u8>,
}
#[derive(Debug, Deserialize, Validate)]
pub struct ConfigDB {
#[validate(url)]
pub url: String,
#[validate(range(min = 1, max = 100))]
pub max_conexiones: u32,
}
pub fn cargar_y_validar() -> Result<ConfigApp, Box<dyn std::error::Error>> {
let config = cargar_config()?;
config.servidor.validate()?;
config.base_datos.validate()?;
Ok(config)
}
Organizar la configuración en aplicaciones grandes
Un patrón común es tener la configuración como estado compartido en un Arc:
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub config: Arc<ConfigApp>,
pub db: sqlx::PgPool,
}
impl AppState {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let config = Arc::new(cargar_y_validar()?);
let db = sqlx::PgPool::connect(&config.base_datos.url)
.await?;
Ok(AppState { config, db })
}
}
// En Axum:
let estado = AppState::new().await?;
let app = Router::new()
.route("/", get(handler))
.with_state(estado);
Secrets: separar configuración de secretos
Los secretos (contraseñas, API keys, tokens JWT) nunca deben estar en ficheros de configuración que se commiten al repositorio. Los patrones habituales son:
- Variables de entorno: inyectadas en el proceso por el gestor de secretos (Vault, AWS Secrets Manager, etc.).
- Ficheros
.envlocales: condotenvypara desarrollo, nunca en producción. - Archivos de secretos montados: en Kubernetes, los Secrets se montan como ficheros que la app lee al arrancar.
[dependencies]
dotenvy = "0.15"
// En main.rs, antes de cargar la configuración:
dotenvy::dotenv().ok(); // Carga .env si existe, ignora si no
Con esta estructura de capas (default.toml ? entorno.toml ? variables de entorno), la configuración es predecible, testeable y segura. Los valores sensibles solo existen en variables de entorno o en gestores de secretos, nunca en el código.
