Back to sh0
sh0

Dompter Caddy comme reverse proxy programmatique

Comment nous avons transformé Caddy en reverse proxy entièrement programmatique piloté via son API Admin, avec SSL automatique, synchronisation des routes et récupération après crash.

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

Chaque PaaS a besoin d'un reverse proxy. C'est la porte d'entrée -- le composant qui accepte le trafic HTTP depuis Internet, termine le TLS, et route les requêtes vers le bon conteneur. Se tromper et rien ne fonctionne. Bien faire et les utilisateurs n'y pensent jamais. Nous devions bien faire en un seul après-midi.

Voici l'histoire de comment nous avons transformé Caddy d'un serveur web autonome en un reverse proxy entièrement programmatique piloté depuis Rust, et comment une cascade de cinq correctifs de fiabilité a transformé une intégration fragile en une couche de routage de qualité production.


Pourquoi Caddy plutôt que Nginx ou Traefik

La décision a pris environ dix minutes. Nous avions besoin de trois choses d'un reverse proxy : du HTTPS automatique via ACME, une API de configuration à l'exécution, et un minimum de charge opérationnelle pour un PaaS auto-hébergé.

Nginx était immédiatement éliminé. Il nécessite la génération de fichiers de configuration et un signal de rechargement pour chaque changement de route. Cela signifie du templating, des écritures de fichiers et des appels nginx -s reload -- plus le risque de générer une configuration invalide qui fait tomber tout le routage d'un coup. Pour une plateforme qui ajoute et supprime des routes à chaque déploiement, c'est fragile.

Traefik était un concurrent sérieux. Il a une API robuste et une intégration Docker native. Mais son modèle de configuration est complexe -- labels, middlewares, entrypoints, routers, services -- et le provider Docker veut posséder le cycle de vie des conteneurs. Nous gérions déjà les conteneurs nous-mêmes via sh0-docker. Superposer le provider Docker de Traefik aurait créé deux systèmes se disputant les mêmes conteneurs.

Caddy était le juste milieu. Son API Admin accepte une configuration JSON complète via POST /load, il gère le provisionnement de certificats ACME nativement, et il tourne comme un seul binaire statique. Pas de fichiers de configuration, pas de templating, pas de signaux de rechargement. Juste des appels HTTP.

L'architecture est propre :

Internet --> Caddy (:80/:443) --> Conteneurs Docker (172.18.0.x:port)
                  ^
          sh0-proxy pilote via l'API Admin (localhost:2019)

Gérer Caddy comme processus enfant

Caddy tourne comme processus enfant du serveur sh0. C'est un choix délibéré -- nous voulons un contrôle complet du cycle de vie sans dépendre de systemd ou d'un gestionnaire de processus externe.

La struct CaddyProcess dans process.rs gère le lancement, l'arrêt et la vérification de santé :

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

impl CaddyProcess {
    pub async fn start(&mut self) -> Result<()> {
        // Tuer tout Caddy résiduel d'une exécution précédente
        kill_stale_caddy().await;

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

        // Drainer stderr dans une tâche d'arrière-plan (critique -- voir Article 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 gracieux d'abord
            unsafe { libc::kill(child.id().unwrap() as i32, libc::SIGTERM); }
            // Attendre jusqu'à 5s, puis SIGKILL
            tokio::time::timeout(Duration::from_secs(5), child.wait()).await
                .unwrap_or_else(|_| { child.kill(); Ok(()) });
        }
        self.child = None;
        Ok(())
    }
}

La méthode ensure_running() est le battement de coeur. Elle vérifie si le processus enfant est toujours vivant et si l'API Admin répond aux pings. Si l'une ou l'autre des vérifications échoue, elle redémarre Caddy et retourne un booléen indiquant qu'un redémarrage a eu lieu -- un signal que l'état des routes doit être ré-appliqué.


Le client de l'API Admin

