Back to sh0
sh0

Domar Caddy como reverse proxy programatico

Como convertimos Caddy en un reverse proxy completamente programatico gestionado via su API Admin, con SSL automatico, sincronizacion de rutas y recuperacion ante caidas.

Thales & Claude | March 30, 2026 10 min sh0
EN/ FR/ ES
caddyreverse-proxysslrustinfrastructuredevops

Todo PaaS necesita un reverse proxy. Es la puerta principal -- el componente que acepta trafico HTTP de internet, termina TLS y enruta las peticiones al contenedor correcto. Si falla, nada funciona. Si funciona bien, los usuarios nunca piensan en el. Necesitabamos hacerlo bien en una sola tarde.

Esta es la historia de como transformamos Caddy de un servidor web independiente en un reverse proxy completamente programatico gestionado enteramente desde Rust, y como una cascada de cinco correcciones de fiabilidad convirtio una integracion fragil en una capa de enrutamiento de produccion.


Por que Caddy sobre Nginx o Traefik

La decision tomo unos diez minutos. Necesitabamos tres cosas de un reverse proxy: HTTPS automatico via ACME, una API de configuracion en tiempo de ejecucion y minima sobrecarga operacional para un PaaS auto-hospedado.

Nginx quedo descartado inmediatamente. Requiere generacion de archivos de configuracion y una senal de recarga para cada cambio de ruta. Eso significa templates, escrituras de archivos y llamadas nginx -s reload -- mas el riesgo de generar una configuracion invalida que derrumbe todo el enrutamiento de golpe. Para una plataforma que anade y elimina rutas en cada despliegue, esto es fragil.

Traefik era un candidato serio. Tiene una API robusta e integracion nativa con Docker. Pero su modelo de configuracion es complejo -- labels, middlewares, entrypoints, routers, services -- y el proveedor Docker quiere poseer el ciclo de vida de los contenedores. Nosotros ya gestionabamos contenedores por nuestra cuenta a traves de sh0-docker. Superponer el proveedor Docker de Traefik encima habria creado dos sistemas peleando por los mismos contenedores.

Caddy dio en el clavo. Su API Admin acepta una configuracion JSON completa via POST /load, maneja el aprovisionamiento de certificados ACME de fabrica, y se ejecuta como un unico binario estatico. Sin archivos de configuracion, sin templates, sin senales de recarga. Solo llamadas HTTP.

La arquitectura es limpia:

Internet --> Caddy (:80/:443) --> Contenedores Docker (172.18.0.x:port)
                  ^
          sh0-proxy gestiona via API Admin (localhost:2019)

Gestionando Caddy como proceso hijo

Caddy se ejecuta como un proceso hijo del servidor sh0. Esta es una decision deliberada -- queremos control total del ciclo de vida sin depender de systemd ni ningun gestor de procesos externo.

La struct CaddyProcess en process.rs maneja el arranque, la detencion y la verificacion de salud:

rustpub struct CaddyProcess {
    child: Option<tokio::process::Child>,
    caddy_path: PathBuf,
}

impl CaddyProcess {
    pub async fn start(&mut self) -> Result<()> {
        // Matar cualquier Caddy residual de una ejecucion anterior
        kill_stale_caddy().await;

        let child = Command::new(&self.caddy_path)
            .args(["run", "--config", "-"])
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::piped())
            .spawn()?;

        // Drenar stderr en una tarea en segundo plano (critico -- ver Articulo 7)
        if let Some(stderr) = child.stderr.take() {
            tokio::spawn(async move {
                let reader = BufReader::new(stderr);
                let mut lines = reader.lines();
                while let Ok(Some(line)) = lines.next_line().await {
                    tracing::debug!(target: "caddy", "{}", line);
                }
            });
        }

        self.child = Some(child);
        Ok(())
    }

    pub async fn stop(&mut self) -> Result<()> {
        if let Some(ref child) = self.child {
            // SIGTERM graceful primero
            unsafe { libc::kill(child.id().unwrap() as i32, libc::SIGTERM); }
            // Esperar hasta 5s, luego SIGKILL
            tokio::time::timeout(Duration::from_secs(5), child.wait()).await
                .unwrap_or_else(|_| { child.kill(); Ok(()) });
        }
        self.child = None;
        Ok(())
    }
}

El metodo ensure_running() es el latido. Verifica si el proceso hijo sigue vivo y si la API Admin responde a pings. Si cualquiera de las verificaciones falla, reinicia Caddy y devuelve un booleano indicando que ocurrio un reinicio -- una senal de que el estado de rutas necesita ser re-aplicado.


El cliente de la API Admin

El CaddyClient en caddy.rs envuelve la API Admin de Caddy con metodos Rust tipados. La operacion central es load_config -- enviar una configuracion JSON completa a Caddy:

rustpub struct CaddyClient {
    client: reqwest::Client,
    admin_url: String,  // http://localhost:2019
}

