Subir Archivos a S3 y Cloudinary con Node.js en 2026: Guía Práctica
Cómo subir archivos a AWS S3 con SDK v3 y a Cloudinary desde Node.js: upload directo, presigned URLs, validación y gestión segura.
Tabla de contenidos
Subir archivos parece simple hasta que llega a producción: archivos demasiado grandes, tipos no permitidos, costes inesperados o imágenes que tardan en cargar. Esta guía cubre el flujo completo.
Opción A: AWS S3 con SDK v3
Configuración
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner multer
npm install -D @types/multer
# .env
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=eu-west-1
S3_BUCKET_NAME=mi-app-uploads
Cliente S3 (singleton)
// lib/s3.ts
import { S3Client } from '@aws-sdk/client-s3';
export const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
Subida directa desde servidor (Node.js)
// routes/upload.ts
import { PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { s3 } from '../lib/s3';
import multer from 'multer';
import { randomUUID } from 'crypto';
import path from 'path';
// Multer en memoria (no guarda en disco)
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB máximo
fileFilter: (req, file, callback) => {
const tiposPermitidos = ['image/jpeg', 'image/png', 'image/webp'];
if (!tiposPermitidos.includes(file.mimetype)) {
return callback(new Error('Solo se permiten imágenes JPEG, PNG y WebP'));
}
callback(null, true);
},
});
// Endpoint de subida
app.post('/api/upload', upload.single('archivo'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No se proporcionó archivo' });
}
const extension = path.extname(req.file.originalname);
const key = `uploads/${randomUUID()}${extension}`;
try {
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME!,
Key: key,
Body: req.file.buffer,
ContentType: req.file.mimetype,
// No usar ACL: 'public-read' — usa políticas de bucket en su lugar
}));
const url = `https://${process.env.S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
res.json({ url, key });
} catch (error) {
console.error('Error subiendo a S3:', error);
res.status(500).json({ error: 'Error al subir el archivo' });
}
});
Presigned URLs (recomendado para archivos grandes)
Con presigned URLs, el cliente sube directamente a S3 sin pasar por tu servidor:
// routes/presigned.ts
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3 } from '../lib/s3';
import { randomUUID } from 'crypto';
app.post('/api/upload/presigned', async (req, res) => {
const { tipoArchivo, extension } = req.body;
const tiposPermitidos = ['image/jpeg', 'image/png', 'image/webp'];
if (!tiposPermitidos.includes(tipoArchivo)) {
return res.status(400).json({ error: 'Tipo de archivo no permitido' });
}
const key = `uploads/${randomUUID()}.${extension}`;
const comando = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME!,
Key: key,
ContentType: tipoArchivo,
});
const presignedUrl = await getSignedUrl(s3, comando, {
expiresIn: 300, // válida 5 minutos
});
res.json({ presignedUrl, key });
});
// En el cliente (frontend)
async function subirArchivoDirecto(archivo: File) {
// 1. Pedir la URL firmada al servidor
const { presignedUrl, key } = await fetch('/api/upload/presigned', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tipoArchivo: archivo.type,
extension: archivo.name.split('.').pop(),
}),
}).then(r => r.json());
// 2. Subir directamente a S3
await fetch(presignedUrl, {
method: 'PUT',
body: archivo,
headers: { 'Content-Type': archivo.type },
});
return key;
}
Opción B: Cloudinary
npm install cloudinary multer
# .env
CLOUDINARY_CLOUD_NAME=tu-cloud
CLOUDINARY_API_KEY=123456789
CLOUDINARY_API_SECRET=...
// lib/cloudinary.ts
import { v2 as cloudinary } from 'cloudinary';
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME!,
api_key: process.env.CLOUDINARY_API_KEY!,
api_secret: process.env.CLOUDINARY_API_SECRET!,
secure: true,
});
export { cloudinary };
Subida con transformaciones automáticas
import { cloudinary } from '../lib/cloudinary';
import multer from 'multer';
import { Readable } from 'stream';
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
});
app.post('/api/upload/cloudinary', upload.single('archivo'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No se proporcionó archivo' });
}
// Subir con transformaciones: redimensionar y convertir a webp
const resultado = await new Promise<{ secure_url: string; public_id: string }>(
(resolve, reject) => {
const stream = cloudinary.uploader.upload_stream(
{
folder: 'mi-app/avatares',
transformation: [
{ width: 400, height: 400, crop: 'fill', gravity: 'face' },
{ fetch_format: 'auto', quality: 'auto' }, // webp automático
],
allowed_formats: ['jpg', 'jpeg', 'png', 'webp'],
},
(error, result) => {
if (error || !result) return reject(error);
resolve(result);
}
);
Readable.from(req.file!.buffer).pipe(stream);
}
);
res.json({
url: resultado.secure_url,
publicId: resultado.public_id,
});
});
Eliminar imagen de Cloudinary
app.delete('/api/upload/cloudinary/:publicId', async (req, res) => {
const { publicId } = req.params;
await cloudinary.uploader.destroy(publicId);
res.json({ eliminado: true });
});
Tabla comparativa rápida
| AWS S3 | Cloudinary | |
|---|---|---|
| Precio (almacenamiento) | Barato a escala | Más caro con volumen |
| Transformaciones | Manual (Lambda/Sharp) | Automáticas en URL |
| DX setup | Moderado | Muy simple |
| Control | Total | Limitado al plan |
| Mejor para | Producción a escala | Prototipos, imágenes |
Si tienes variables de entorno como las de AWS o Cloudinary bien separadas entre desarrollo y producción, consulta la guía de variables de entorno en Node.js y Next.js. Para enviar notificaciones al usuario tras una subida exitosa, el artículo de enviar emails con Node.js cubre los casos más comunes.