Le CaddyClient dans caddy.rs enveloppe l'API Admin de Caddy avec des méthodes Rust typées. L'opération centrale est load_config -- envoyer une configuration JSON complète à 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() => {
                    // Mauvaise config -- échouer immédiatement, pas de retry
                    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 retry {}/{} dans {:?}",
                        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 logique de retry avec backoff exponentiel (500 ms, 1 s, 2 s) a été ajoutée après avoir découvert que Caddy retourne occasionnellement des erreurs 5xx pendant les transitions de routes, surtout juste après un redémarrage. La distinction critique : les erreurs 4xx (mauvaise configuration) échouent immédiatement -- réessayer une configuration malformée est inutile et ne ferait que retarder le message d'erreur.


État des routes basé sur RwLock

Le ProxyManager est l'orchestrateur. Il maintient l'ensemble canonique des routes en mémoire, protégé par un RwLock, et reconstruit la configuration Caddy complète à chaque changement :

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
    }
}

Nous avons choisi de reconstruire toute la configuration Caddy à chaque changement de route plutôt que d'utiliser les endpoints PATCH granulaires de Caddy. C'est un compromis conscient : les reconstructions complètes sont légèrement moins efficaces mais dramatiquement plus simples à raisonner. L'ensemble complet des routes est toujours la source de vérité, et Caddy reçoit toujours une configuration cohérente et complète. Avec des dizaines de routes (pas des milliers), le surcoût est négligeable.

Le RwLock permet les lectures concurrentes (pour les health checks, les requêtes de statut) tout en garantissant un accès exclusif pendant les écritures. C'est important parce que les déploiements peuvent se produire en parallèle -- deux utilisateurs déployant des applications différentes en même temps ne devraient pas corrompre l'état des routes de l'autre.


Le moniteur de santé en arrière-plan

Une tâche tokio en arrière-plan s'exécute toutes les cinq secondes, vérifiant la santé de Caddy et récupérant automatiquement des crashs :

rust// Dans main.rs -- lancé après le démarrage de Caddy
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 a été redémarré -- routes ré-appliquées");
            }
            Err(e) => {
                tracing::error!("Vérification de santé Caddy échouée : {}", e);
            }
            _ => {} // sain, rien à faire
        }
    }
});

Quand ensure_running() détecte que Caddy est mort ou ne répond plus, il tue le processus, en démarre un nouveau, attend 500 ms pour l'initialisation, puis reconstruit et ré-applique la configuration complète depuis l'état des routes en mémoire. Du point de vue de l'utilisateur, il y a un bref hoquet puis tout refonctionne.


La cascade de cinq correctifs de fiabilité

L'intégration initiale de Caddy fonctionnait dans le cas nominal mais s'effondrait dans les conditions réelles. Au cours du durcissement en production, nous avons identifié et corrigé cinq modes de défaillance distincts. Chaque correctif adressait un scénario spécifique, et ensemble ils forment une couche de fiabilité complète.

Correctif 1 : tuer le Caddy résiduel au démarrage

Le premier crash en production nous a enseigné une leçon embarrassante : si sh0 plante et redémarre, l'ancien processus Caddy tourne toujours, occupant le port 2019. La nouvelle instance de Caddy ne peut pas lier le port de l'API Admin, donc toutes les opérations de proxy échouent silencieusement.

Le correctif : avant de lancer un nouveau processus Caddy, kill_stale_caddy() s'exécute en haut de CaddyProcess::start(). Il tente un POST /stop gracieux sur l'API Admin avec un timeout de 2 secondes, se rabat sur pkill -f "caddy run" si l'API ne répond pas, et attend 500 ms pour que le port se libère. Si la connexion est refusée (pas de processus résiduel), il saute l'étape.

Correctif 2 : retry avec backoff exponentiel