impl CaddyClient {
    pub async fn load_config(&self, config: &CaddyConfig) -> Result<()> {
        let url = format!("{}/load", self.admin_url);
        let mut attempts = 0;
        let max_retries = 3;

        loop {
            match self.client.post(&url).json(config).send().await {
                Ok(resp) if resp.status().is_success() => return Ok(()),
                Ok(resp) if resp.status().is_client_error() => {
                    // Configuracion mala -- fallar inmediatamente, sin reintento
                    return Err(ProxyError::CaddyConfigError(resp.text().await?));
                }
                Ok(_) | Err(_) if attempts < max_retries => {
                    attempts += 1;
                    let delay = Duration::from_millis(500 * 2u64.pow(attempts - 1));
                    tracing::warn!("Caddy load_config reintento {}/{} en {:?}",
                        attempts, max_retries, delay);
                    tokio::time::sleep(delay).await;
                }
                Err(e) => return Err(e.into()),
                Ok(resp) => return Err(ProxyError::CaddyServerError(resp.status())),
            }
        }
    }

    pub async fn ping(&self) -> bool {
        self.client.get(format!("{}/reverse_proxy/upstreams", self.admin_url))
            .timeout(Duration::from_secs(2))
            .send().await
            .map(|r| r.status().is_success())
            .unwrap_or(false)
    }
}

La logica de reintentos con backoff exponencial (500ms, 1s, 2s) se anadio despues de descubrir que Caddy ocasionalmente devuelve errores 5xx durante transiciones de rutas, especialmente justo despues de un reinicio. La distincion critica: los errores 4xx (configuracion mala) fallan inmediatamente -- reintentar una configuracion malformada es inutil y solo retrasaria el mensaje de error.


Estado de rutas basado en RwLock

El ProxyManager es el orquestador. Mantiene el conjunto canonico de rutas en memoria, protegido por un RwLock, y reconstruye la configuracion completa de Caddy en cada cambio:

rustpub struct ProxyManager {
    process: Mutex<CaddyProcess>,
    client: CaddyClient,
    routes: RwLock<HashMap<String, AppRoute>>,
    custom_certs: RwLock<Vec<CustomCert>>,
    email: RwLock<String>,
    config: ProxyConfig,
}

impl ProxyManager {
    pub async fn set_app_route(&self, app_id: &str, route: AppRoute) -> Result<()> {
        {
            let mut routes = self.routes.write().await;
            routes.insert(app_id.to_string(), route);
        }
        self.rebuild_and_load().await
    }

    pub async fn remove_app_route(&self, app_id: &str) -> Result<()> {
        {
            let mut routes = self.routes.write().await;
            routes.remove(app_id);
        }
        self.rebuild_and_load().await
    }

    async fn rebuild_and_load(&self) -> Result<()> {
        let routes = self.routes.read().await;
        let certs = self.custom_certs.read().await;
        let email = self.email.read().await;
        let config = build_config_full(&routes, &certs, &email, &self.config);
        self.client.load_config(&config).await
    }
}

Elegimos reconstruir la configuracion completa de Caddy en cada cambio de ruta en lugar de usar los endpoints PATCH granulares de Caddy. Este es un compromiso consciente: las reconstrucciones completas son ligeramente menos eficientes pero dramaticamente mas simples de razonar. El conjunto completo de rutas es siempre la fuente de verdad, y Caddy siempre recibe una configuracion consistente y completa. Con docenas de rutas (no miles), la sobrecarga es despreciable.

El RwLock permite lecturas concurrentes (para health checks, consultas de estado) mientras asegura acceso exclusivo durante escrituras. Esto importa porque los despliegues pueden ocurrir concurrentemente -- dos usuarios desplegando diferentes apps al mismo tiempo no deberian corromper el estado de rutas del otro.


El monitor de salud en segundo plano

Una tarea tokio en segundo plano se ejecuta cada cinco segundos, verificando la salud de Caddy y auto-recuperandose de caidas:

rust// En main.rs -- iniciado despues de que Caddy arranca
let proxy_monitor = proxy.clone();
tokio::spawn(async move {
    let mut interval = tokio::time::interval(Duration::from_secs(5));
    loop {
        interval.tick().await;
        match proxy_monitor.ensure_running().await {
            Ok(restarted) if restarted => {
                tracing::warn!("Caddy fue reiniciado -- rutas re-aplicadas");
            }
            Err(e) => {
                tracing::error!("Verificacion de salud de Caddy fallo: {}", e);
            }
            _ => {} // saludable, nada que hacer
        }
    }
});

Cuando ensure_running() detecta que Caddy murio o dejo de responder, mata el proceso, inicia uno nuevo, espera 500ms para la inicializacion, luego reconstruye y re-aplica la configuracion completa desde el estado de rutas en memoria. Desde la perspectiva del usuario, hay un breve parpadeo y luego todo funciona de nuevo.


La cascada de cinco correcciones de fiabilidad

La integracion inicial con Caddy funcionaba en el camino feliz pero se desmoronaba bajo condiciones del mundo real. Durante el endurecimiento para produccion, identificamos y corregimos cinco modos de fallo distintos. Cada correccion abordaba un escenario especifico, y juntas forman una capa de fiabilidad integral.

Correccion 1: Matar Caddy residual al arrancar

La primera caida en produccion nos enseno una leccion vergonzosa: si sh0 se cae y reinicia, el proceso Caddy anterior sigue ejecutandose, ocupando el puerto 2019. La nueva instancia de Caddy no puede vincular el puerto de la API Admin, por lo que todas las operaciones de proxy fallan silenciosamente.

La correccion: antes de crear un nuevo proceso Caddy, kill_stale_caddy() se ejecuta al inicio de CaddyProcess::start(). Intenta un POST /stop graceful en la API Admin con un timeout de 2 segundos, recurre a pkill -f "caddy run" si la API no responde, y espera 500ms para que el puerto se libere. Si la conexion es rechazada (no hay proceso residual), se salta por completo.

Correccion 2: Reintentos con backoff exponencial

La API Admin de Caddy ocasionalmente devuelve errores transitorios durante actualizaciones de rutas, especialmente inmediatamente despues de un reinicio. La logica de reintentos descrita anteriormente (3 intentos, backoff 500ms/1s/2s) los maneja con gracia. La idea clave: solo reintentar en errores de conexion y respuestas 5xx. Un 4xx significa que la configuracion es invalida, y reintentar nunca lo arreglara.

Correccion 3: Sincronizar rutas desde la base de datos al arrancar

Cuando sh0 reinicia, el mapa de rutas en memoria esta vacio. Sin sincronizacion de rutas, cada app en ejecucion se vuelve inalcanzable aunque los contenedores sigan funcionando. Al arrancar, antes de iniciar el monitor de salud, ahora cargamos todas las apps con status == "running" desde la base de datos, inspeccionamos sus contenedores Docker buscando IPs en la red sh0-net, construimos structs AppRoute y llamamos a proxy.sync_routes(). Los servicios existentes son accesibles inmediatamente.

Correccion 4: Re-aplicar rutas tras recuperacion de caida

Cuando el monitor de salud detecta una caida de Caddy y lo reinicia, la instancia nueva de Caddy no tiene rutas. El metodo ensure_running() ahora devuelve un booleano indicando si ocurrio un reinicio. Si fue asi, el ProxyManager espera 500ms para que Caddy se inicialice, luego reconstruye la configuracion completa desde el mapa de rutas en memoria y la re-aplica via load_config. El bucle de salud de cinco segundos se convierte en un mecanismo de auto-reparacion.

Correccion 5: Errores suaves en fallos de enrutamiento

La ultima correccion fue tanto filosofica como tecnica. Cuando un despliegue se completa exitosamente -- el contenedor esta ejecutandose y saludable -- pero la actualizacion de ruta en Caddy falla, deberiamos marcar el despliegue como fallido? Inicialmente, lo haciamos. Pero esto era enganoso: la app estaba funcionando bien, solo no enrutada. La correccion: los fallos de ruta se registran como errores pero el despliegue se marca como exitoso. El monitor de salud eventualmente re-aplicara las rutas cuando Caddy se recupere.


Lecciones aprendidas

Construir una capa de proxy programatica nos enseno tres cosas:

Las reconstrucciones completas de configuracion superan a las actualizaciones incrementales para sistemas a pequena escala. Con menos de cien rutas, la simplicidad de "reconstruir todo y POST /load" supera con creces la complejidad de rastrear parches de rutas individuales. La configuracion completa siempre es consistente, y la depuracion es trivial -- solo registra el JSON que enviaste a Caddy.

La gestion de procesos hijo es mas dificil de lo que parece. Procesos residuales, deadlocks de buffer de pipe (una historia para el siguiente articulo), conflictos de puertos y recuperacion ante caidas necesitan manejo explicito. Un enfoque de "crear y olvidar" funciona hasta que no, y cuando falla, falla catastroficamente.

La auto-reparacion supera a las alertas para componentes de infraestructura. El monitor de salud que auto-reinicia Caddy y re-aplica rutas es mas valioso que cualquier dashboard de monitorizacion. A los usuarios no les importa que Caddy se haya caido tres segundos si su app vuelve a estar en linea en ocho.


Lo que viene despues

La capa de proxy era solida -- o eso creiamos. En el siguiente articulo, recorreremos el pipeline de despliegue de ocho pasos que une la clonacion git, los builds Docker, los health checks y los swaps de contenedores bleu-vert en una sola operacion atomica. Y despues de eso, te contaremos la historia del bug de 16KB que casi nos hizo abandonar Caddy por completo.

Esta es la Parte 5 de la serie "Como construimos sh0.dev". sh0 es una plataforma PaaS construida enteramente por un CEO en Abidjan y un CTO de IA, sin ningun ingeniero humano.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles