Back to sh0
sh0

SSL automatico: DNS, ACME y certificados personalizados

Como sh0 gestiona certificados SSL automaticamente via la integracion ACME de Caddy, soporta subidas de certificados personalizados con claves privadas cifradas con AES-256-GCM, y configura DNS para despliegues auto-hospedados.

Thales & Claude | March 30, 2026 14 min sh0
EN/ FR/ ES
sslacmednscaddycertificatessecuritylets-encrypt

En 2026, no hay excusa para servir trafico sobre HTTP plano. Todo PaaS debe manejar certificados SSL automaticamente -- los usuarios no deberian tener que pensar en TLS en absoluto. Pero "automatico" cubre un espectro. En un extremo, tienes integracion ACME basica que aprovisiona certificados de Let's Encrypt. En el otro extremo, tienes clientes empresariales que traen sus propios certificados, firmados por sus propias autoridades certificadoras, con claves privadas que deben estar cifradas en reposo.

sh0 maneja el espectro completo. Esta es la historia de como construimos la gestion de certificados SSL que abarca desde ACME de configuracion cero hasta subidas de certificados personalizados con almacenamiento de claves privadas cifrado con AES-256-GCM -- todo orquestado a traves del mismo reverse proxy Caddy que domamos en el Articulo 5.


Caddy y ACME: el camino feliz

La funcionalidad estrella de Caddy es HTTPS automatico. Cuando Caddy recibe una ruta para myapp.example.com, automaticamente:

  1. Escucha en el puerto 80 para el desafio ACME HTTP-01
  2. Solicita un certificado de Let's Encrypt (o ZeroSSL, su CA por defecto)
  3. Instala el certificado y comienza a servir HTTPS en el puerto 443
  4. Renueva el certificado automaticamente antes de su vencimiento

Para sh0, esto significa que la experiencia SSL por defecto requiere cero configuracion del usuario. Despliega una app, apunta tu DNS al servidor, y HTTPS funciona. Lo unico que Caddy necesita de nosotros es una direccion de correo ACME para notificaciones de vencimiento de certificados.

La configuracion de Caddy que generamos incluye el emisor ACME:

rustfn build_tls_automation(email: &str) -> CaddyTlsAutomation {
    CaddyTlsAutomation {
        policies: vec![CaddyTlsPolicy {
            issuers: vec![CaddyIssuer {
                module: "acme".to_string(),
                email: email.to_string(),
            }],
            subjects: None,  // aplica a todos los dominios por defecto
        }],
    }
}

Este unico bloque de configuracion habilita HTTPS automatico para cada dominio enrutado a traves de Caddy. Sin gestion de certificados por dominio, sin tareas cron de renovacion, sin alertas de vencimiento. Caddy lo maneja todo.


Configuracion del correo ACME en tiempo de ejecucion

El correo ACME no es solo algo agradable de tener -- Let's Encrypt lo usa para enviar notificaciones criticas sobre problemas con certificados. Lo hicimos configurable en tiempo de ejecucion a traves del dashboard, no solo al arrancar.

La implementacion abarca todo el stack:

Backend: Un endpoint POST /settings/acme-email almacena el correo en la base de datos (tabla settings, clave acme_email) y actualiza el ProxyManager en tiempo de ejecucion:

rustpub async fn set_acme_email(
    State(state): State<AppState>,
    Json(req): Json<SetAcmeEmailRequest>,
) -> Result<Json<ApiResponse>> {
    // Persistir en base de datos
    Setting::upsert(&state.pool, "acme_email", &req.email).await?;

    // Actualizar proxy en tiempo de ejecucion -- reconstruye y recarga config de Caddy
    state.proxy.set_email(&req.email).await?;

    Ok(Json(ApiResponse::success("ACME email updated")))
}

ProxyManager: El campo email esta envuelto en un RwLock<String>, permitiendo actualizaciones en tiempo de ejecucion sin reiniciar el servidor:

rustpub async fn set_email(&self, email: &str) -> Result<()> {
    {
        let mut current = self.email.write().await;
        *current = email.to_string();
    }
    // Reconstruir y recargar la config de Caddy con el nuevo correo
    self.rebuild_and_load().await
}

Arranque: La funcion principal carga el correo ACME desde la base de datos si el flag CLI --acme-email esta vacio, usando la configuracion de la base de datos como respaldo. Esto significa que el correo sobrevive a reinicios del servidor sin requerir reconfiguracion por linea de comandos.


Configuracion DNS para despliegues auto-hospedados

sh0 es software auto-hospedado. Los usuarios lo ejecutan en sus propios servidores, con sus propios dominios. La configuracion DNS es responsabilidad del usuario, pero les guiamos a traves del proceso.

El panel de gestion de dominios del dashboard muestra la direccion IP real del servidor (obtenida de la API /settings, no codificada) y proporciona instrucciones claras:

  1. Crear un registro A apuntando el dominio a la IP del servidor
  2. Esperar la propagacion DNS
  3. Caddy se encarga del resto (desafio ACME, aprovisionamiento de certificados, HTTPS)

Eliminamos deliberadamente dos elementos de versiones anteriores de este panel:

Sin fila CNAME. Los registros CNAME son relevantes para sh0 Cloud (una futura oferta gestionada) donde los usuarios apuntan a un hostname de balanceador de carga. Para despliegues auto-hospedados, un registro A apuntando directamente a la IP del servidor es mas simple y confiable.

Sin boton "Verificar DNS". Inicialmente construimos una funcionalidad de verificacion DNS, pero la eliminamos. El proceso ACME de Caddy es en si mismo la verificacion: si el DNS esta configurado correctamente, el certificado se aprovisiona automaticamente. Si el DNS esta mal, el desafio ACME falla y Caddy registra el error. Un boton de verificacion manual anadio complejidad sin anadir valor.


El problema de Cloudflare

Un porcentaje significativo de usuarios de sh0 usan Cloudflare para DNS. Esto crea un desafio de configuracion sutil que abordamos directamente en el dashboard.

Cuando el proxy de Cloudflare esta activado (el icono de nube naranja), el trafico fluye a traves de la red edge de Cloudflare antes de llegar al servidor origen. Cloudflare termina TLS en el edge y establece una nueva conexion TLS al origen. Esto significa que hay dos configuraciones TLS diferentes en juego:

Modo DNS Only (nube gris): El trafico va directamente al servidor. Caddy maneja TLS de extremo a extremo via ACME. Este es el caso simple -- todo funciona de fabrica.

Modo Proxied (nube naranja): Cloudflare termina TLS en el edge. El servidor origen (Caddy) debe tener un certificado valido que Cloudflare pueda verificar. La configuracion SSL/TLS de Cloudflare debe ser "Full (Strict)" para asegurar cifrado de extremo a extremo.

Anadimos orientacion especifica de Cloudflare al modal de configuracion DNS:

Usas Cloudflare? Para SSL directo de Caddy, usa "DNS only" (icono de nube gris). Si prefieres el proxy de Cloudflare (nube naranja), configura tu modo SSL/TLS a "Full (Strict)" en el dashboard de Cloudflare.

Este unico parrafo previno una categoria de solicitudes de soporte que anticipamos por experiencia: usuarios habilitando el proxy de Cloudflare con SSL en "Flexible", lo que causa bucles de redireccion y errores de contenido mixto.


Certificados SSL personalizados: la ruta empresarial

El ACME automatico cubre el 90% de los casos de uso. Pero los clientes empresariales a menudo tienen requisitos que ACME no puede satisfacer:

  • Autoridades certificadoras internas con certificados que no son de confianza publica
  • Certificados de Validacion Extendida (EV) requeridos por politicas de cumplimiento
  • Certificados wildcard que cubren toda una jerarquia de dominios
  • Certificados que deben ser emitidos por una CA especifica (p. ej., DigiCert, Sectigo)

sh0 soporta todos estos a traves de subidas de certificados personalizados.

El modelo de datos

El esquema de base de datos introduce dos nuevas tablas:

sqlCREATE TABLE certificates (
    id TEXT PRIMARY KEY,
    app_id TEXT NOT NULL REFERENCES apps(id),
    common_name TEXT NOT NULL,
    issuer_cn TEXT,
    san_domains TEXT NOT NULL,      -- Array JSON
    fingerprint TEXT NOT NULL UNIQUE,
    not_before TIMESTAMP NOT NULL,
    not_after TIMESTAMP NOT NULL,
    cert_pem TEXT NOT NULL,
    key_encrypted TEXT NOT NULL,     -- Cifrado AES-256-GCM
    key_nonce TEXT NOT NULL,
    csr_pem TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE domain_certificates (
    domain_id TEXT REFERENCES domains(id) ON DELETE CASCADE,
    certificate_id TEXT REFERENCES certificates(id) ON DELETE CASCADE,
    PRIMARY KEY (domain_id, certificate_id)
);

La tabla domains gana dos nuevas columnas: ssl_mode (ya sea auto para ACME o custom para certificados subidos) y certificate_id (una clave foranea a la tabla de certificados).

Generacion de CSR

Para usuarios que necesitan que su CA emita un certificado, sh0 genera el Certificate Signing Request (CSR) en el lado del servidor usando el crate rcgen:

rustpub async fn generate_csr(
    State(state): State<AppState>,
    Path(app_id): Path<String>,
    Json(req): Json<GenerateCsrRequest>,
) -> Result<Json<CsrResponse>> {
    let mut params = CertificateParams::new(vec![req.domain.clone()]);
    params.distinguished_name.push(DnType::CommonName, &req.domain);
    params.distinguished_name.push(DnType::OrganizationName, &req.organization);
    params.distinguished_name.push(DnType::CountryName, &req.country);
    params.distinguished_name.push(DnType::StateOrProvinceName, &req.state);
    params.distinguished_name.push(DnType::LocalityName, &req.city);

    let key_pair = KeyPair::generate(&PKCS_ECDSA_P256_SHA256)?;
    let csr = params.serialize_request(&key_pair)?;

    // Cifrar y almacenar la clave privada
    let key_pem = key_pair.serialize_pem();
    let (encrypted, nonce) = encrypt(&key_pem.as_bytes(), &state.master_key)?;

    // Almacenar CSR + clave cifrada en base de datos
    let cert = Certificate::create_from_csr(
        &state.pool, &app_id, &req.domain,
        &csr.pem()?, &encrypted, &nonce,
    ).await?;

    Ok(Json(CsrResponse {
        id: cert.id,
        csr_pem: csr.pem()?,
    }))
}

El usuario descarga el CSR PEM, lo envia a su CA, recibe el certificado firmado, y lo sube de vuelta a sh0 para completar el flujo.

Subida y validacion de certificados

Cuando se sube un certificado (ya sea nuevo o completando un flujo CSR), el backend lo valida exhaustivamente usando el crate x509-parser:

  1. Parsear el certificado codificado en PEM
  2. Extraer el Common Name (CN) del sujeto y los Subject Alternative Names (SANs)
  3. Verificar que el certificado no esta expirado
  4. Calcular el fingerprint SHA-256 para deduplicacion
  5. Si se completa un flujo CSR, verificar que el certificado coincide con el CSR almacenado

El certificado validado y su clave privada cifrada se almacenan en la base de datos y se escriben como archivos PEM en el certs_dir para que Caddy los cargue.

Cifrado de claves privadas

Las claves privadas son las joyas de la corona de cualquier despliegue TLS. sh0 las cifra en reposo usando AES-256-GCM, el mismo esquema de cifrado autenticado usado para el cifrado de variables de entorno:

rust// Cifrar clave privada antes de almacenar en base de datos
let (encrypted, nonce) = sh0_auth::crypto::encrypt(
    key_pem.as_bytes(),
    &state.master_key,
)?;

// Escribir PEM descifrado a disco con permisos restringidos (para que Caddy lo lea)
let key_path = state.certs_dir.join(format!("{}.key", cert_id));
let mut file = File::create(&key_path)?;
file.write_all(&decrypted_key)?;

// Unix: restringir a lectura/escritura solo del propietario
#[cfg(unix)]
{
    use std::os::unix::fs::PermissionsExt;
    std::fs::set_permissions(&key_path, Permissions::from_mode(0o600))?;
}

La base de datos almacena solo la clave cifrada y su nonce. El archivo PEM descifrado en disco (que Caddy lee) tiene permisos 0o600 -- legible solo por el propietario del proceso sh0. Al reiniciar el servidor, una rutina de sincronizacion de arranque descifra las claves desde la base de datos y re-crea los archivos PEM para Caddy.


Cambio de modo SSL por dominio

Cada dominio en sh0 tiene un ssl_mode que determina como se maneja su certificado TLS:

  • auto (por defecto): La integracion ACME de Caddy aprovisiona y renueva certificados automaticamente
  • custom: Caddy carga el certificado y clave subidos desde disco

El constructor de configuracion de Caddy maneja ambos modos en un solo pase de generacion de configuracion:

rustpub fn build_config_full(
    routes: &HashMap<String, AppRoute>,
    custom_certs: &[CustomCert],
    email: &str,
    config: &ProxyConfig,
) -> CaddyConfig {
    let mut tls = CaddyTls::default();

    // Automatizacion ACME para todos los dominios en modo "auto"
    let mut auto_policy = CaddyTlsPolicy {
        issuers: vec![CaddyIssuer::acme(email)],
        subjects: None,
    };

    // Saltar ACME para dominios con certificados personalizados
    if !custom_certs.is_empty() {
        let custom_domains: Vec<String> = custom_certs
            .iter()
            .flat_map(|c| c.domains.clone())
            .collect();

        auto_policy.subjects = Some(
            all_domains.iter()
                .filter(|d| !custom_domains.contains(d))
                .cloned()
                .collect()
        );

        // Cargar certificados personalizados desde archivos
        tls.certificates = Some(CaddyCertificates {
            load_files: custom_certs.iter().map(|c| CaddyCertLoadFile {
                certificate: c.cert_path.to_string_lossy().to_string(),
                key: c.key_path.to_string_lossy().to_string(),
            }).collect(),
        });
    }

    tls.automation = Some(CaddyTlsAutomation {
        policies: vec![auto_policy],
    });

    // ... construir rutas y ensamblar configuracion completa
}

La idea clave es el salto de politica ACME por dominio. Cuando existen certificados personalizados, la politica de automatizacion ACME lista explicitamente solo los dominios que deben usar ACME, excluyendo aquellos con certificados personalizados. Esto previene que Caddy intente aprovisionar certificados ACME para dominios que ya tienen personalizados -- lo que fallaria y contaminaria los logs con errores.


Sincronizacion de certificados al arrancar

Cuando sh0 reinicia, el estado custom_certs en memoria esta vacio y los archivos PEM en disco pueden no existir. La rutina de arranque restaura todo desde la base de datos:

rust// En main.rs, despues de que el pool de base de datos y la clave maestra estan inicializados
let certs = Certificate::list_active(&pool).await?;
let mut custom_certs = Vec::new();

for cert in certs {
    // Descifrar clave privada desde la base de datos
    let key_pem = decrypt(&cert.key_encrypted, &cert.key_nonce, &master_key)?;

    // Escribir archivos PEM a disco para Caddy
    let cert_path = certs_dir.join(format!("{}.crt", cert.id));
    let key_path = certs_dir.join(format!("{}.key", cert.id));
    std::fs::write(&cert_path, &cert.cert_pem)?;
    write_with_permissions(&key_path, &key_pem, 0o600)?;

    custom_certs.push(CustomCert {
        domains: cert.san_domains(),
        cert_path,
        key_path,
    });
}

tracing::info!("Restaurados {} certificados personalizados desde base de datos", custom_certs.len());
proxy.set_custom_certs(custom_certs).await?;

Esto asegura que los dominios con certificados personalizados estan inmediatamente disponibles despues de un reinicio, sin intervencion manual.


Gestion del ciclo de vida de certificados

El dashboard muestra el estado de los certificados en el panel de gestion de dominios:

  • Cada dominio muestra su modo SSL: "Auto" (con un icono de candado) o "Custom" (con el nombre del emisor)
  • Las advertencias de vencimiento de certificados aparecen como insignias de colores: amarillo cuando quedan menos de 30 dias, rojo cuando ha expirado
  • Una seccion dedicada "Certificados SSL" lista todos los certificados de una app, con metadatos completos: common name, emisor, SANs, fingerprint, fechas de validez

La API proporciona seis endpoints de certificados:

EndpointProposito
POST /apps/:id/certificates/csrGenerar CSR + par de claves cifrado
POST /apps/:id/certificatesSubir certificado (nuevo o completar CSR)
GET /apps/:id/certificatesListar certificados de una app
GET /certificates/:idDetalle del certificado
DELETE /certificates/:idEliminar certificado, revertir dominios a ACME
PATCH /apps/:id/domains/:domain_id/sslCambiar modo SSL del dominio

Eliminar un certificado automaticamente revierte todos los dominios asociados al modo auto (ACME). Esta cascada asegura que ningun dominio quede en modo custom apuntando a un certificado que ya no existe.


Consideraciones de seguridad

La gestion de certificados SSL es critica para la seguridad. Aplicamos varias medidas de defensa en profundidad:

Cifrado en reposo. Las claves privadas se almacenan en la base de datos cifradas con AES-256-GCM. La clave maestra se deriva de la contrasena del administrador via Argon2. Incluso si la base de datos se compromete, las claves privadas no son legibles sin la clave maestra.

Permisos de archivo. Los archivos PEM en disco tienen permisos 0o600 (lectura/escritura solo del propietario). Caddy se ejecuta como el mismo usuario que sh0, por lo que puede leer los archivos, pero ningun otro usuario del sistema puede.

Sin clave privada en respuestas API. El endpoint de detalle de certificado devuelve metadatos (common name, emisor, fingerprint, fechas, SANs) pero nunca la clave privada. Una vez subida, la clave privada es de solo escritura desde la perspectiva de la API.

Deduplicacion por fingerprint. El fingerprint SHA-256 se almacena como campo unico, previniendo que el mismo certificado se suba dos veces.

Eliminacion en cascada. Cuando un certificado se elimina, la tabla de union domain_certificates propaga la eliminacion en cascada, y todos los dominios asociados revierten al modo ACME. Sin referencias huerfanas.


Lecciones aprendidas

Dejar que el reverse proxy maneje ACME. Implementar ACME directamente en Rust habria tomado semanas e introducido bugs sutiles (timing de desafios, limites de tasa, rotacion de claves). La implementacion ACME de Caddy esta probada en batalla y maneja casos extremos que nunca habriamos anticipado. Nuestro trabajo era configurarlo correctamente, no reimplementarlo.

Cifrar claves privadas incluso en el mismo servidor. Parece redundante -- la clave esta en el mismo disco que la version cifrada. Pero la defensa en profundidad importa. Una copia de seguridad de base de datos que se filtre (por permisos S3 mal configurados, un disco de respaldo robado, un accidente de logging) no expondra claves privadas si estan cifradas en reposo.

Guiar a los usuarios a traves del DNS, no automatizarlo. La configuracion DNS es lo unico que no podemos hacer por los usuarios auto-hospedados. En lugar de construir una verificacion DNS fragil que da falsos negativos (por retrasos de propagacion) y falsos positivos (por registros en cache), mostramos instrucciones claras y dejamos que el desafio ACME de Caddy sea la verificacion real.


Lo que viene despues

Con enrutamiento, despliegues y SSL en su lugar, sh0 tenia la infraestructura central de un PaaS de produccion. Los siguientes articulos de esta serie cubriran el sistema de autenticacion, el frontend del dashboard y la capa de monitorizacion que une todo. Mantente atento.

Esta es la Parte 8 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