Cuando despliegas una aplicación Laravel en sh0, automáticamente obtiene un subdominio como my-laravel.sh0.app. Cada servicio con un puerto HTTP obtiene uno: my-laravel-phpmyadmin.sh0.app, my-laravel-redis.sh0.app. Sin configuración DNS, sin configuración de proxy, sin gestión de certificados SSL. Simplemente funciona.
Pero el almacenamiento de archivos era diferente. Cuando publicamos MinIO gestionado en la sesión anterior, la API S3 y la consola web solo eran accesibles mediante mapeos de puertos localhost. Podías ver http://localhost:55519 en el dashboard, pero eso es inútil si estás en tu teléfono, compartiendo un enlace con un compañero o desplegando un frontend que necesita un endpoint S3 público.
La pregunta era simple: ¿por qué los servicios de infraestructura no reciben el mismo tratamiento que las aplicaciones?
El patrón existente
sh0 ya tenía un sistema maduro de subdominios automáticos para servicios de aplicaciones. Cuando un template se despliega, el pipeline en templates.rs ejecuta un paso llamado "6b: Auto-subdomain for secondary HTTP services" que hace tres cosas:
- Genera el subdominio --
{app_name}-{service_name}.{base_domain} - Configura Caddy -- crea una ruta de proxy inverso desde ese dominio hacia la IP y el puerto del contenedor en la red Docker
sh0-net - Crea un registro DNS -- llama a la API de Cloudflare para apuntar el subdominio a la IP pública del servidor
El certificado SSL es manejado automáticamente por Caddy vía Let's Encrypt. Todo toma aproximadamente dos segundos.
El almacenamiento de archivos ya tenía su propio sistema de dominios -- los usuarios podían añadir manualmente dominios personalizados a través de una pestaña "Dominios", y el backend configuraba Caddy y Cloudflare. Pero la parte automática faltaba.
La decisión de diseño
Teníamos dos opciones para cuándo se asignan los dominios automáticos:
Opción A: Solo cuando el usuario hace clic en un botón. Seguro, explícito, sin sorpresas.
Opción B: Automáticamente en el momento de la creación, más un botón para instancias existentes. Cero fricción para nuevos despliegues, con un respaldo para instancias creadas antes de esta funcionalidad.
Elegimos la Opción B. Todo el propósito de sh0 es que la infraestructura sea invisible. Si creas una instancia de almacenamiento, probablemente quieras acceder a ella desde fuera de localhost.
El patrón de nomenclatura sigue la convención de las aplicaciones:
- API S3: {instance-name}-s3.{base_domain} (ej.: system-storage-s3.sh0.app)
- Consola web: {instance-name}-console.{base_domain} (ej.: system-storage-console.sh0.app)
La implementación
Un helper compartido, no lógica duplicada
El handler existente add_storage_domain ya hacía todo lo necesario: validar el dominio, verificar unicidad entre tablas, insertar un registro FileStorageDomain, configurar Caddy y crear un registro DNS. Extrajimos las partes reutilizables en una nueva función:
rustasync fn auto_assign_storage_domains(
state: &AppState,
storage_id: &str,
instance_name: &str,
) -> Result<Vec<FileStorageDomainResponse>> {
let base_domain = match &state.base_domain {
Some(bd) => bd.clone(),
None => return Ok(vec![]), // Sin dominio base configurado -- se omite silenciosamente
};
let candidates = [
(format!("{}-s3.{}", instance_name, base_domain), "api"),
(format!("{}-console.{}", instance_name, base_domain), "console"),
];
// Para cada candidato: verificar unicidad, insertar, DNS, luego actualizar Caddy
// ...
}Decisiones de diseño clave en esta función:
- Retorno temprano si no hay
base_domain-- las instancias autoalojadas sin dominio registrado omiten la asignación automática silenciosamente. Sin errores, sin ruido en los logs.
- Idempotente -- cada subdominio se verifica tanto en la tabla
domainsde aplicaciones como en la tablafile_storage_domainsantes de la inserción. Llamar a la función dos veces es seguro.
- El enrutamiento Caddy se agrupa --
update_proxy_for_storagese llama una sola vez al final, no por dominio. Agrupa todos los dominios API en una ruta Caddy (puerto upstream 9000) y todos los dominios de consola en otra (puerto upstream 9001).
No-fatal en la creación
El detalle crítico: la asignación automática de dominios durante la creación de la instancia no debe bloquear la creación en sí.
rust// En create_instance():
if let Err(e) = auto_assign_storage_domains(&state, &created.id, &created.name).await {
tracing::warn!(instance = %created.name, error = %e,
"Failed to auto-assign storage domains");
}Si Cloudflare está caído, si el dominio ya existe, si algo sale mal -- la instancia de almacenamiento se crea de todas formas. El usuario obtiene su MinIO, y puede asignar dominios más tarde mediante el botón.
El endpoint del botón
Para instancias existentes creadas antes de esta funcionalidad, añadimos un endpoint simple:
POST /api/v1/file-storage/{id}/auto-domainLlama al mismo helper auto_assign_storage_domains. El dashboard muestra una tarjeta "Activar acceso externo" en la pestaña de vista general cuando no existen dominios, siguiendo el patrón usado para los servicios de aplicaciones.
Lo que Caddy ve
Cuando la función termina, la configuración de Caddy contiene dos nuevos bloques de rutas. Simplificado:
json{
"match": [{"host": ["system-storage-s3.sh0.app"]}],
"handle": [{
"handler": "reverse_proxy",
"upstreams": [{"dial": "172.18.0.5:9000"}]
}]
}El puerto 9000 es la API S3 de MinIO. El puerto 9001 es la consola web. Caddy maneja la terminación TLS y el aprovisionamiento de certificados automáticamente.
La dimensión i18n
Esta sesión también reveló una laguna: docenas de cadenas en inglés codificadas directamente en las páginas de almacenamiento de archivos. "Console Username", "Enable subdomain", "Browse", "DNS Active", descripciones de tarjetas de funcionalidades -- todo en inglés sin traducir.
Añadimos 25 nuevas claves de traducción en los cinco idiomas (inglés, francés, español, portugués, suajili) y reemplazamos cada cadena codificada por llamadas t(). Esto importa porque sh0 está dirigido a desarrolladores africanos, muchos de los cuales prefieren interfaces en francés o portugués.
La página de dominios (/domains) tenía el mismo problema -- encabezados de tabla como "Service", "Status", "Instance", "Target" estaban todos codificados. También corregidos.
El patrón a destacar
La lección más amplia aquí: cuando construyes una funcionalidad para una categoría de recursos (aplicaciones), diseña el sistema subyacente (integración Caddy + Cloudflare) para ser reutilizable. Cuando construimos el sistema de dominios para almacenamiento de archivos un día antes, lo modelamos siguiendo el sistema de dominios de aplicaciones -- mismo patrón de esquema de base de datos, misma lógica de configuración de proxy, misma integración DNS. Eso hizo que los subdominios automáticos fueran cuestión de llamar funciones existentes en el orden correcto, no de construir nueva infraestructura.
La implementación total fue una función helper en Rust, un endpoint API, un registro de ruta y un puñado de cambios frontend. El trabajo difícil ya estaba hecho.