L'API Admin de Caddy retourne occasionnellement des erreurs transitoires pendant les mises à jour de routes, surtout immédiatement après un redémarrage. La logique de retry décrite ci-dessus (3 tentatives, backoff 500 ms/1 s/2 s) gère cela gracieusement. L'idée clé : ne réessayer que sur les erreurs de connexion et les réponses 5xx. Une erreur 4xx signifie que la configuration est invalide, et réessayer ne la corrigera jamais.

Correctif 3 : synchroniser les routes depuis la base de données au démarrage

Quand sh0 redémarre, la carte des routes en mémoire est vide. Sans synchronisation des routes, chaque application en cours d'exécution devient injoignable même si les conteneurs tournent toujours. Au démarrage, avant de lancer le moniteur de santé, nous chargeons maintenant toutes les applications avec status == "running" depuis la base de données, inspectons leurs conteneurs Docker pour les IP sur le réseau sh0-net, construisons des structs AppRoute, et appelons proxy.sync_routes(). Les services existants sont joignables immédiatement.

Correctif 4 : ré-appliquer les routes après récupération de crash

Quand le moniteur de santé détecte un crash de Caddy et le redémarre, l'instance Caddy fraîche n'a pas de routes. La méthode ensure_running() retourne maintenant un booléen indiquant si un redémarrage a eu lieu. Si c'est le cas, le ProxyManager attend 500 ms pour que Caddy s'initialise, puis reconstruit la configuration complète depuis la carte des routes en mémoire et la ré-applique via load_config. La boucle de santé de cinq secondes devient un mécanisme d'auto-guérison.

Correctif 5 : erreurs douces sur les échecs de routage

Le dernier correctif était autant philosophique que technique. Quand un déploiement se termine avec succès -- le conteneur tourne et est sain -- mais que la mise à jour de la route Caddy échoue, devions-nous marquer le déploiement comme échoué ? Initialement, oui. Mais c'était trompeur : l'application tournait bien, elle n'était juste pas routée. Le correctif : les échecs de routage sont enregistrés comme erreurs mais le déploiement est marqué comme réussi. Le moniteur de santé finira par ré-appliquer les routes quand Caddy récupérera.


Leçons apprises

Construire une couche de proxy programmatique nous a enseigné trois choses :

Les reconstructions complètes de configuration battent les mises à jour incrémentales pour les systèmes de petite échelle. Avec moins d'une centaine de routes, la simplicité de « tout reconstruire et POST /load » l'emporte largement sur la complexité du suivi des patches de routes individuels. La configuration entière est toujours cohérente, et le débogage est trivial -- il suffit de logger le JSON envoyé à Caddy.

La gestion des processus enfants est plus difficile qu'il n'y paraît. Processus résiduels, deadlocks de buffer de pipe (une histoire pour le prochain article), conflits de ports et récupération après crash nécessitent tous une gestion explicite. Une approche « lancer et oublier » fonctionne jusqu'à ce qu'elle ne fonctionne plus, et quand elle échoue, elle échoue catastrophiquement.

L'auto-guérison bat l'alerte pour les composants d'infrastructure. Le moniteur de santé qui redémarre automatiquement Caddy et ré-applique les routes est plus précieux que n'importe quel tableau de bord de monitoring. Les utilisateurs ne se soucient pas que Caddy ait planté pendant trois secondes si leur application est de retour en ligne en huit.


Ce qui vient ensuite

La couche proxy était solide -- du moins le pensions-nous. Dans le prochain article, nous parcourrons le pipeline de déploiement en huit étapes qui lie le clonage git, les builds Docker, les health checks et les swaps de conteneurs bleu-vert en une seule opération atomique. Et après cela, nous vous raconterons l'histoire du bug de 16 Ko qui a failli nous faire abandonner Caddy entièrement.

Ceci est la Partie 5 de la série « Comment nous avons construit sh0.dev ». sh0 est une plateforme PaaS construite entièrement par un CEO à Abidjan et un CTO IA, avec zéro ingénieur humain.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles