← Blog
awss3cloudfrontnode.jssharp

S3, Sharp e CloudFront: la media pipeline di ReD Sposi

27 marzo 2026

S3, Sharp e CloudFront: la media pipeline di ReD Sposi

Il primo prototipo della galleria di ReD Sposi usava GridFS — il layer di storage file di MongoDB. Funzionava: l'invitato caricava una foto, Express la salvava su GridFS, e quando l'app richiedeva il thumbnail il backend leggeva il file, lo ridimensionava on-the-fly con Sharp, e lo trasmetteva via HTTP.

Semplice. E per un prototipo, più che sufficiente.

Il problema è che quel "on-the-fly" ha un costo. Ogni richiesta di thumbnail colpisce il backend, che deve aprire una connessione GridFS, leggere il file completo in memoria, ridimensionarlo, e rispondere. Per una gallery con centinaia di foto e una griglia a tre colonne che carica tutto insieme, quel costo si moltiplica velocemente. E il backend fa già abbastanza: gestisce l'auth, la chat via Socket.IO, le notifiche push, l'RSVP.

Non aveva senso usare lo stesso processo Node.js per servire file statici.

La struttura del modello Photo

Prima di parlare della soluzione, vale la pena capire come è strutturato il dato.

Il modello Photo nel backend ha un campo storageType che determina come viene costruita l'URI quando il backend risponde alle richieste della app:

Con storageType: "gridfs" il backend costruisce l'URI al volo: BASE_URL/api/gallery/file/{id}?size=thumb. Con storageType: "url" l'URL è già definitiva e punta direttamente a CloudFront.

Questo campo è stato la chiave della migrazione. Le foto caricate prima del cambio hanno ancora storageType: "gridfs" e continuano a essere servite dal backend. Le foto nuove hanno storageType: "url" e puntano direttamente a CloudFront. Non c'è stato un big bang: il cambio è stato incrementale e reversibile.

Il pipeline di upload

Quando un invitato carica una foto, la app invia una richiesta multipart a POST /api/gallery/upload. Lato server, il flusso è questo:

  1. Ricezione — Multer gestisce il multipart e salva il file in una directory temporanea.
  2. Elaborazione con Sharp — Vengono generate due versioni: il thumbnail (massimo 400px su lato lungo) e la versione medium (massimo 1200px). Sharp comprime in JPEG con qualità 80.
  3. Upload su S3 — Entrambe le versioni vengono caricate con PutObjectCommand. Le chiavi seguono uno schema fisso: gallery/{weddingId}/{photoId}/thumb.jpg e gallery/{weddingId}/{photoId}/medium.jpg.
  4. Salvataggio su MongoDB — Il documento Photo viene creato con storageType: "url" e le due URL CloudFront già calcolate come valori definitivi.
  5. Pulizia — Il file temporaneo viene eliminato.
Diagramma del pipeline di upload: App Mobile → Express → Sharp → S3 → MongoDB, CDN via CloudFront

Il thumbnail viene generato al momento dell'upload, non quando viene richiesto. Il backend non fa mai resize on-the-fly sulle foto nuove.

Codice della funzione generateThumbnail con Sharp

I video

I video seguono un percorso leggermente diverso.

L'app mobile usa expo-video per il playback, quindi il formato deve essere MP4 con H.264. Quando arriva un video, prima passa per ffmpeg che lo transcodifica nel formato corretto, poi Sharp estrae il primo frame e lo usa come thumbnail. Il video trascoded viene caricato su S3 come gallery/{weddingId}/{photoId}/video.mp4, mentre il thumbnail del primo frame diventa thumb.jpg con lo stesso schema delle foto.

Il campo contentType nel modello Photo dice all'app se renderizzare un <Image> o un VideoView. La griglia mostra sempre il thumbnail — foto o video che sia — e solo aprendo il lightbox la app sceglie il componente giusto.

CloudFront

CloudFront sta davanti al bucket S3 e fa tre cose:

Distribuzione geografica. Il server è in Europa. Gli invitati che aprono l'app dall'estero (o anche solo da una connessione lenta) caricano le immagini dall'edge node più vicino, non dal server fisico.

Cache. Le immagini nella galleria non cambiano mai dopo l'upload. Cache-Control: max-age=31536000, immutable — un anno. CloudFront non fa mai una richiesta al bucket S3 per la stessa chiave due volte, dopo la prima.

Separazione dei ruoli. Il backend Express non gestisce più traffico di file. Le richieste di media vanno direttamente a CloudFront. Il backend riceve solo le chiamate API vere: auth, RSVP, moderazione, chat.

La configurazione CloudFront per questo caso è minimale: origin S3, nessun comportamento personalizzato, HTTPS obbligatorio, cache behavior di default con TTL lungo. Non ho usato Lambda@Edge o funzioni CloudFront — non ce n'era bisogno.

Cosa è cambiato nell'app

Lato mobile, il cambiamento è quasi invisibile. La funzione mapPhoto() in src/api/gallery.ts costruisce le URI dal backend, e per le foto nuove restituisce direttamente le URL CloudFront senza passare per l'API del backend. La app non sa (e non deve sapere) se un'immagine viene da GridFS o da CloudFront — usa sempre thumbUri per la griglia e uri per il lightbox.

La moderazione admin carica le immagini con header di autorizzazione per le foto vecchie su GridFS, e senza header per quelle su CloudFront — le URL S3 servite via CloudFront sono pubbliche per chi ha l'URL, ma non indicizzabili. È un compromesso accettabile per una gallery privata con accesso via codice invito.

La lezione principale

La cosa che mi ha convinto a fare questa migrazione non è stata la performance — anche se è migliorata. È stata la separazione delle responsabilità.

Un server Node.js è bravo a gestire logica applicativa, connessioni WebSocket, e trasformazioni dati. È pessimo come CDN. Usarlo per entrambe le cose non è un peccato mortale, ma è un debito che si paga nel momento peggiore: quando l'app ha più utenti e il server è già sotto pressione per altre ragioni.

S3 per lo storage permanente, Sharp per le trasformazioni al momento giusto (upload, non richiesta), CloudFront per la distribuzione: ogni pezzo fa una cosa sola. Questo è il tipo di architettura che non ti chiede attenzione una volta messa in piedi.

Per qualche centinaio di invitati al matrimonio, probabilmente andava bene anche prima. Ma costruire la cosa giusta adesso è più facile che aggiustarla sotto pressione in agosto.