Configuración en Rust con config y figment: múltiples fuentes, entornos y validación

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 .env locales: con dotenvy para 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.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP