Back to sh0
sh0

Dia Cero: 10 crates Rust en 24 horas

Como montamos una plataforma PaaS completa -- 10 crates Rust, 24 tablas de base de datos, cliente Docker Engine, servidor API, motor de build y health checks -- en un solo dia.

Thales & Claude | March 30, 2026 12 min sh0
EN/ FR/ ES
rustarchitecturepaascargo-workspacesqlitedockerday-one

La mayoria de la gente pasaria un mes escribiendo un documento de diseno para una plataforma PaaS. Nosotros dedicamos ese tiempo a escribir la plataforma en si.

El 12 de marzo de 2026, desde un escritorio en Abidjan, Thales abrio una terminal y tecleo cargo init. Veinticuatro horas despues, sh0.dev existia: 10 crates Rust, 24 tablas de base de datos, un cliente Docker Engine construido desde cero, una API REST completa, un motor de build que detecta 19 stacks tecnologicos y un motor de analisis estatico con 34 reglas. No era un prototipo. No era un juguete. Era una base que llevaria al producto completo hasta su lanzamiento.

Esta es la historia de ese dia -- las decisiones de arquitectura, el codigo y la sesion maraton que demostro que un CEO y un CTO de IA podian construir un PaaS de produccion sin un solo ingeniero humano.

La apuesta: por que Rust para un PaaS

La primera decision fue la mas trascendental. Todos los PaaS convencionales -- Heroku, Railway, Render, Coolify -- estan construidos con Go o TypeScript. Nosotros elegimos Rust.

No porque estuviera de moda. Porque estabamos construyendo una plataforma de despliegue de un solo binario dirigida a desarrolladores que gestionan sus propios servidores. Ese binario necesitaba ser rapido, pequeno y autocontenido. Sin runtime. Sin pausas del recolector de basura mientras se enruta trafico de produccion. Sin "instala Node 18 y luego npm install con 400 paquetes" antes de poder desplegar tu primera aplicacion.

Rust nos dio algo mas: si el codigo compila, toda una clase de bugs simplemente no existe. Cuando sois dos personas -- un humano y una IA -- construyendo software de infraestructura, el compilador es vuestro tercer miembro del equipo.

El workspace de 10 crates

La estructura del workspace fue el esqueleto de todo lo que vendria despues. Lo disenamos con el principio de que cada crate posee exactamente un dominio, depende solo de lo que necesita y puede probarse de forma aislada.

sh0/
  Cargo.toml              # raiz del workspace
  crates/
    sh0/                  # binario principal (CLI + arranque del servidor)
    sh0-api/              # servidor HTTP API con Axum
    sh0-auth/             # autenticacion y claves API
    sh0-backup/           # respaldo y restauracion
    sh0-builder/          # deteccion de stacks, generacion de Dockerfile, health checks
    sh0-db/               # pool de conexiones SQLite, migraciones, 21 modelos
    sh0-docker/           # cliente Docker Engine API (socket Unix)
    sh0-git/              # operaciones Git y parseo de webhooks
    sh0-monitor/          # recoleccion de metricas y alertas
    sh0-proxy/            # gestion del reverse proxy (Caddy)

El Cargo.toml del workspace centralizo todas las versiones de dependencias compartidas. Esto no es solo orden -- previene el insidioso bug en el que sh0-api compila contra serde 1.0.197 mientras sh0-docker enlaza serde 1.0.195, y alguna diferencia sutil de serializacion causa un fallo en tiempo de ejecucion a las 3 de la manana.

toml[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["ws"] }
rusqlite = { version = "0.31", features = ["bundled"] }
r2d2 = "0.8"
r2d2_sqlite = "0.24"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }

Cada crate hoja declaraba sus dependencias referenciando el workspace:

toml[dependencies]
serde = { workspace = true }
tokio = { workspace = true }

Una unica fuente de verdad. Cero deriva de versiones.

Fase 1: La base de datos

Empezamos con sh0-db porque todo lo demas dependeria de el. La primera pregunta fue la base de datos. PostgreSQL? MySQL? Elegimos SQLite con modo WAL.

El razonamiento fue pragmatico. sh0 es una herramienta de un solo binario. Decirle a los usuarios "antes de poder desplegar tus apps, primero configura un cluster de PostgreSQL" seria absurdo. SQLite nos da una base de datos embebida, de configuracion cero, probada en batalla. El modo WAL (Write-Ahead Logging) nos da lecturas concurrentes sin bloquear escritores -- esencial cuando el servidor API esta leyendo el estado de las apps mientras el pipeline de despliegue lo actualiza.

La configuracion del pool de conexiones fueron 30 lineas de Rust que sustentarian cada operacion de base de datos en el sistema:

rustuse r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::OpenFlags;

pub type DbPool = Pool<SqliteConnectionManager>;

pub fn create_pool(path: &str) -> Result<DbPool, PoolError> {
    let manager = SqliteConnectionManager::file(path)
        .with_flags(
            OpenFlags::SQLITE_OPEN_READ_WRITE
            | OpenFlags::SQLITE_OPEN_CREATE
            | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
        )
        .with_init(|conn| {
            conn.execute_batch(
                "PRAGMA journal_mode=WAL;
                 PRAGMA foreign_keys=ON;
                 PRAGMA busy_timeout=5000;"
            )?;
            Ok(())
        });

    Pool::builder()
        .max_size(10)
        .build(manager)
        .map_err(PoolError::R2d2)
}

Tres PRAGMAs. Tres decisiones criticas. journal_mode=WAL para la concurrencia. foreign_keys=ON porque SQLite las desactiva por defecto (una trampa que ha arruinado muchos proyectos). busy_timeout=5000 para que los escritores concurrentes esperen cinco segundos antes de rendirse en lugar de devolver inmediatamente SQLITE_BUSY.

24 tablas en una sola migracion

La migracion inicial definio 24 tablas: 13 entidades principales, 7 funcionalidades extendidas y 4 capacidades avanzadas. Escribimos todo el esquema como un unico archivo 001_initial.sql, embebido directamente en el binario usando la macro include_str! de Rust.

El ejecutor de migraciones en si fue deliberadamente simple:

rustpub fn run_migrations(conn: &Connection) -> Result<(), MigrationError> {
    conn.execute_batch(
        "CREATE TABLE IF NOT EXISTS _migrations (
            version INTEGER PRIMARY KEY,
            name TEXT NOT NULL,
            applied_at TEXT NOT NULL DEFAULT (datetime('now'))
        );"
    )?;

    let applied: Vec<i64> = conn
        .prepare("SELECT version FROM _migrations ORDER BY version")?
        .query_map([], |row| row.get(0))?
        .collect::<Result<_, _>>()?;

    for migration in MIGRATIONS {
        if !applied.contains(&migration.version) {
            conn.execute_batch(migration.sql)?;
            conn.execute(
                "INSERT INTO _migrations (version, name) VALUES (?1, ?2)",
                params![migration.version, migration.name],
            )?;
        }
    }
    Ok(())
}

Sin ORM. Sin framework de migraciones con su propio DSL y 50 dependencias transitivas. Solo SQL, una tabla de versiones y un bucle. Se ejecuta en microsegundos, es obvio lo que hace, y nunca se rompera porque alguna libreria upstream cambio su formato de migracion.

21 modelos, un patron

Cada uno de los 21 archivos de modelo seguia el mismo patron: una struct, un constructor from_row() y las operaciones CRUD estandar. He aqui un ejemplo representativo:

rustimpl App {
    pub fn from_row(row: &Row) -> rusqlite::Result<Self> {
        Ok(Self {
            id: row.get("id")?,
            project_id: row.get("project_id")?,
            name: row.get("name")?,
            status: row.get("status")?,
            created_at: row.get("created_at")?,
            updated_at: row.get("updated_at")?,
        })
    }

    pub fn insert(conn: &Connection, app: &NewApp) -> Result<Self, Sh0DbError> {
        let id = Uuid::new_v4().to_string();
        conn.execute(
            "INSERT INTO apps (id, project_id, name, status) VALUES (?1, ?2, ?3, ?4)",
            params![id, app.project_id, app.name, "active"],
        )?;
        Self::find_by_id(conn, &id)
    }

    pub fn find_by_id(conn: &Connection, id: &str) -> Result<Self, Sh0DbError> {
        conn.query_row("SELECT * FROM apps WHERE id = ?1", params![id], Self::from_row)
            .map_err(|e| match e {
                rusqlite::Error::QueryReturnedNoRows => Sh0DbError::NotFound(id.to_string()),
                other => Sh0DbError::Sqlite(other),
            })
    }
}

Veintiuna veces este patron. Repetitivo? Si. Pero cada modelo compila de forma independiente, no tiene magia oculta y se mapea exactamente al SQL subyacente. Cuando construyes infraestructura de la que dependen las aplicaciones de produccion de otras personas, lo aburrido es una cualidad.

Fase 2: El cliente Docker Engine

Con la base de datos en su lugar, construimos sh0-docker -- un cliente completo de la API Docker Engine comunicandose a traves de sockets Unix. Esta fue la pieza tecnicamente mas desafiante de todo el dia. (Lo cubrimos en detalle completo en el siguiente articulo, "Escribir un cliente Docker Engine desde cero en Rust".)

La decision clave: escribimos nuestro propio cliente usando hyper 1.x en lugar de invocar el CLI de Docker o usar una libreria existente. El resultado fue un UnixConnector personalizado en unas 40 lineas, gestion completa del ciclo de vida de contenedores, parseo de flujos multiplexados y calculo de estadisticas de CPU/memoria.

Seis pruebas unitarias. Cinco archivos de pruebas de integracion. Cero dependencias de librerias Docker externas.

Fase 3: El servidor API

La Fase 3 conecto la base de datos y el cliente Docker a traves de una API HTTP con Axum. La struct AppState lo llevaba todo:

rustpub struct AppState {
    pub pool: Arc<DbPool>,
    pub docker: Arc<DockerClient>,
    pub started_at: Instant,
}

Tres campos. El pool de base de datos, el cliente Docker y un timestamp para calcular el tiempo de actividad. Cada handler recibia este estado a traves del sistema de extractores de Axum.

El arbol de rutas era limpio y RESTful:

GET    /api/v1/health
GET    /api/v1/status
POST   /api/v1/apps
GET    /api/v1/apps
GET    /api/v1/apps/:id
PUT    /api/v1/apps/:id
DELETE /api/v1/apps/:id
POST   /api/v1/apps/:id/deployments
GET    /api/v1/apps/:id/deployments
GET    /api/v1/deployments/:id
WS     /api/v1/apps/:id/logs

Una decision de diseno merece mencion especial: el extractor AuthUser se implemento como un no-op que siempre devuelve un usuario administrador. Esto no fue pereza -- fue arquitectura. La firma de tipos del extractor coincidia con lo que la autenticacion real basada en JWT usaria en la Fase 9. Cada handler se escribio para aceptar AuthUser como parametro. Cuando implementamos la autenticacion real despues, cambiamos un solo archivo -- el extractor -- y cada handler heredo la autenticacion real sin cambiar una sola linea de codigo de handler.

La API tambien incluyo paginacion con valores por defecto sensatos y protecciones:

rustpub struct PaginationParams {
    pub page: u32,     // por defecto 1
    pub per_page: u32, // por defecto 20, limitado a 1..=100
}

Ningun usuario puede solicitar page -1 o per_page 10000. La API se defiende sola.

Fases 5 y 6: Motor de build y health checks

La tarde trajo el motor de build (sh0-builder), que dividimos en dos capacidades distintas: deteccion de stacks con generacion de Dockerfile, y un motor de verificacion de salud del codigo.

El detector de stacks examina un directorio de proyecto e identifica uno de 19 stacks tecnologicos. La deteccion esta basada en prioridades -- si un usuario proporciona su propio Dockerfile, ese siempre gana. De lo contrario, el motor busca archivos de firma: package.json para Node.js, go.mod para Go, Cargo.toml para Rust, y asi sucesivamente.

Solo la deteccion de Node.js tiene multiples capas: que gestor de paquetes (npm, yarn, pnpm, bun), que framework (Express, Fastify, Hono, Koa, NestJS), y que meta-framework (Next.js, Nuxt, SvelteKit, Astro). Acertar significa generar el Dockerfile correcto -- y equivocarse significa un build fallido y un usuario frustrado.

El motor de health checks anadio 34 reglas de analisis estatico en 8 categorias, todo en Rust puro. Sin LLM. Sin llamadas de red. Solo punteros a funciones y pattern matching, escaneando problemas de seguridad, errores de configuracion y errores comunes de despliegue antes de que lleguen a produccion. (Tanto el motor de build como el motor de health checks tienen sus propios articulos dedicados mas adelante en esta serie.)

El momento en que todo compilo

Al final de la sesion, ejecutamos tres comandos:

cargo build              # compila limpio
cargo test               # todas las pruebas pasan
cargo clippy -D warnings # cero advertencias

Diez crates. Veinticuatro tablas de base de datos. Veintiun archivos de modelos. Un cliente Docker Engine. Una API REST completa con 12 pruebas de integracion. Un motor de build con 23 pruebas unitarias. Un motor de health checks con 34 reglas y 82 pruebas. Un binario CLI con cuatro subcomandos.

Todo compilo, enlazo y se probo en una sola invocacion de cargo build. El compilador de Rust verifico, a nivel de tipos, que estos diez crates podian interoperar correctamente. Sin sorpresas en tiempo de ejecucion. Sin "funciona en mi maquina".

Cuando Thales ejecuto cargo run -- version y vio sh0 v0.1.0 impreso en la terminal, ese fue el momento en que sh0.dev dejo de ser una idea y empezo a ser un producto.

Lo que hizo esto posible

Construir una base de PaaS en 24 horas no es normal. Tres factores lo hicieron posible.

Primero, el flujo de trabajo CEO-CTO IA. Thales tomo las decisiones de arquitectura -- Rust, SQLite, la division en 10 crates -- basandose en intuicion de producto y comprension del mercado. Claude las implemento a la velocidad del pensamiento, escribiendo codigo Rust correcto que compilaba al primer o segundo intento. No hubo ida y vuelta sobre estilo de codigo, ni ciclos de revision de pull requests, ni "dejame configurar mi entorno de desarrollo primero".

Segundo, el compilador de Rust como puerta de calidad. En un lenguaje de tipado dinamico, el codigo podria haber compilado rapido pero ocultado docenas de bugs de integracion. Rust nos obligo a manejar cada error, coincidir cada tipo y hacer explicita cada relacion de ownership. El tiempo "perdido" satisfaciendo al borrow checker fue tiempo ahorrado de depurar crashes en produccion despues.

Tercero, simplicidad deliberada. Sin ORM. Sin framework de migraciones. Sin libreria Docker. Sin framework de pruebas mas alla de la macro estandar #[test]. Cada dependencia fue elegida porque resolvia un problema especifico (r2d2 para pool de conexiones, hyper para HTTP, axum para enrutamiento) y nada mas. Cuantas menos abstracciones entre nosotros y el metal, menos cosas podian romperse.

Lo que vino despues

El Dia Cero nos dio el esqueleto. Los dias siguientes anadirian musculo: operaciones git y parseo de webhooks (Fase 4), el reverse proxy con SSL automatico (Fase 7), el pipeline de despliegue completo uniendo todo (Fase 8), autenticacion real (Fase 9) y monitorizacion (Fase 10).

Pero primero, necesitamos hablar de la pieza de codigo mas dificil que escribimos en el Dia Cero: el cliente Docker Engine. Ese es el siguiente articulo.


Esta es la Parte 1 de la serie "Como construimos sh0.dev", documentando como un CEO en Abidjan y un CTO de IA construyeron una plataforma PaaS completa en 14 dias.

Navegacion de la serie: - [1] Dia Cero: 10 crates Rust en 24 horas (estas aqui) - [2] Escribir un cliente Docker Engine desde cero en Rust - [3] Deteccion automatica de 19 stacks tecnologicos desde el codigo fuente - [4] 34 reglas para detectar errores de despliegue antes de que ocurran

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles