← Proyectos/aws-secure-content-distribution
Live💻 Full Stack

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.

AstroNode.jsExpressEC2CloudFrontS3DockerTerraform

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

1

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.

2

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.

3

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.

4

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

Astro 6.xTailwind CSS 4.xVanilla JavaScriptNode.js ≥ 22pnpm 11

Backend

Node.js ≥ 20Express 5.2.1AWS SDK v3sharp 0.34.5express-fileuploadhelmetexpress-rate-limitnanoid

Infraestructura AWS

EC2S3 (privado + OAC)CloudFront + Signed URLsKMS (SSE-KMS)Secrets ManagerTerraform

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

Backend — Generación de Signed URL (Express 5 + AWS SDK v3)javascript
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 },
  });
});
Frontend — Fetch de Signed URL y copia al portapapeles (Vanilla JS)javascript
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);
  });
});