From n00b to ZeroCool / Profesionalización
Archivos, imágenes y storage: el cementerio de bugs (y cómo salir vivo)
Cómo subir, guardar y servir archivos sin sorpresas: rutas, permisos, S3, Nginx, CDN, headers, validación, seguridad y debugging real.
Lo que vale la pena leer aquí
La escena: 6:40 pm, tu laptop ya pide esquina (ventilador a tope), y tu PM suelta el clásico: “Oye, en production no se ven las imágenes. Urge para la campaña”.
La escena: 6:40 pm, tu laptop ya pide esquina (ventilador a tope), y tu PM suelta el clásico: “Oye, en production no se ven las imágenes. Urge para la campaña”.
Abres el panel. En tu máquina todo jala. QA te dice “en staging sí”. En prod: puro cuadrito roto.
Bienvenido al cementerio de bugs: archivos, imágenes y storage. El lugar donde un deploy “sencillo” se vuelve talacha, aparecen permisos raros, y un Content-Type mal puesto hace que el navegador intente abrir un PDF como si fuera audio.
Si te suena, no estás solo. Casi todos nos hemos tropezado con:
- Rutas relativas que “funcionaban” hasta que metiste reverse proxy o cambiaste el
root. - Permisos que dependen del user del proceso (y nadie se acuerda cuál es en prod).
- Un bucket “temporal” público que se quedó así medio año.
- Un upload que acepta
.jpg… pero por dentro trae un.phpcon ganas de fiesta.
Qué te llevas de aquí
- Un workflow sano: upload → storage → entrega, sin improvisar paths al calor del deadline.
- Validación de verdad: MIME, tamaño, extensión y firma/magic bytes.
- Qué guardar en tu DB (spoiler: casi nunca el binario) y cómo modelar metadata.
- Local vs S3/compatible: tradeoffs, costos, latencia y trampas típicas.
- Debugging de guerra para el “en mi máquina sí” sin perder la tarde.
Contexto práctico: el triángulo del desastre (app, storage, entrega)
Un bug de “no se ve la imagen” casi nunca es “la imagen”. Normalmente se rompe una de estas capas:
- Aplicación (API/web): recibe, valida, nombra, sube y guarda referencia.
- Storage: filesystem, NFS, S3/MinIO, GCS, Azure Blob. Aquí viven permisos, lifecycle y consistencia.
- Entrega: Nginx/Apache, CDN, signed URLs, headers y cache. Aquí viven CORS,
Content-Type,Cache-Control, redirects.
Decisión práctica: antes de aventarte un refactor o meter un hotfix a ciegas, hazte una pregunta bien bruta:
¿El archivo realmente existe donde juras que existe?
- Si no existe: bug de upload/escritura.
- Si existe pero no se sirve: permisos, ruta, reverse proxy, bucket policy.
- Si se sirve pero “se ve mal”: headers, cache, encoding o archivo corrupto.
Y sí: en México se pone más sabroso con red floja, un “lo subí desde el cel con datos”, o un disco al 95% que te corta el proceso justo cuando ibas a celebrar.
Guía principal: un flujo de archivos que no te sabotea
1) Define el contrato del upload (deja de aceptar lo que caiga)
Por endpoint o caso de uso, define reglas claras:
- Tipos permitidos (ej.
image/jpeg,image/png,application/pdf). - Tamaño máximo (5MB avatar, 20MB comprobante, etc.).
- Dimensiones máximas (para imágenes): evita que te suban una de 8000×8000 y se muera tu worker.
- Qué guardas: original vs transformado (thumbnail, webp).
Decisión práctica: si tu negocio es “comprobantes”, guarda el original y genera una vista para UI. Si es “avatar”, normaliza y listo: no te cases con el original gigante.
2) Nombra archivos como adulto: IDs, no final_final_ahora_si.jpg
Usa keys que no choquen y no filtren datos:
- Estructura recomendada:
/{env}/{tenant}/{tipo}/{yyyy}/{mm}/{id}.{ext} idcomo UUID o hash.- El nombre original no va en la ruta pública (si lo necesitas, guárdalo como metadata).
Ejemplo:
prod/acme/avatar/2026/08/3f2c1f2e-... .webp
Esto te ahorra colisiones, traversal raro y el “subió 3 archivos con el mismo nombre y ahora cuál es cuál”.
3) Valida por contenido, no por fe (extensión ≠ verdad)
Regla de oro: algo.jpg no garantiza JPEG.
Validación mínima que sí sirve:
- Tamaño (antes de procesar).
- MIME real (detectado por librería o encabezados confiables).
- Firma/magic bytes cuando aplique.
Ejemplo en Node.js (Express) con validación básica de MIME y tamaño:
import express from "express";
import multer from "multer";
const app = express();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});
const ALLOWED = new Set(["image/jpeg", "image/png", "application/pdf"]);
app.post("/upload", upload.single("file"), async (req, res) => {
if (!req.file) return res.status(400).json({ error: "Falta archivo" });
const { mimetype, originalname, buffer } = req.file;
if (!ALLOWED.has(mimetype)) {
return res.status(415).json({
error: "Tipo no permitido",
got: mimetype,
file: originalname,
});
}
// TODO: magic bytes check con librería tipo `file-type`.
// TODO: virus scan si recibes uploads de usuarios no confiables.
res.json({ ok: true, mimetype, size: buffer.length });
});
Decisión práctica (modo defensivo): si recibes archivos de usuarios anónimos (formularios públicos, marketplace, soporte), considera scanning (ClamAV o servicio administrado) y bloquea dobles extensiones. Y lo más importante: jamás ejecutes ni interpretes lo que se sube.
4) No guardes binarios en la DB (casi nunca)
Sí se puede, pero en producto web típico se vuelve castigo:
- Backups gigantes.
- Réplicas y migraciones lentas.
- Performance y costos que se salen de control.
Lo que sí conviene guardar en DB:
idstorage_key(ruta en bucket o filesystem)original_namecontent_typesize_byteschecksum(SHA-256)created_by,created_atstatus(subiendo/listo/falló)
Decisión práctica: usa checksum cuando la integridad importa (facturas, contratos, documentos legales). Te evita la pelea de “yo lo subí bien” vs “tu sistema lo rompió”.
5) Elige storage: local vs S3/compatible (y cuándo te va a doler)
Opción A: filesystem local (disco del servidor)
Pros:
- Rápido y simple para arrancar.
- Barato si ya pagas el VPS.
Contras:
- Escalado horizontal: si tienes 2 pods/2 máquinas, una trae el archivo y la otra no.
- Deploy/rollback: puedes pisar rutas o perder archivos si reconstruyes el contenedor.
- Backups: lo “simple” se vuelve operación.
Úsalo si:
- MVP con un solo servidor.
- Archivos no críticos o temporales.
Opción B: S3 o compatible (AWS S3, MinIO, Wasabi, etc.)
Pros:
- Escala y aguanta multi instancia.
- Se lleva bien con CDN.
- Lifecycle policies (archivar/borrar automático).
Contras:
- IAM/permisos: fácil romperlo con un policy mal armado.
- Latencia si la región queda lejos (hola
us-eastcon usuarios en México). - Costos por egress si sirves directo sin CDN.
Úsalo si:
- Ya tienes más de una instancia o vas a crecer.
- Los archivos son parte del producto.
- Necesitas durabilidad real.
6) Sirve archivos con headers correctos (y cachea como si te pagaran por eso)
Los headers son la diferencia entre “carga rápido” y “cada refresh vuelve a bajar 10MB”.
Para assets que no cambian (porque versionas el nombre):
Cache-Control: public, max-age=31536000, immutable
Para cosas que sí cambian con el mismo URL (peligroso, pero pasa):
Cache-Control: private, max-age=0, must-revalidate
Y siempre:
Content-Typecorrecto.Content-Dispositioncuando sea descarga:attachment; filename="..."
Decisión práctica: si usas CDN, versiona URLs (hash en el filename) para cache largo. Si no lo haces, prepárate para el “ya quedó” pero al cliente le sigue saliendo lo viejo por cache pegajoso.

7) Seguridad: público vs privado, signed URLs y control de acceso
Preguntas incómodas (pero te salvan):
- ¿Este archivo lo puede ver cualquiera con el link?
- ¿El link se puede adivinar?
- ¿Necesitas expiración?
Patrones comunes:
- Público (CDN): catálogo, assets estáticos. Usa nombres no adivinables y cero datos sensibles.
- Privado con signed URL: comprobantes, contratos, docs internos. URL temporal con expiración.
- Proxy por backend: el backend valida permisos y streamea el archivo (más control, más carga).
Decisión práctica: para docs privados, signed URLs suelen ser el sweet spot. Proxyear por backend sirve si necesitas auditoría fina o transformar al vuelo, pero ojo: tu API se vuelve “servidor de descargas” y se te cae cuando haya pico.
8) Manejo de imágenes: transforma, no confíes en el cliente
Nunca des por hecho:
- Que viene en RGB (a veces llega CMYK y se ve raro en web).
- Que la orientación está bien (EXIF rotation).
- Que el PNG “pesa poquito” (spoiler: no).
Pipeline razonable:
- Guardar original (según caso).
- Normalizar: quitar metadata sensible (EXIF con ubicación), corregir orientación.
- Generar variantes: thumbnail, medium, webp/avif.
- Guardar metadata: ancho/alto y tamaño final.
Decisión práctica: WebP da wins reales en e-commerce y contenido. Si tu audiencia trae devices viejos o Safari viejo, deja fallback y no te metas en un bug que solo reproduce el director.
9) Observabilidad: cuando truena, necesitas pistas (no fe)
Log por request:
upload_idofile_iduser_idstorage_key- tamaño,
content_type - tiempos: recibir → procesar → subir → responder
Y métricas:
- errores por tipo (
413size,415tipo,403permisos,500timeout storage) - p95/p99 de upload
Escena real: el bug de viernes donde “solo falla con Telcel” o “solo falla en la red de la oficina”. Con logs de tamaño/tiempo cachas si el upload se corta o si tu proxy está limitando el body.
Screenshots sugeridos
- Config del límite de tamaño en Nginx/Apache y el error cuando lo excedes.
- Panel del bucket (S3/MinIO): policy/ACL y un objeto con metadata.
- DevTools mostrando
Content-Type,Cache-Control,Content-Disposition. - Logs del backend con
file_id,storage_key, tiempos y error. - Ejemplo de URL versionada (con hash) y cómo cambia al actualizar.
Errores comunes (del cementerio) + solución
1) “En local sí, en prod no”: path relativo y working directory
Síntoma: guardas en ./uploads, en prod el proceso corre desde otro directorio y termina en un lugar fantasma.
Solución:
- Rutas absolutas por env (
UPLOAD_DIR=/var/app/uploads). - Loguea la ruta final real.
2) 413 Request Entity Too Large
Síntoma: la API ni ve el archivo; el proxy lo corta antes.
Solución:
- Nginx:
client_max_body_size 10m; - Revisa límites en tu framework (multer, body-parser, etc.).
3) Permisos: EACCES / Permission denied
Síntoma: el proceso no puede escribir.
Solución:
- Asegura ownership del directorio para el user del proceso.
- En containers, monta volúmenes con permisos correctos.
4) Imagen “corrupta” o que no abre
Síntoma: el archivo existe, pero el visor falla.
Solución:
- Verifica tamaño final vs esperado.
- Calcula checksum al recibir y al almacenar.
- Evita procesar en memoria archivos grandes: usa streaming.
5) “Ya lo arreglé” pero el cliente ve lo viejo
Síntoma: cache pegajoso (browser/CDN).
Solución:
- Versiona URLs (hash en filename).
- Ajusta
Cache-Control. - Purge en CDN solo cuando sea necesario (y define quién lo hace, porque luego nadie lo quiere pagar).

Checklist final
- Definí tipos, tamaños y reglas por caso de uso.
- Valido MIME + (ideal) magic bytes.
- No uso el nombre original como key pública.
- Guardo metadata en DB (key, size, content-type, checksum).
- Storage elegido con intención (local para MVP; S3/compatible para escala).
- Headers correctos (
Content-Type,Cache-Control,Content-Disposition). - Decidí público vs privado y apliqué signed URLs si aplica.
- Transformo imágenes (orientation, strip EXIF, variantes).
- Logs y métricas listas para debugging.
- Probé desde una red “real” (café/oficina/datos) y con archivo grande.
FAQ
1) ¿Cuándo conviene usar signed URLs vs servir por mi backend?
Signed URLs cuando el archivo es privado pero no necesitas lógica compleja en cada descarga. Proxy por backend cuando necesitas control fino, auditoría estricta o transformación dinámica, aceptando el costo.
2) ¿Qué es lo mínimo para evitar que me suban un archivo malicioso?
Límite de tamaño, allowlist de MIME, verificación por firma (magic bytes), nombres no adivinables y jamás ejecutar/interpretar lo subido. Si el contexto es riesgoso, agrega scanning.
3) ¿Puedo guardar archivos en la base de datos?
Sí, pero normalmente te complica backups, performance y costos. Para la mayoría de productos web: guarda el archivo en storage y en DB solo referencias y metadata.
4) ¿Por qué mis imágenes se ven giradas o raras?
Por EXIF orientation y perfiles de color. Solución: normaliza al procesar (corrige orientación, convierte a un formato estándar y quita metadata innecesaria).
5) ¿Cómo manejo el cache sin volverme loco?
Versiona URLs (hash) para assets “inmutables” y usa Cache-Control agresivo. Para contenido que cambia, evita reusar el mismo URL o usa cache corto con revalidación.
Siguiente episodio
Las imágenes ya cargan… hasta que tu API se ahoga con picos y jobs atorados.
La que sigue: colas, workers y retries sin duplicar trabajo (ni cobrar dos veces).
Idea para cerrar bien este post: toma una sola práctica de aquí y conviértela en algo que tu equipo pueda aplicar hoy.
Cuando un artículo aterriza en decisiones reales, deja de ser contenido y se vuelve ventaja.


