AWS Secure Content Distribution
Plataforma fullstack para subir archivos a S3 y servirlos con Signed URLs de CloudFront. Frontend en Astro + backend Node.js containerizado en EC2 + Docker.
Arquitecturas AWS utilizadas
Problema & Solución
Problema
Una plataforma necesita permitir a usuarios autenticados subir archivos a S3 y acceder a ellos de forma privada con URLs temporales, sin exponer el bucket directamente a Internet. El backend debe escalar bajo demanda sin gestionar servidores.
Solución
Frontend estático en Astro 6 que interactúa con una API REST en Express 5 containerizada en EC2 + Docker. El backend gestiona la subida a S3, lista archivos y genera Signed URLs de CloudFront firmadas con Key Pair RSA. El bucket es completamente privado con OAC; CloudFront valida la firma en el edge antes de servir el contenido.
Diagrama de Arquitectura
Browser (Astro SSG)
│
├─ POST /api/v1/files/upload → sube a S3 (multipart, sharp compresión)
├─ GET /api/v1/files → lista archivos del bucket
├─ GET /api/v1/files/:key/signed-url → genera Signed URL de CloudFront
└─ DELETE /api/v1/files/:key → elimina objeto S3
│
▼
┌──────────────────────────────────────────────────────┐
│ EC2 + Docker (Node.js + Express 5) │
│ • sharp: compresión de imágenes antes de subir │
│ • @aws-sdk/cloudfront-signer: firma RSA de URLs │
│ • helmet + rate-limit: seguridad HTTP │
└───────────────┬─────────────────────────┬────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ S3 Bucket (privado) │ │ CloudFront Distribution │
│ OAC + SSE-KMS │ │ Signed URLs (RSA Key) │
│ BlockPublicAccess │ │ OAC SigV4 → S3 │
└──────────────────────┘ └──────────────────────────┘Implementación
Frontend — Zona de drag & drop (Astro + Vanilla JS)
El frontend es un sitio estático generado con Astro 6 + Tailwind CSS v4. Sin frameworks de componentes — toda la interactividad se maneja con JavaScript nativo. La zona de drag & drop valida el tamaño del archivo en cliente (máx. 10 MB) antes de enviarlo.
Backend — Upload a S3 con compresión automática
El backend (Express 5) recibe el archivo via multipart/form-data. Si es una imagen, sharp la comprime antes de subir a S3: 30% calidad para >2MB, 60% para 1-2MB, sin pérdida para <1MB. Esto reduce el costo de almacenamiento y la latencia de descarga.
Generación de Signed URL de CloudFront
El backend obtiene la clave privada RSA del CloudFront Key Pair desde variables de entorno (en producción, desde Secrets Manager). Usa @aws-sdk/cloudfront-signer para generar una Signed URL con expiración configurable (15 min, 1h, 24h, 7 días). CloudFront valida la firma en el edge antes de hacer el request a S3.
Acceso privado a S3 vía CloudFront OAC
El bucket S3 tiene BlockPublicAccess habilitado en las 4 opciones. La bucket policy solo permite GetObject al ARN exacto del distribution de CloudFront. CloudFront firma cada request a S3 con SigV4 (OAC) — el bucket rechaza cualquier request que no venga de este distribution.
Tech Stack
Frontend
Backend
Infraestructura AWS
Decisiones Técnicas
Astro vs Next.js para el frontend
Elegido
Astro (modo estático)
Alternativas
- —Next.js — necesario si se requiere SSR, autenticación server-side o rutas dinámicas
- —SvelteKit — output estático también, pero menos ecosistema
Razón
El frontend es una demo interactiva sin routing complejo ni SSR. Astro genera HTML estático puro — cero JavaScript de framework en producción. Se sirve desde S3 + CloudFront sin servidor. Next.js añadiría complejidad innecesaria (server, bundle, hydration) para una SPA de una sola página.
sharp vs client-side resize
Elegido
sharp en el backend (Node.js)
Alternativas
- —Canvas API en el browser — dependiente del dispositivo del usuario, calidad variable
- —Sin compresión — mayor costo de S3 storage y mayor latencia de descarga
Razón
La compresión server-side garantiza que todos los archivos en S3 estén optimizados independientemente del cliente. El browser no tiene acceso a APIs de compresión tan eficientes. sharp usa binarios nativos de libvips — procesa imágenes de 10MB en <200ms.
Snippets de Código
import { getSignedUrl } from '@aws-sdk/cloudfront-signer';
// CLOUDFRONT_PRIVATE_KEY viene de Secrets Manager en prod
// o de variable de entorno en dev
async function firmarUrl(url, expiresInSeconds = 86400) {
const signedUrl = await getSignedUrl({
url,
dateLessThan: new Date(Date.now() + expiresInSeconds * 1000),
privateKey: process.env.CLOUDFRONT_PRIVATE_KEY,
keyPairId: process.env.CLOUDFRONT_KEYPAIR_ID,
});
return signedUrl;
}
// Route handler
router.get('/files/:key/signed-url', async (req, res) => {
const { key } = req.params;
const expires = parseInt(req.query.expires) || 86400;
const cfUrl = `${process.env.CLOUDFRONT_DOMAIN}/${key}`;
const signedUrl = await firmarUrl(cfUrl, expires);
res.json({
message: 'Signed URL generated',
data: { signedUrl, expiresIn: expires },
});
});async function generateSignedUrl(key, expiresSeconds) {
const res = await fetch(
`${API_URL}/files/${encodeURIComponent(key)}/signed-url?expires=${expiresSeconds}`
);
const { data } = await res.json();
await navigator.clipboard.writeText(data.signedUrl);
showToast('URL copiada al portapapeles', 'success');
}
// Botones de expiración
document.querySelectorAll('[data-expires]').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.closest('[data-key]').dataset.key;
generateSignedUrl(key, btn.dataset.expires);
});
});