Vídeo en inglés con subtítulos en español.
La respuesta corta: sí, pero S3 es una de tres capas
Una app de chat hace tres trabajos distintos. Entrega cada mensaje nuevo a todos los de la sala en el momento en que se envía. Recuerda los mensajes, para que quien abre la sala vea las últimas decenas y la app pueda responder cosas como «qué tengo sin leer». Y guarda archivos: fotos, vídeos cortos, notas de voz y documentos que la gente comparte en la conversación.
El almacenamiento de objetos tipo S3 es muy bueno en el tercer trabajo y no está pensado para los dos primeros. Así que la respuesta honesta a «¿puedo hacer un chat sobre S3?» es sí, con S3 como capa de archivos y de histórico, junto a otras dos piezas:
Un servidor de aplicación que mantiene abierta una conexión WebSocket con cada cliente y reparte los mensajes. Es pequeño: unos cientos de líneas de Node.js con Socket.io bastan para un chat que funcione.
Una base de datos que guarda los mensajes como filas, para que «los últimos 50 de esta sala» sea una sola consulta con índice.
Almacenamiento de objetos para todo lo que sea un archivo. Mantiene ligeros la base de datos y el servidor, porque ninguno de los dos tiene que guardar ni mover megas de contenido multimedia.
El resto de la guía explica dónde está la frontera entre ellos y después conecta las tres piezas en CDNTR. La app de demostración de esta guía funciona en directo en CDNTR (Node.js, Express y Socket.io): la app, Postgres, la vinculación del bucket y la subida y descarga prefirmadas se verificaron en ella el 2026-11-06. Las partes marcadas como opciones, el adaptador Redis, el trabajo de archivado y los servidores ya hechos, no formaban parte de la demo.
Lo que S3 por sí solo no puede hacer en un chat
No hay push. S3 responde a peticiones; nunca inicia una. Cuando llega un mensaje nuevo, nada avisa a los demás clientes. Sin un servidor propio tendrían que hacer polling: listar los objetos de la sala cada uno o dos segundos, desde cada pestaña y cada teléfono abiertos. Se nota lento, desperdicia peticiones y sigue sin ser tiempo real.
No hay append. Un objeto se escribe entero. No existe «añade una línea al final de este archivo». Así que cada mensaje acaba siendo un objeto propio (miles de objetos diminutos por sala y día) o la reescritura de uno más grande, con dos remitentes compitiendo por sobrescribir la versión del otro.
No hay consultas. S3 sabe listar claves por prefijo, y nada más. «Los últimos 50 mensajes de esta sala», «lo que llegó desde mi última visita», «número de no leídos», «buscar factura» son preguntas para una base de datos. Ordenar las claves por tiempo te acerca un poco y ahí se queda.
No hay usuarios ni permisos. S3 comprueba una sola cosa: si la petición está firmada con una clave válida. Quién está en qué sala, quién puede ver qué archivo, quién está bloqueado: esa lógica es de tu app. Dar las claves de S3 al navegador para que lea y escriba directamente daría a cada usuario acceso a todo el bucket.
Nada de esto es un defecto de S3. Es un almacén de archivos, y un chat es una conversación en tiempo real y con estado. Usa cada herramienta para lo suyo.
En qué es bueno S3 dentro de una app de chat
Adjuntos. Fotos, vídeos, notas de voz, PDF. Son grandes, se escriben una vez y se leen muchas: justo el trabajo para el que está hecho el almacenamiento de objetos. La base de datos solo guarda una clave y un nombre de archivo por mensaje, así que sigue pequeña y rápida por mucho contenido que se comparta.
Histórico de mensajes. Las conversaciones antiguas se leen poco, pero no deben desaparecer. Sacarlas de la base de datos y llevarlas a S3 en bloques comprimidos (por ejemplo, uno por sala y día) mantiene pequeña la tabla activa sin perder nada del historial.
Exportaciones y copias de seguridad. «Descargar mis datos», exportar una sala, volcados nocturnos de la base de datos: cada uno es un archivo, y cada uno va en un bucket.
Un servidor de aplicación más ligero. Como el navegador sube directamente a S3 y descarga directamente de él (las siguientes secciones explican cómo), el servidor nunca transmite contenido multimedia. Firma URLs de corta duración y mueve pequeños mensajes JSON, así que un solo contenedor pequeño da para mucho.
En CDNTR, el servicio S3 de s3.cdn.com.tr guarda los objetos en nuestros centros de datos en Turquía y es compatible con S3: los SDK de AWS funcionan con un endpoint personalizado y direccionamiento path-style.
La arquitectura en CDNTR, montada con cdnctl
La app corre en Managed Container Apps. La demo es Node.js con Express y Socket.io, desplegada desde su carpeta de código con cdnctl deploy: la imagen se construye dentro del clúster, así que no necesitas registry. La app recibe una dirección como https://xxxx.cdn.com.tr, y su tráfico WebSocket pasa por la CDN (en la demo, Socket.io informó websocket como transporte). Si los contenedores son nuevos para ti, desplegar un contenedor en la CDN cubre lo básico.
La base de datos es el complemento gestionado de Postgres. Activado con --env-prefix DATABASE, da a la app DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER y el secreto DATABASE_PASSWORD.
Los archivos viven en un bucket de almacenamiento de objetos. Creas el bucket y una clave de acceso para él, y después vinculas ambos a la app con --env-prefix S3. La app recibe S3_ENDPOINT (https://s3.cdn.com.tr), S3_BUCKET, S3_REGION (cdn-tr), S3_ACCESS_KEY_ID y el secreto S3_SECRET_ACCESS_KEY. Las claves solo existen en el entorno de la app, nunca en el navegador.
Todo el montaje son cuatro pasos. Sustituye <account>, <app>, <bucket> y <key> por los ID que imprimen los comandos anteriores.
# 1. deploy the app from source (built in the cluster, no registry)
cdnctl deploy --dir . --account <account> --name chat-demo --healthcheck /health
# 2. managed Postgres -> DATABASE_HOST, _PORT, _NAME, _USER, _PASSWORD
cdnctl container addons enable-postgres --account <account> --app <app> --env-prefix DATABASE
# 3. a bucket and an access key for it
cdnctl object-storage buckets create --account <account> --name chat-demo-files
cdnctl object-storage access-keys create --account <account> --bucket <bucket>
# 4. bind them -> S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY
cdnctl object-storage bindings create --account <account> --app <app> \
--bucket <bucket> --access-key <key> --env-prefix S3
Mensajes: una tabla en Postgres y un handler de Socket.io
Un mensaje es una fila: sala, autor, texto y, para los adjuntos, la clave del objeto y el nombre original del archivo. Un índice sobre (room, id) convierte la consulta que un chat ejecuta sin parar, los últimos 50 mensajes de una sala, en un recorrido de índice corto por mucho que crezca la tabla.
La parte de Socket.io son dos eventos. Con join, el cliente entra en una sala y recibe su historial. Con message, el servidor escribe primero la fila y después difunde la fila guardada a todos los de la sala, incluido el remitente. Difundir lo que devolvió la base de datos, y no lo que envió el cliente, da a todos el mismo id y la misma marca de tiempo; y un mensaje que no se guardó no le llega a nadie.
socket.data.user lo asigna tu propio middleware de autenticación al abrirse la conexión. El cliente nunca dice quién es dentro del mensaje.
La demo corre con una réplica. Con varias, un mensaje tiene que llegar también a los clientes conectados a otras réplicas; lo habitual es el complemento gestionado de Redis (cdnctl container addons enable-redis) con el adaptador Redis de Socket.io. Más sobre Redis en qué es Redis.
Esquema y handler de mensajes (Postgres, socket.io 4)
-- messages table
CREATE TABLE IF NOT EXISTS messages (
id bigserial PRIMARY KEY,
room text NOT NULL,
author text NOT NULL,
body text,
file_key text,
file_name text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS messages_room_id ON messages (room, id);
// server.js (socket.io 4)
io.on('connection', (socket) => {
socket.on('join', async (room) => {
socket.join(room);
const { rows } = await db.query(
`SELECT * FROM (SELECT * FROM messages WHERE room = $1
ORDER BY id DESC LIMIT 50) last50 ORDER BY id`, [room]);
socket.emit('history', rows);
});
socket.on('message', async ({ room, body, fileKey, fileName }) => {
const { rows } = await db.query(
`INSERT INTO messages (room, author, body, file_key, file_name)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[room, socket.data.user, body ?? null, fileKey ?? null, fileName ?? null]);
io.to(room).emit('message', rows[0]); // everyone in the room, at once
});
});
Subidas: URLs prefirmadas, del navegador directo a S3
Pasar los archivos por el servidor de aplicación lo convertiría en el cuello de botella: cada foto se subiría dos veces y ocuparía memoria y ancho de banda por el camino. En su lugar, la app concede el permiso y se aparta.
1. El navegador pide a la app una URL de subida, enviando el tipo y el tamaño del archivo. La app comprueba el usuario, la sala y el tamaño (la demo permite hasta 10 MB), elige una clave como rooms/<room>/<uuid> y devuelve una URL prefirmada de PUT de corta duración, válida 5 minutos.
2. El navegador hace PUT del archivo directamente a S3 con esa URL.
3. El navegador envía el mensaje del chat con la clave del objeto; el servidor lo guarda como cualquier otro mensaje.
4. Para abrir un archivo, el receptor llama a la app, que comprueba el acceso y redirige a una URL prefirmada de GET, también válida 5 minutos.
Una URL prefirmada es una petición S3 normal con la firma en la query string: quien la tenga puede hacer exactamente esa operación, sobre esa clave, hasta que caduque. URLs firmadas explica la idea con más detalle. La app comprueba el tamaño declarado antes de firmar; mantén el límite, la caducidad y el formato de la clave en el servidor, donde los usuarios no pueden cambiarlos.
PUT prefirmado con Content-Type firmado, y la redirección de descarga
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'node:crypto';
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT, // https://s3.cdn.com.tr
region: process.env.S3_REGION, // cdn-tr
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
});
const MAX_BYTES = 10 * 1024 * 1024;
app.post('/api/upload-url', express.json(), async (req, res) => {
const { room, type, size } = req.body;
if (!type || !(size > 0) || size > MAX_BYTES) {
return res.status(400).json({ error: 'max 10 MB' });
}
const key = `rooms/${room}/${randomUUID()}`;
const url = await getSignedUrl(s3,
new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, ContentType: type }),
{ expiresIn: 300, signableHeaders: new Set(['content-type']) });
res.json({ url, key });
});
app.get('/api/files', async (req, res) => {
// check here that the user may read this room
const url = await getSignedUrl(s3,
new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: req.query.key }),
{ expiresIn: 300 });
res.redirect(302, url);
});
La trampa del Content-Type: 403 AccessDenied en un PUT prefirmado
Este punto le cuesta a la gente una tarde entera, así que tiene sección propia. Nuestro S3 funciona sobre Ceph RGW, y RGW es más estricto que AWS con una cabecera: si el navegador envía un Content-Type que la firma no cubre, el PUT prefirmado se rechaza con 403 AccessDenied. AWS ignora esa cabecera cuando no está firmada, así que un código que allí funciona puede fallar aquí con un error que parece un problema de claves.
La solución es firmar el tipo dentro de la URL y luego enviar exactamente ese tipo. Con AWS SDK v3, pasa ContentType en el PutObjectCommand y añade signableHeaders: new Set(['content-type']) a getSignedUrl, como hace el fragmento anterior. En el navegador, pon la misma cabecera Content-Type en el PUT.
Dos detalles lo hacen fiable. Usa el mismo valor por defecto en los dos lados: un archivo sin tipo (file.type es una cadena vacía) debe firmarse y enviarse con el mismo valor, por ejemplo application/octet-stream. Y no dejes que una librería reescriba la cabecera: algunos helpers de subida añaden ; charset=utf-8 a los archivos de texto, y eso ya no coincide con la firma.
Lado del navegador: el mismo tipo que firmó el servidor
// browser: ask for a URL, PUT the file to S3, then send the message
const { url, key } = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room, type: file.type || 'application/octet-stream', size: file.size }),
}).then((r) => r.json());
const put = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': file.type || 'application/octet-stream' }, // exactly the signed type
body: file,
});
if (!put.ok) throw new Error(`upload failed: ${put.status}`);
socket.emit('message', { room, fileKey: key, fileName: file.name });
CORS en el bucket
La página corre en la dirección de tu app y la subida va a s3.cdn.com.tr, que es otro origen. Por eso el navegador envía un preflight antes del PUT y solo continúa si el bucket responde con una regla CORS que permita el origen de tu app, el método PUT y la cabecera Content-Type. Sin ella, la subida falla en el navegador mientras la misma petición funciona desde curl: la señal clásica de que falta la regla CORS.
Las reglas CORS de un bucket se definen por la API de S3 con PutBucketCors. La demo lo hace una vez al arrancar, lo que mantiene la regla junto al código que depende de ella. Permite tu propio origen, no *: la regla es parte de lo que impide que otros sitios usen tu bucket desde sus páginas. Qué es CORS explica en detalle los preflights y los mensajes de error habituales.
Una regla CORS de bucket para subidas y descargas directas
import { PutBucketCorsCommand } from '@aws-sdk/client-s3';
// once at startup: let the app's own origin PUT and GET directly
await s3.send(new PutBucketCorsCommand({
Bucket: process.env.S3_BUCKET,
CORSConfiguration: {
CORSRules: [{
AllowedOrigins: ['https://xxxx.cdn.com.tr'], // your app's address
AllowedMethods: ['PUT', 'GET'],
AllowedHeaders: ['content-type'],
MaxAgeSeconds: 3600,
}],
},
}));
WebSocket a través de la CDN: timeouts y reconexiones
La CDN transporta WebSocket por regla de entrega. En un dominio de Managed Container Apps, WebSocket está activado por defecto, con un timeout de inactividad de 300 segundos salvo que la regla de entrega fije otro valor; una regla puede fijar cualquier valor entre 1 y 3.600 segundos. El timeout cuenta el silencio, no la duración total; una conexión que sigue intercambiando datos permanece abierta. Socket.io envía su propio heartbeat cada 25 segundos por defecto, lo que mantiene una sala tranquila muy por dentro del valor por defecto. Los ajustes se describen en la guía de conexión con el origen.
Los clientes deben reconectarse solos. Las conexiones WebSocket abiertas pueden cerrarse cuando se recarga la configuración del edge, y los teléfonos pierden la conexión cada vez que cambian de red. Socket.io se reconecta por sí mismo; si usas WebSocket a pelo, añade reconexión con backoff.
Tras reconectarse, el cliente debe recuperar lo que se perdió. Lo más sencillo es volver a enviar join: el servidor responde con el historial reciente de la sala y el cliente lo fusiona por id de mensaje, para que nada aparezca dos veces.
Archivar mensajes antiguos, o usar un servidor de chat ya hecho
Archivado. La demo guarda todos los mensajes en Postgres, y eso funciona bien durante mucho tiempo. Cuando la tabla crece, un patrón habitual es un trabajo periódico que toma los mensajes de más de, por ejemplo, 90 días, los escribe en S3 como un bloque JSON por sala y día (archive/<room>/2026-08-01.json.gz) y después borra esas filas. «Cargar mensajes anteriores» lee el bloque adecuado de S3 con un GET prefirmado. La base de datos guarda lo que la gente realmente consulta; S3 guarda el resto.
Servidores ya hechos. Si prefieres no escribir tu propio chat, Mattermost, Rocket.Chat y Matrix Synapse pueden guardar los archivos subidos en almacenamiento compatible con S3. Las mismas tres capas siguen ahí: su propio proceso servidor, su propia base de datos y un bucket para los archivos. En CDNTR eso significa el contenedor, el complemento de base de datos y el bucket de esta guía, configurados con el endpoint de S3, el bucket y las claves que pida la documentación de cada proyecto.
Checklist: los errores que rompen las subidas en un chat
Regla CORS en el bucket. El origen de tu app, PUT y GET, la cabecera Content-Type. Si falta, solo se ve como una subida fallida en el navegador.
Content-Type firmado. Fírmalo en la URL de PUT y envía exactamente ese valor, con el mismo valor por defecto para archivos sin tipo. Si no: 403 AccessDenied.
Caducidad corta. Minutos, no días. La demo usa 5 minutos tanto para PUT como para GET; una URL filtrada deja de servir casi enseguida.
Los archivos no pasan por la app. Subida y descarga van directamente entre el navegador y S3. La app firma y redirige; no transmite.
Reconexión automática. Cuenta con que las conexiones WebSocket se cierren y se vuelvan a abrir. Vuelve a unirte a la sala y fusiona el historial por id de mensaje.
Límites de tamaño. Comprueba el tamaño declarado antes de firmar y rechaza lo que supere tu límite (10 MB en la demo).
Las claves se quedan en el servidor. Las claves de acceso de S3 viven en el entorno de la app gracias a la vinculación. Nunca las envíes al navegador ni las incrustes en el código del cliente; el navegador solo ve URLs prefirmadas.
Control de acceso en la descarga. Haz pasar los enlaces a archivos por la app, para que confirme que el usuario puede ver esa sala antes de redirigir.
Cómo configurarlo
Guías paso a paso en el centro de ayuda:
Preguntas frecuentes sobre chats con S3
¿Puedo crear una app de chat solo con S3?
No una en tiempo real. S3 no puede enviar mensajes nuevos, añadir datos a objetos ni consultarlos, así que los clientes tendrían que hacer polling y acabarías reconstruyendo una base de datos sobre listados de claves. Usa S3 para archivos e histórico, una base de datos para los mensajes y un pequeño servidor WebSocket para la entrega.
¿Por qué no hacer que los clientes consulten S3 para ver si hay mensajes?
Cada cliente abierto listaría objetos cada uno o dos segundos: se nota lento, multiplica las peticiones y sigue sin responder a «los últimos 50» ni a «no leídos». Una conexión WebSocket entrega el mensaje a todos los de la sala en cuanto el servidor lo guarda.
¿Los archivos subidos pasan por mi servidor de aplicación?
No. La app devuelve una URL prefirmada de PUT, válida 5 minutos en la demo, y el navegador sube directamente a S3. Las descargas funcionan igual: la app comprueba el acceso y redirige a una URL prefirmada de GET.
¿Por qué mi PUT prefirmado devuelve 403 AccessDenied?
En nuestro S3 la causa más común es una cabecera Content-Type que la firma no cubre. Fírmala en la URL (con AWS SDK v3: ContentType en PutObjectCommand y signableHeaders: new Set(['content-type'])) y envía exactamente ese tipo desde el navegador. Si el navegador muestra en cambio un error de CORS, el bucket necesita una regla CORS.
¿Funciona WebSocket a través de la CDN?
Sí. En un dominio de Managed Container Apps, WebSocket está activado por defecto, con un timeout de inactividad de 300 segundos salvo que la regla de entrega fije otro valor (una regla puede fijar de 1 a 3.600 segundos). Los clientes deben reconectarse automáticamente, porque las conexiones abiertas pueden cerrarse cuando se recarga la configuración del edge; Socket.io lo hace por sí mismo.
¿Necesito Redis para un chat con Socket.io?
Con una réplica, no; la demo corre con una. Con varias, un mensaje tiene que llegar a los clientes conectados a las demás, y lo habitual es el complemento gestionado de Redis con el adaptador Redis de Socket.io.