Un desarrollador sube su codigo. Treinta segundos despues, esta ejecutandose en un contenedor. No escribio un Dockerfile. No configuro un pipeline de build. No especifico que runtime, que gestor de paquetes ni que puerto exponer.
Esta es la promesa de un PaaS moderno, y hacerla realidad requiere resolver un problema enganosamente dificil: mirar un directorio de archivos fuente y descifrar que es realmente el proyecto.
El motor de build de sh0 maneja esto en Rust puro -- sin servicio de heuristicas, sin LLM, sin llamadas a APIs externas. Un algoritmo de deteccion basado en prioridades examina el proyecto, identifica el stack, genera un Dockerfile de produccion con builds multi-etapa y crea un contexto de build optimizado. Todo ocurre en milisegundos, y todo fue construido en el Dia Cero.
Los 19 stacks
El enum Stack define cada tecnologia que sh0 puede detectar y construir:
rustpub enum Stack {
Dockerfile, // Dockerfile proporcionado por el usuario (maxima prioridad)
NextJs,
Nuxt,
SvelteKit,
Astro,
NodeGeneric,
Bun,
Python,
Django,
FastApi,
Go,
Rust,
JavaMaven,
JavaGradle,
Php,
DotNet,
Ruby,
StaticSite,
Unknown, // Fallback (minima prioridad)
}Cada variante lleva metadatos a traves de metodos:
rustimpl Stack {
pub fn label(&self) -> &str {
match self {
Stack::NextJs => "Next.js",
Stack::FastApi => "FastAPI",
Stack::SvelteKit => "SvelteKit",
// ...
}
}
pub fn default_port(&self) -> u16 {
match self {
Stack::NextJs => 3000,
Stack::Django => 8000,
Stack::FastApi => 8000,
Stack::Go => 8080,
Stack::Rust => 8080,
Stack::StaticSite => 80,
// ...
}
}
pub fn needs_dockerfile(&self) -> bool {
matches!(self, Stack::Php | Stack::DotNet | Stack::Ruby | Stack::Unknown)
}
}El metodo needs_dockerfile() es honesto: para PHP, .NET y Ruby, las configuraciones de build son demasiado variadas para que una plantilla generica sea confiable. sh0 le dice al usuario "detecte tu stack, pero necesitas proporcionar un Dockerfile" en lugar de generar uno malo. La honestidad supera a la magia.
La regla de prioridad: el Dockerfile siempre gana
El algoritmo de deteccion tiene una regla inviolable: si existe un Dockerfile en la raiz del proyecto, ese es el stack. Punto.
rustpub fn detect(path: &Path) -> DetectedStack {
// Regla 1: El Dockerfile del usuario siempre tiene prioridad
if path.join("Dockerfile").exists() {
return DetectedStack {
stack: Stack::Dockerfile,
..Default::default()
};
}
// Regla 2: Detectar por archivos de firma
if path.join("package.json").exists() {
return detect_node(path);
}
if path.join("go.mod").exists() {
return detect_go(path);
}
if path.join("Cargo.toml").exists() {
return detect_rust(path);
}
// ... stacks restantes
DetectedStack {
stack: Stack::Unknown,
..Default::default()
}
}Esta es una decision de diseno deliberada. Un usuario que ha escrito su propio Dockerfile ha tomado una decision consciente sobre como debe construirse su aplicacion. Sobreescribir esa decision con auto-deteccion seria presuntuoso y, en muchos casos, incorrecto. Su Dockerfile podria tener dependencias de sistema personalizadas, flags de compilador especificos o una imagen base propietaria que ningun algoritmo de deteccion podria adivinar.
El orden de prioridad despues del Dockerfile se basa en la especificidad del archivo de firma. Verificamos package.json antes de buscar archivos Python porque un proyecto podria tener ambos (por ejemplo, un backend Python con un paso de build en JavaScript para assets). La coincidencia mas especifica gana.
Node.js: el desafio de la sub-deteccion
Node.js es con diferencia el objetivo de deteccion mas complejo. El ecosistema tiene cuatro gestores de paquetes principales, docenas de frameworks y varios meta-frameworks, cada uno requiriendo diferentes comandos de build y configuraciones de runtime.
La deteccion procede en capas:
rustfn detect_node(path: &Path) -> DetectedStack {
let pkg_json = read_package_json(path);
// Capa 1: Deteccion del gestor de paquetes
let package_manager = if path.join("bun.lockb").exists() {
PackageManager::Bun
} else if path.join("pnpm-lock.yaml").exists() {
PackageManager::Pnpm
} else if path.join("yarn.lock").exists() {
PackageManager::Yarn
} else {
PackageManager::Npm
};
// Capa 2: Deteccion de meta-frameworks (archivos de configuracion)
if path.join("next.config.js").exists()
|| path.join("next.config.mjs").exists()
|| path.join("next.config.ts").exists()
{
return DetectedStack {
stack: Stack::NextJs,
package_manager: Some(package_manager),
framework: Some("next".into()),
build_command: Some(build_cmd(&package_manager, "build")),
start_command: Some("node server.js".into()),
..Default::default()
};
}
if path.join("svelte.config.js").exists()
|| path.join("svelte.config.ts").exists()
{
return DetectedStack {
stack: Stack::SvelteKit,
package_manager: Some(package_manager),
framework: Some("sveltekit".into()),
build_command: Some(build_cmd(&package_manager, "build")),
start_command: Some("node build/index.js".into()),
..Default::default()
};
}
// Capa 3: Deteccion de framework desde dependencias
if let Some(ref pkg) = pkg_json {
if has_dependency(pkg, "express") {
return DetectedStack {
stack: Stack::NodeGeneric,
framework: Some("express".into()),
// ...
};
}
// fastify, hono, koa, nestjs...
}
// Fallback: Node.js generico
DetectedStack {
stack: Stack::NodeGeneric,
package_manager: Some(package_manager),
..Default::default()
}
}Observa el orden. Los meta-frameworks (Next.js, Nuxt, SvelteKit, Astro) se detectan por archivos de configuracion, no por dependencias en package.json. Esto importa porque los archivos de configuracion son inequivocos -- si next.config.js existe, esto es un proyecto Next.js -- mientras que la deteccion por dependencias es difusa (un proyecto podria importar express como sub-dependencia sin ser una aplicacion Express).
El helper build_cmd genera la invocacion correcta para cada gestor de paquetes:
rustfn build_cmd(pm: &PackageManager, script: &str) -> String {
match pm {
PackageManager::Npm => format!("npm run {}", script),
PackageManager::Yarn => format!("yarn {}", script),
PackageManager::Pnpm => format!("pnpm run {}", script),
PackageManager::Bun => format!("bun run {}", script),
}
}Funcion pequena. Pero equivocarse significa que pnpm run build falla porque alguien uso la sintaxis de yarn build.
Python: el truco del wsgi.py
La deteccion de Python tiene su propia complejidad. Un proyecto Python podria ser Django, FastAPI, Flask o algo completamente personalizado. Usamos heuristicas basadas en archivos:
rustfn detect_python(path: &Path) -> DetectedStack {
// Django: buscar wsgi.py o manage.py
if path.join("manage.py").exists() || find_wsgi(path) {
return DetectedStack {
stack: Stack::Django,
start_command: Some("gunicorn config.wsgi:application --bind 0.0.0.0:8000".into()),
..Default::default()
};
}
// FastAPI: buscar main.py/app.py con import de fastapi
if detect_fastapi_entry(path) {
return DetectedStack {
stack: Stack::FastApi,
start_command: Some("uvicorn main:app --host 0.0.0.0 --port 8000".into()),
..Default::default()
};
}
// Python generico
DetectedStack {
stack: Stack::Python,
..Default::default()
}
}La deteccion de Django via wsgi.py es robusta porque todo proyecto Django tiene uno, y los proyectos que no son Django casi nunca lo tienen. Es una senal mejor que verificar requirements.txt buscando django (que podria ser una sub-dependencia o un residuo no utilizado).
Generacion de Dockerfiles: 12 plantillas de produccion
Una vez detectado el stack, el motor de build genera un Dockerfile optimizado para ese stack. Las plantillas no son simples scripts "FROM node, COPY, RUN". Usan builds multi-etapa, imagenes base Alpine o slim, usuarios no-root y optimizacion de cache de build.
Aqui esta la plantilla generica de Node.js (simplificada):
rustfn dockerfile_node(detected: &DetectedStack) -> String {
let pm = detected.package_manager.as_ref().unwrap_or(&PackageManager::Npm);
let install_cmd = match pm {
PackageManager::Npm => "npm ci --only=production",
PackageManager::Yarn => "yarn install --frozen-lockfile --production",
PackageManager::Pnpm => "pnpm install --frozen-lockfile --prod",
PackageManager::Bun => "bun install --production",
};
format!(r#"
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN {install_cmd}
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /app .
USER app
EXPOSE {port}
CMD ["node", "dist/index.js"]
"#, install_cmd = install_cmd, port = detected.stack.default_port())
}Detalles clave:
- Build multi-etapa: La etapa
builderinstala dependencias y construye; la etapa final copia solo lo necesario. Esto mantiene la imagen de produccion pequena. npm cisobrenpm install:ciusa el lockfile exactamente, asegurando builds reproducibles.installpodria actualizar dependencias.--frozen-lockfile: Mismo principio para yarn y pnpm.- Usuario no-root: El usuario
appevita que el proceso del contenedor se ejecute como root, una medida de seguridad basica. - Base Alpine: Aproximadamente 50MB contra 900MB de la imagen completa de Node.
La plantilla de Next.js es mas sofisticada, aprovechando el modo de salida standalone de Next:
rustfn dockerfile_nextjs(detected: &DetectedStack) -> String {
format!(r#"
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER app
EXPOSE 3000
CMD ["node", "server.js"]
"#)
}Tres etapas: instalacion de dependencias, build y produccion. La imagen final contiene solo el servidor standalone, los assets estaticos y los archivos publicos -- sin node_modules, sin codigo fuente, sin herramientas de build.
Para Go, la plantilla va mas lejos, usando scratch como imagen base final:
rustfn dockerfile_go(_detected: &DetectedStack) -> String {
r#"
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server .
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
"#.to_string()
}La imagen final literalmente no contiene nada excepto el binario compilado. Sin shell, sin utilidades del SO, sin superficie de ataque. Los -ldflags="-s -w" eliminan los simbolos de debug y la informacion DWARF, reduciendo aun mas el tamano del binario.
El .dockerignore: previniendo la inflacion del contexto de build
Un error comun de despliegue: el contexto de build de Docker incluye node_modules, .git u otros directorios grandes, haciendo los builds lentos y las imagenes innecesariamente grandes. sh0 genera un archivo .dockerignore adaptado al stack detectado:
rustfn generate_dockerignore(stack: &Stack) -> String {
let mut patterns = vec![
".git",
".gitignore",
"*.md",
"LICENSE",
".env*",
".DS_Store",
"Thumbs.db",
];
match stack {
Stack::NodeGeneric | Stack::NextJs | Stack::SvelteKit | Stack::Nuxt | Stack::Astro => {
patterns.extend_from_slice(&[
"node_modules",
".next",
".nuxt",
"dist",
"coverage",
".cache",
]);
}
Stack::Python | Stack::Django | Stack::FastApi => {
patterns.extend_from_slice(&[
"__pycache__",
"*.pyc",
".venv",
"venv",
".pytest_cache",
]);
}
Stack::Go => {
patterns.push("vendor");
}
Stack::Rust => {
patterns.push("target");
}
_ => {}
}
patterns.join("\n")
}El patron .env* es particularmente importante. Los archivos de entorno a menudo contienen secretos -- contrasenas de base de datos, claves API, credenciales de la nube. Incluirlos en el contexto de build de Docker significa que terminan en el historial de capas de la imagen, legibles por cualquiera con acceso a la imagen. sh0 los excluye por defecto.
Contexto de build: archivos tar en memoria
Los builds de Docker requieren un archivo tar como contexto de build. sh0 crea este archivo en memoria, usando el crate tar de Rust:
rustpub fn create_build_context(
path: &Path,
dockerfile_content: &str,
ignore_patterns: &[&str],
) -> Result<Vec<u8>, BuilderError> {
let mut archive = tar::Builder::new(Vec::new());
// Inyectar el Dockerfile generado
let dockerfile_bytes = dockerfile_content.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_path("Dockerfile")?;
header.set_size(dockerfile_bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive.append(&header, dockerfile_bytes)?;
// Recorrer el directorio del proyecto, respetando .dockerignore
for entry in walkdir::WalkDir::new(path) {
let entry = entry?;
let rel_path = entry.path().strip_prefix(path)?;
if should_ignore(rel_path, ignore_patterns) {
continue;
}
// ... anadir archivo al archivo tar
}
Ok(archive.into_inner()?)
}El Dockerfile generado se inyecta directamente en el archivo tar. El proyecto nunca necesita un Dockerfile en disco. Esto es mas limpio que escribir un archivo temporal y esperar que la limpieza ocurra correctamente.
El directorio .git/ siempre se excluye, independientemente de los patrones del .dockerignore. Un directorio .git puede ser de cientos de megabytes y no tiene lugar en un contexto de build.
El orquestador: detectar, generar, construir
La struct Builder une todo en un pipeline de tres pasos:
rustimpl Builder {
pub async fn build(&self, opts: BuildOpts) -> Result<BuildOutput, BuilderError> {
let start = Instant::now();
// Paso 1: Detectar stack
let detected = detect(&opts.source_path);
// Paso 2: Generar Dockerfile (o usar el existente)
let dockerfile = match detected.stack {
Stack::Dockerfile => {
std::fs::read_to_string(opts.source_path.join("Dockerfile"))?
}
stack if stack.needs_dockerfile() => {
return Err(BuilderError::NeedsDockerfile(stack.label().into()));
}
_ => generate_dockerfile(&detected),
};
// Paso 3: Crear contexto y construir
let ignore = generate_dockerignore(&detected.stack);
let context = create_build_context(&opts.source_path, &dockerfile, &ignore)?;
let image_id = self.docker.build_image(&context, &opts.image_tag).await?;
Ok(BuildOutput {
image_id,
stack: detected,
duration: start.elapsed(),
// ...
})
}
}El pipeline es lineal y transparente. Cada paso tiene una entrada y salida claras. Si cualquier paso falla, el error se propaga con contexto completo: "la deteccion de stack fallo porque el directorio esta vacio", "la generacion de Dockerfile no es compatible con PHP -- proporciona el tuyo", "el build de Docker fallo con codigo de salida 1 y estos logs".
Pruebas: 23 unitarias, sin necesidad de Docker
La logica de deteccion esta exhaustivamente probada:
rust#[test]
fn test_detect_nextjs() {
let dir = tempdir().unwrap();
File::create(dir.path().join("package.json")).unwrap();
File::create(dir.path().join("next.config.js")).unwrap();
let detected = detect(dir.path());
assert_eq!(detected.stack, Stack::NextJs);
}
#[test]
fn test_dockerfile_takes_priority() {
let dir = tempdir().unwrap();
File::create(dir.path().join("package.json")).unwrap();
File::create(dir.path().join("next.config.js")).unwrap();
File::create(dir.path().join("Dockerfile")).unwrap();
let detected = detect(dir.path());
assert_eq!(detected.stack, Stack::Dockerfile); // No NextJs
}
#[test]
fn test_detect_yarn() {
let dir = tempdir().unwrap();
File::create(dir.path().join("package.json")).unwrap();
File::create(dir.path().join("yarn.lock")).unwrap();
let detected = detect(dir.path());
assert_eq!(detected.package_manager, Some(PackageManager::Yarn));
}Cada prueba crea un directorio temporal con los archivos minimos necesarios para activar una ruta de deteccion especifica. Sin proyectos reales, sin fixtures, sin Docker. Las pruebas se ejecutan en paralelo en menos de un segundo.
Por que esto importa
La deteccion de stacks es la funcionalidad que transforma una herramienta de despliegue en una plataforma. Sin ella, cada usuario tiene que escribir un Dockerfile, configurar un comando de build y especificar un puerto. Con ella, suben codigo y funciona.
Equivocarse es peor que no tener la funcionalidad. Un stack mal detectado significa un build fallido, un mensaje de error confuso y un usuario que pierde la confianza. Por eso la deteccion es conservadora: senales especificas sobre las vagas, archivos de configuracion sobre escaneo de dependencias, y "necesitas proporcionar un Dockerfile" sobre una mala suposicion.
El motor de build tambien es donde se conecta el sistema de health checks de sh0. Despues de detectar el stack pero antes de construir la imagen, sh0 ejecuta 34 reglas de analisis estatico para detectar errores de despliegue. Ese es el tema del siguiente articulo.
Esta es la Parte 3 de la serie "Como construimos sh0.dev".
Navegacion de la serie: - [1] Dia Cero: 10 crates Rust en 24 horas - [2] Escribir un cliente Docker Engine desde cero en Rust - [3] Deteccion automatica de 19 stacks tecnologicos desde el codigo fuente (estas aqui) - [4] 34 reglas para detectar errores de despliegue antes de que ocurran