Back to sh0
sh0

119 plantillas de un clic: de WordPress a Ollama

Cómo construimos un sistema de plantillas basado en YAML con sustitución de variables, ordenamiento de dependencias y 119 plantillas listas para producción que cubren bases de datos, CMS, IA/ML y más.

Thales & Claude | March 30, 2026 11 min sh0
EN/ FR/ ES
templatesyamldeploymentdockerpaasone-clickself-hosted

Todo PaaS vive o muere por una sola pregunta: ¿qué tan rápido puede un nuevo usuario ir de "me registré" a "mi aplicación está funcionando"? Si la respuesta implica escribir un Dockerfile, configurar variables de entorno, montar una base de datos y cablear un proxy inverso -- ya lo has perdido. Cerrará la pestaña y levantará un VPS de $5 con un script de shell que encontró en Reddit.

Necesitábamos despliegues de un clic. No diez plantillas. No treinta. Ciento diecinueve, cubriendo todo desde WordPress hasta Ollama, desde PostgreSQL hasta ERPNext. Y cada plantilla tenía que manejar la complejidad que los usuarios nunca deberían ver: generación de variables, ordenamiento de dependencias, aprovisionamiento de volúmenes y redes de servicios.

Así es como construimos un motor de plantillas basado en YAML en Rust, incrustamos 119 plantillas directamente en el binario de sh0 y enviamos una tienda de aplicaciones de un clic en tres días.

El esquema de plantillas

La base fue un esquema YAML que pudiera expresar cualquier aplicación multi-servicio. Necesitábamos algo más simple que Docker Compose pero más potente que un archivo de configuración plano. El resultado fue un formato personalizado diseñado para despliegue de un clic:

yamlname: wordpress
description: WordPress with MySQL database
category: cms
tags: [wordpress, cms, blog, php]
icon: wordpress

variables:
  - name: MYSQL_ROOT_PASSWORD
    description: MySQL root password
    type: secret_32
  - name: MYSQL_DATABASE
    default: wordpress
  - name: WORDPRESS_TABLE_PREFIX
    default: wp_
    required: false

services:
  - name: wordpress
    image: wordpress:6-apache
    port: 80
    expose: true
    env:
      WORDPRESS_DB_HOST: mysql:3306
      WORDPRESS_DB_USER: root
      WORDPRESS_DB_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
      WORDPRESS_DB_NAME: "${MYSQL_DATABASE}"
      WORDPRESS_TABLE_PREFIX: "${WORDPRESS_TABLE_PREFIX}"
    depends_on: [mysql]
    volumes:
      - name: wp-data
        mount: /var/www/html

  - name: mysql
    image: mysql:8
    env:
      MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
      MYSQL_DATABASE: "${MYSQL_DATABASE}"
    volumes:
      - name: db-data
        mount: /var/lib/mysql

Las decisiones de diseño clave: las variables se declaran a nivel superior con tipos que impulsan la auto-generación. Los servicios referencian variables mediante marcadores ${VAR}. Un servicio se marca como expose: true -- ese es el que obtiene el dominio público y el enrutamiento Caddy. Todo lo demás es interno.

Parsing YAML con tipos seguros

Los tipos Rust para el esquema de plantillas usaron las macros derive de Serde con atención cuidadosa a campos opcionales y valores por defecto:

rust#[derive(Debug, Deserialize, Serialize)]
pub struct Template {
    pub name: String,
    pub description: String,
    pub category: String,
    pub tags: Vec<String>,
    pub icon: Option<String>,
    pub variables: Vec<VariableDef>,
    pub services: Vec<ServiceDef>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct VariableDef {
    pub name: String,
    pub description: Option<String>,
    #[serde(rename = "type", default)]
    pub var_type: Option<String>,
    pub default: Option<String>,
    #[serde(default = "default_true")]
    pub required: bool,
}

El campo var_type (renombrado desde type para evitar la colisión con la palabra reservada de Rust) controlaba la generación automática de valores. Cuando una variable tenía tipo secret_32, el motor generaba una cadena hexadecimal aleatoria de 32 bytes. Cuando tenía tipo password, generaba una contraseña alfanumérica de 16 caracteres. Cuando tenía tipo secret_64, un secreto de 64 bytes. Los usuarios nunca necesitaban inventar contraseñas para sus bases de datos -- el motor de plantillas lo hacía por ellos.

El motor de sustitución de variables

La sustitución de variables era más que un simple reemplazo de cadenas. El motor tenía que manejar tres capas de variables: valores incorporados (como APP_NAME y DOMAIN que solo se conocen en tiempo de despliegue), secretos auto-generados y sobreescrituras del usuario.

rustpub fn resolve_variables(
    template: &Template,
    app_name: &str,
    domain: &str,
    user_vars: &HashMap<String, String>,
) -> Result<HashMap<String, String>, Vec<String>> {
    let mut resolved = HashMap::new();
    let mut errors = Vec::new();

    // Variables incorporadas
    resolved.insert("APP_NAME".to_string(), app_name.to_string());
    resolved.insert("DOMAIN".to_string(), domain.to_string());

    for var in &template.variables {
        if let Some(value) = user_vars.get(&var.name) {
            resolved.insert(var.name.clone(), value.clone());
        } else if let Some(ref var_type) = var.var_type {
            match var_type.as_str() {
                "secret_32" => resolved.insert(var.name.clone(), generate_hex(32)),
                "secret_64" => resolved.insert(var.name.clone(), generate_hex(64)),
                "password"  => resolved.insert(var.name.clone(), generate_password(16)),
                _ => { errors.push(format!("Unknown type: {}", var_type)); None }
            };
        } else if let Some(ref default) = var.default {
            resolved.insert(var.name.clone(), default.clone());
        } else if var.required {
            errors.push(format!("Required variable '{}' not provided", var.name));
        }
    }

    if errors.is_empty() { Ok(resolved) } else { Err(errors) }
}

La cadena de prioridad fue deliberada: las sobreescrituras del usuario superan a la auto-generación, la auto-generación supera a los valores por defecto, los valores por defecto superan a nada. Si una variable requerida no tenía valor después de las tres pasadas, el despliegue se rechazaba con un mensaje de error claro listando cada variable faltante.

Validación: capturando errores antes de que lleguen a Docker

La validación de plantillas fue la red de seguridad entre un archivo YAML y un contenedor en ejecución. Implementamos 19 tests unitarios cubriendo cada regla de validación:

  • Debe existir al menos un servicio
  • Exactamente un servicio debe estar marcado como expose: true
  • Las referencias de imágenes deben ser formatos válidos de imagen Docker
  • Las referencias depends_on deben apuntar a servicios existentes
  • Las referencias de montaje de volúmenes deben corresponder a volúmenes declarados
  • Los nombres de variables deben coincidir con [A-Z_][A-Z0-9_]*
  • Sin dependencias circulares entre servicios

La verificación de dependencias circulares usó búsqueda en profundidad con un patrón de seguimiento visitado/en-pila. Sin ella, una plantilla con service-a depends_on service-b y service-b depends_on service-a haría que el motor de despliegue entrara en un bloqueo mutuo -- o peor, desplegara parcialmente y dejara contenedores rotos.

Ordenamiento topológico para secuencia de servicios

Al desplegar una plantilla de WordPress, el contenedor MySQL debe estar ejecutándose antes de que WordPress inicie. Al desplegar Plausible Analytics, PostgreSQL y ClickHouse deben estar ambos activos antes de que el contenedor de Plausible se lance. El motor de despliegue necesitaba un ordenamiento topológico que respetara el grafo de dependencias.

Implementamos el algoritmo de Kahn: comenzar con servicios que no tienen dependencias, desplegarlos, luego desplegar servicios cuyas dependencias están todas satisfechas, y repetir. El ordenamiento topológico producía una secuencia de despliegue que la tarea en segundo plano ejecutaba una capa a la vez: extraer imagen, crear contenedor, iniciar contenedor, esperar health check, luego proceder a la siguiente capa.

Para Plausible, el orden era: [postgres, clickhouse] (paralelo, sin dependencias), luego [plausible] (depende de ambos). Para Gitea: [postgres], luego [gitea]. El ordenamiento manejaba profundidad arbitraria -- una plantilla podía tener cinco capas de dependencias y el motor obtendría el orden correcto.

Incrustando 119 plantillas en el binario

Este fue uno de los patrones de Rust más elegantes que usamos. El crate include_dir nos permitió incrustar todo el directorio templates/ en el binario compilado en tiempo de compilación:

rustuse include_dir::{include_dir, Dir};

static TEMPLATES_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../templates");

pub fn list_templates() -> Vec<TemplateSummary> {
    TEMPLATES_DIR
        .files()
        .filter(|f| f.path().extension() == Some("yaml".as_ref()))
        .filter_map(|f| {
            let content = f.contents_utf8()?;
            let template: Template = serde_yaml::from_str(content).ok()?;
            Some(TemplateSummary {
                name: template.name,
                description: template.description,
                category: template.category,
                tags: template.tags,
                icon: template.icon,
            })
        })
        .collect()
}

Sin acceso al sistema de archivos. Sin directorio de configuración. Sin descarga de archivos de plantillas desde un registro. Cada plantilla fue compilada directamente en el binario de sh0. Cuando un usuario ejecutaba sh0 templates list, se parseaban los archivos YAML incrustados y se devolvían instantáneamente. Cuando ejecutaban sh0 templates deploy wordpress, el binario ya tenía la plantilla en memoria.

Esta decisión de diseño tenía un beneficio secundario: integridad de las plantillas. Los usuarios no podían corromper accidentalmente un archivo de plantilla. El binario era la fuente de verdad.

De 10 a 119: La expansión de plantillas

La sesión inicial de la Fase 16 produjo 10 plantillas: las esenciales (WordPress, Ghost, PostgreSQL, MySQL, Redis, MinIO, Gitea, Plausible, Umami, Uptime Kuma). Luego expandimos en lotes.

El inventario final de 119 plantillas abarcó nueve categorías:

CategoríaCantidadEjemplos
CMS y E-Commerce16WordPress, Ghost, Strapi, Directus, Payload, WooCommerce, PrestaShop, Medusa
Bases de datos6PostgreSQL, MySQL, Redis, MongoDB, MariaDB, ClickHouse
Analítica6Plausible, Umami, PostHog, Matomo, Grafana, Prometheus
Auth e Identidad6Keycloak, Authentik, Logto, SuperTokens, Authelia, Zitadel
IA y ML7Ollama, Open WebUI, Dify, Flowise, Langfuse, LocalAI, AnythingLLM
DevTools12Gitea, Forgejo, Jenkins, SonarQube, Vault, Verdaccio, Nexus
Comunicación5Rocket.Chat, Mattermost, Chatwoot, Listmonk, Cal.com
Productividad8Nextcloud, Plane, NocoDB, Baserow, Outline, BookStack, Vikunja
Infraestructura53Colas, búsqueda, redes, medios, finanzas, educación, foros

Cada plantilla fue validada por un test de integración que parseaba cada archivo YAML, verificaba la estructura, comprobaba las convenciones de nomenclatura de variables, confirmaba que los servicios de base de datos tuvieran volúmenes persistentes y validaba los tipos de auto-generación. El test se ejecutaba contra las 119 plantillas en una sola pasada:

rust#[test]
fn all_templates_parse_and_validate() {
    let templates = list_templates();
    assert_eq!(templates.len(), 119);
    for summary in &templates {
        let template = get_template(&summary.name)
            .expect(&format!("Template '{}' should load", summary.name));
        let errors = validate_template(&template);
        assert!(errors.is_empty(),
            "Template '{}' has validation errors: {:?}",
            summary.name, errors);
    }
}

Si una sola plantilla tenía un nombre de variable mal formado, una declaración de volumen faltante o una dependencia circular, toda la suite de tests lo capturaría antes de que el código pudiera compilarse en un binario de release.

El panel: Una tienda de plantillas

El panel presentaba las plantillas como una tienda navegable con pestañas de filtro por categoría (Todo, CMS, Base de datos, Analítica, DevTools, Monitoreo, Almacenamiento, IA/ML) y un campo de búsqueda que filtraba por nombre, descripción y etiquetas.

Cada tarjeta de plantilla mostraba el nombre, descripción, insignia de categoría y un botón "Desplegar". Al hacer clic en "Desplegar" se abría un modal con un campo de nombre de aplicación y un formulario generado dinámicamente para las variables de la plantilla. Las variables requeridas se marcaban con una insignia. Las variables auto-generadas mostraban una insignia "(auto)" y tenían valores pre-rellenados que los usuarios podían sobreescribir. Las variables opcionales tenían valores por defecto sensatos.

El modal de despliegue enviaba una sola llamada API a POST /api/v1/templates/:name/deploy. El backend validaba la plantilla, resolvía todas las variables, realizaba la sustitución, creaba el registro de la aplicación y lanzaba una tarea de despliegue en segundo plano. El usuario veía actualizaciones de estado en tiempo real conforme cada servicio era descargado, creado, iniciado y conectado a la red.

La experiencia CLI

Para usuarios de terminal, las plantillas eran igualmente accesibles:

bash# Listar todas las plantillas
sh0 templates list

# Obtener detalles de una plantilla
sh0 templates info plausible

# Desplegar con secretos auto-generados
sh0 templates deploy wordpress --app-name my-blog

# Desplegar con variables personalizadas
sh0 templates deploy ghost --app-name newsletter \
  --var MAIL_HOST=smtp.example.com \
  --var [email protected]

El comando sh0 templates info mostraba la descripción de la plantilla, todos los servicios con sus imágenes y cada variable con su tipo, valor por defecto y estado requerido. Era el equivalente de leer la documentación -- excepto que la documentación siempre estaba sincronizada con la plantilla real porque se generaba desde la misma fuente YAML.

Qué hizo que esto funcionara

Tres decisiones arquitectónicas hicieron el sistema de plantillas fiable a escala:

Plantillas incrustadas con inclusión en tiempo de compilación significaban cero dependencias en tiempo de ejecución del sistema de archivos. El binario era el formato de distribución para plantillas. Sin paso de descarga. Sin desajuste de versiones entre el motor y las plantillas.

Validación estricta antes del despliegue significaba que los errores se capturaban antes de que se hiciera una sola llamada a la API de Docker. Una plantilla con una referencia de dependencia incorrecta era rechazada con un mensaje claro, no descubierta después de que tres contenedores ya hubieran sido creados y necesitaran limpieza manual.

Ordenamiento topológico para el orden de despliegue significaba que las plantillas multi-servicio simplemente funcionaban. Los usuarios no necesitaban entender que ClickHouse tenía que iniciar antes que Plausible, o que PostgreSQL necesitaba estar sano antes de que Gitea pudiera conectarse. El motor manejaba la orquestación.

El resultado: 119 aplicaciones, desplegables en menos de un minuto cada una, con un solo clic o un solo comando CLI. Sin experiencia en Docker requerida. Sin YAML que escribir. Sin variables de entorno que investigar.


Siguiente en la serie: Docker Compose en un PaaS: parsing, validación y despliegue -- cómo añadimos soporte para archivos Docker Compose estándar, permitiendo a los usuarios traer su docker-compose.yml existente y desplegarlo en sh0 sin modificación.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles