Saltar al contenido principal
Intermedio Node.jsBackendAWS

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.

Fran Cobos 4 min de lectura 777 palabras

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 S3Cloudinary
Precio (almacenamiento)Barato a escalaMás caro con volumen
TransformacionesManual (Lambda/Sharp)Automáticas en URL
DX setupModeradoMuy simple
ControlTotalLimitado al plan
Mejor paraProducción a escalaPrototipos, 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.

Fran Cobos

Fran Cobos

Desarrollador Full Stack especializado en IA aplicada, automatización y desarrollo web. Escribo sobre herramientas, tutoriales y casos reales para programadores.

¿Necesitas desarrollo a medida?

Apps web, IA, módulos ERP — cuéntame tu proyecto.