Stockage d'objets vs blobs en base : modélisez les métadonnées dans Postgres, stockez les octets en stockage d'objets et gardez des téléchargements rapides avec des coûts prévisibles.

Les uploads utilisateurs paraissent simples : accepter un fichier, le sauvegarder, l'afficher plus tard. Ça fonctionne avec quelques utilisateurs et de petits fichiers. Puis le volume augmente, les fichiers grossissent, et la douleur apparaît là où on ne l'attend pas forcément, loin du bouton d'upload.
Les téléchargements ralentissent parce que votre serveur d'appli ou votre base de données effectue le travail lourd. Les sauvegardes deviennent énormes et lentes, donc les restaurations prennent plus de temps exactement quand vous en avez besoin. Les factures de stockage et de bande passante (egress) peuvent exploser parce que les fichiers sont servis de façon inefficace, dupliqués ou jamais nettoyés.
Ce que vous voulez en général, c'est ennuyeusement fiable : des transferts rapides sous charge, des règles d'accès claires, des opérations simples (sauvegarde, restauration, nettoyage) et des coûts qui restent prévisibles quand l'usage croît.
Pour y arriver, séparez deux choses qu'on mélange souvent :
Les métadonnées sont de petites informations sur un fichier : qui en est propriétaire, comment il s'appelle, la taille, le type, quand il a été uploadé, et où il se trouve. Elles appartiennent à votre base de données (comme Postgres) parce que vous devez les interroger, filtrer et joindre aux utilisateurs, projets et permissions.
Les octets du fichier sont le contenu réel (la photo, le PDF, la vidéo). Stocker les octets dans des blobs de la base peut fonctionner, mais alourdit la base, augmente les sauvegardes et rend les performances moins prévisibles. Mettre les octets dans du stockage d'objets garde la base concentrée sur ce qu'elle fait de mieux, tandis que les fichiers sont servis rapidement et à moindre coût par des systèmes conçus pour ça.
Quand on dit «stocker les uploads dans la base», on pense généralement aux blobs : soit une colonne BYTEA (octets bruts dans une ligne) soit les «large objects» de Postgres (fonctionnalité qui stocke les grandes valeurs séparément). Les deux peuvent fonctionner, mais confient à la base le rôle de servir les octets.
Le stockage d'objets est une autre idée : le fichier vit dans un bucket comme objet, adressé par une clé (par exemple uploads/2026/01/file.pdf). Il est conçu pour les gros fichiers, le stockage peu coûteux et le streaming. Il gère aussi de nombreuses lectures concurrentes sans monopoliser vos connexions DB.
Postgres excelle pour les requêtes, contraintes et transactions. Il est parfait pour les métadonnées : qui possède le fichier, de quoi il s'agit, quand il a été uploadé et s'il peut être téléchargé. Ces métadonnées sont petites, faciles à indexer et à garder cohérentes.
Règle pratique :
Un contrôle de bon sens : si les sauvegardes, réplicas et migrations deviennent douloureux avec les octets inclus, laissez les octets en dehors de Postgres.
Le montage que la plupart des équipes adoptent est simple : stockez les octets dans un stockage d'objets, et conservez l'enregistrement de fichier (qui en est propriétaire, ce que c'est, où il se trouve) dans Postgres. Votre API coordonne et autorise, mais ne fait pas transiter les gros uploads et téléchargements.
Cela vous donne trois responsabilités claires :
file_id stable, propriétaire, taille, content type et le pointeur vers l'objet.Ce file_id stable devient la clé primaire pour tout : commentaires qui référencent une pièce jointe, factures qui pointent vers un PDF, logs d'audit et outils de support. Les utilisateurs peuvent renommer un fichier, vous pouvez le déplacer entre buckets, et le file_id reste inchangé.
Quand c'est possible, traitez les objets stockés comme immuables. Si un utilisateur remplace un document, créez un nouvel objet (et généralement une nouvelle ligne ou une nouvelle ligne de version) au lieu d'écraser les octets en place. Ça simplifie le caching, évite les surprises du type «l'ancien lien retourne un nouveau fichier» et donne une histoire de rollback propre.
Décidez de la confidentialité tôt : privé par défaut, public seulement par exception. Bonne règle : la base de données est la source de vérité pour qui peut accéder, le stockage d'objets applique la permission de courte durée que votre API délivre.
Avec la séparation propre, Postgres stocke les faits sur le fichier et le stockage d'objets stocke les octets. Cela garde votre base plus petite, les sauvegardes plus rapides et les requêtes simples.
Une table uploads pratique n'a besoin que de quelques champs pour répondre à des questions réelles comme «qui possède ça ?», «où est-ce stocké ?» et «est-ce sûr à télécharger ?»
CREATE TABLE uploads (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
bucket text NOT NULL,
object_key text NOT NULL,
size_bytes bigint NOT NULL,
content_type text,
original_filename text,
checksum text,
state text NOT NULL CHECK (state IN ('pending','uploaded','failed','deleted')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX uploads_owner_created_idx ON uploads (owner_id, created_at DESC);
CREATE INDEX uploads_checksum_idx ON uploads (checksum);
Quelques décisions qui évitent des soucis plus tard :
bucket + object_key comme pointeur de stockage. Gardez-le immuable une fois uploadé.pending. Basculez en uploaded seulement après que votre système confirme que l'objet existe et que la taille (et idéalement le checksum) correspond.original_filename pour l'affichage seulement. Ne vous fiez pas à ce champ pour des décisions de type ou de sécurité.Si vous supportez les remplacements (comme un utilisateur qui ré-uploade une facture), ajoutez une table upload_versions séparée avec upload_id, version, object_key et created_at. Ainsi vous conservez l'historique, pouvez revenir en arrière et évitez de casser d'anciennes références.
Garde les uploads rapides en faisant gérer à votre API la coordination, pas les octets. Votre base reste réactive, tandis que le stockage d'objets prend le hit de bande passante.
Commencez par créer un enregistrement d'upload avant l'envoi. Votre API renvoie un upload_id, où le fichier sera stocké (un object_key) et une permission d'upload de courte durée.
Un flux courant :
pending, plus la taille attendue et le content type prévu.upload_id et éventuellement des champs de réponse du stockage (comme ETag). Votre serveur vérifie la taille, le checksum (si vous en utilisez un) et le content type, puis marque la ligne uploaded.failed et supprimez éventuellement l'objet.Les retries et doublons sont normaux. Faites de l'appel de finalisation une opération idempotente : si le même upload_id est finalisé deux fois, retournez le succès sans rien altérer.
Pour réduire les doublons dus aux retries et ré-uploads, stockez un checksum et considérez «même propriétaire + même checksum + même taille» comme le même fichier.
Un bon flux de téléchargement commence par une URL stable dans votre application, même si les octets vivent ailleurs. Pensez : /files/{file_id}. Votre API utilise file_id pour lire les métadonnées dans Postgres, vérifie la permission, puis décide comment livrer le fichier.
file_id.uploaded.Les redirections sont simples et rapides pour des fichiers publics ou semi-publics. Pour les fichiers privés, les URLs GET présignées gardent le stockage privé tout en permettant au navigateur de télécharger directement.
Pour la vidéo et les gros téléchargements, assurez-vous que le stockage d'objets (et toute couche proxy) supporte les requêtes de plage (Range headers). Cela permet la recherche et les téléchargements reprenables. Si vous faites transiter les octets par votre API, le support des ranges casse souvent ou devient coûteux.
Le caching est là où la vitesse apparaît. Votre endpoint stable /files/{file_id} doit généralement rester non-cacheable (c'est la porte d'autorisation), tandis que la réponse du stockage d'objets peut souvent être mise en cache selon le contenu. Si les fichiers sont immuables (nouvel upload = nouvelle clé), vous pouvez définir un long TTL. Si vous écrasez des fichiers, gardez les temps de cache courts ou utilisez des clés versionnées.
Un CDN aide quand vous avez beaucoup d'utilisateurs globaux ou de gros fichiers. Si votre audience est réduite ou surtout dans une région, le stockage d'objets seul suffit souvent et coûte moins cher pour commencer.
Les factures surprises viennent généralement des téléchargements et du churn, pas des octets bruts sur le disque.
Évaluez quatre leviers qui font bouger la facture : combien vous stockez, la fréquence des lectures et écritures (requêtes), la quantité de données qui quittent le fournisseur (egress) et si vous utilisez un CDN pour réduire les téléchargements répétés depuis l'origine. Un petit fichier téléchargé 10 000 fois peut coûter plus qu'un gros fichier que personne ne touche.
Des contrôles pour stabiliser les dépenses :
Les règles de cycle de vie sont souvent le gain le plus simple. Par exemple : garder les photos originales «hot» pendant 30 jours puis les déplacer vers une classe moins chère ; garder les factures pendant 7 ans ; supprimer les parties d'upload échouées après 7 jours. Même des politiques de rétention basiques stoppent la dérive du stockage.
La déduplication peut être simple : stockez un hash de contenu (par exemple SHA-256) dans la table métadonnées et appliquez l'unicité par propriétaire. Quand un utilisateur upload le même PDF deux fois, vous pouvez réutiliser l'objet existant et créer juste une nouvelle ligne métadonnée.
Enfin, suivez l'utilisation là où vous faites déjà la comptabilité utilisateur : Postgres. Stockez bytes_uploaded, bytes_downloaded, object_count et last_activity_at par utilisateur ou workspace. Cela permet d'afficher des limites dans l'UI et de déclencher des alertes avant la facture.
La sécurité des uploads se résume à deux choses : qui peut accéder à un fichier, et ce que vous pouvez prouver plus tard si quelque chose tourne mal.
Commencez par un modèle d'accès clair et encodez-le dans Postgres, pas dans des règles ponctuelles dispersées entre services.
Un modèle simple couvre la plupart des apps :
Pour les fichiers privés, évitez d'exposer des clés d'objet brutes. Délivrez des URLs d'upload et de téléchargement à durée limitée et à portée limitée, et faites-les tourner souvent.
Vérifiez le chiffrement en transit et au repos. En transit signifie HTTPS end-to-end, y compris pour les uploads directs vers le stockage. Au repos signifie chiffrement côté fournisseur de stockage, et que les sauvegardes et réplicas sont aussi chiffrés.
Ajoutez des points de contrôle pour la sécurité et la qualité des données : validez le content type et la taille avant de délivrer une URL d'upload, puis validez à nouveau après l'upload (sur la base des octets réellement stockés, pas seulement du nom de fichier). Si votre profil de risque l'exige, exécutez un scan antivirus asynchrone et mettez le fichier en quarantaine jusqu'à passage.
Conservez des champs d'audit pour pouvoir enquêter : uploaded_by, ip, user_agent et last_accessed_at constituent une base pratique.
Si vous avez des exigences de localisation des données, choisissez la région de stockage délibérément et gardez-la cohérente avec où vous exécutez le calcul.
La plupart des problèmes d'upload ne concernent pas la vitesse brute. Ils viennent de choix de conception pratiques au début puis devenus pénibles quand le trafic, les données et le support arrivent.
Un exemple concret : si un utilisateur remplace sa photo de profil trois fois, vous pouvez vous retrouver à payer pour trois anciens objets à moins de planifier un nettoyage. Un pattern sûr est une suppression logique (soft delete) dans Postgres, puis un job en background qui supprime l'objet et enregistre le résultat.
La plupart des problèmes apparaissent quand arrive le premier gros fichier, qu'un utilisateur rafraîchit en plein upload, ou que quelqu'un supprime un compte et que les octets restent derrière.
Assurez-vous que votre table Postgres enregistre la taille du fichier, le checksum (pour vérifier l'intégrité) et un chemin d'état clair (par exemple : pending, uploaded, failed, deleted).
Une checklist de dernier kilomètre :
Un test concret : uploadez un fichier de 2 Go, rafraîchissez la page à 30%, puis reprenez. Puis téléchargez-le sur une connexion lente et allez au milieu. Si l'un des flux est bancal, corrigez-le maintenant, pas après le lancement.
Une SaaS simple a souvent deux types d'uploads très différents : photos de profil (fréquentes, petites, sûres à mettre en cache) et factures PDF (sensibles, privées). C'est là que la séparation métadonnées/Postgres et octets/stockage d'objets paie.
Voici à quoi peuvent ressembler les métadonnées dans une table files, avec quelques champs qui influencent le comportement :
| field | exemple photo profil | exemple PDF facture |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (servi via URL signée) | private |
cache_control | public, max-age=31536000, immutable | no-store |
object_key | users/42/avatars/2026-01-17T120102Z.webp | orgs/7/invoices/INV-1049.pdf |
status | uploaded | uploaded |
size_bytes | 184233 | 982341 |
Quand un utilisateur remplace une photo, traitez-la comme un nouveau fichier, pas comme un écrasement. Créez une nouvelle ligne et un nouveau object_key, puis mettez à jour le profil utilisateur pour pointer vers le nouvel file_id. Marquez l'ancienne ligne replaced_by=<new_id> (ou deleted_at) et supprimez l'ancien objet plus tard avec un job en background. Cela conserve l'historique, facilite les rollbacks et évite les conditions de course.
Le support et le debug deviennent plus faciles parce que les métadonnées racontent une histoire. Quand quelqu'un dit «mon upload a échoué», le support peut vérifier status, un last_error lisible, un storage_request_id ou etag (pour tracer les logs du stockage), des timestamps (est-ce que ça a stallé ?) et owner_id et kind (la politique d'accès est-elle correcte ?).
Commencez petit et rendez le chemin heureux ennuyeusement fiable : les fichiers s'uploadent, les métadonnées se sauvegardent, les téléchargements sont rapides et rien ne se perd.
Une bonne première étape est une table Postgres minimale pour les métadonnées + un flux d'upload direct vers le stockage et un flux de téléchargement unique que vous pouvez expliquer sur un tableau blanc. Une fois que cela marche de bout en bout, ajoutez versions, quotas et règles de cycle de vie.
Définissez une politique de stockage claire par type de fichier et notez-la. Par exemple, les photos de profil sont cacheables, tandis que les factures sont privées et n'accessibles que via des URLs de téléchargement de courte durée. Mélanger les politiques dans un même préfixe de bucket sans plan est la source d'expositions accidentelles.
Ajoutez de l'instrumentation tôt. Les métriques à vouloir dès le jour 1 : taux d'échec de finalisation d'upload, taux d'orphelins (objets sans ligne DB correspondante et vice versa), volume d'egress par type de fichier, latence P95 des téléchargements et taille moyenne des objets.
Si vous voulez prototyper plus vite, Koder.ai (koder.ai) est construit pour générer des apps complètes depuis un chat, et il colle à la stack commune utilisée ici (React, Go, Postgres). Cela peut être un moyen pratique d'itérer sur le schéma, les endpoints et les jobs de nettoyage sans réécrire sans cesse le même squelette.
Après ça, n'ajoutez que ce que vous pouvez expliquer en une phrase : «on garde les anciennes versions 30 jours» ou «chaque workspace a 10 Go». Restez simple jusqu'à ce que l'usage réel vous force à changer.
Utilisez Postgres pour les métadonnées que vous devez interroger et sécuriser (propriétaire, permissions, état, checksum, pointeur). Placez les octets dans un stockage d'objets pour que les téléchargements et transferts volumineux n'utilisent pas les connexions de la base de données ni n'alourdissent les sauvegardes.
Cela fait de votre base de données un serveur de fichiers. La taille des tables augmente, les sauvegardes et restaurations ralentissent, la réplication se charge davantage, et les performances deviennent moins prévisibles quand beaucoup d'utilisateurs téléchargent en même temps.
Oui. Gardez un file_id stable dans votre application, stockez les métadonnées dans Postgres et les octets dans un stockage d'objets adressés par bucket et object_key. Votre API doit autoriser l'accès et délivrer des permissions d'upload/téléchargement de courte durée plutôt que de proxyfier les octets.
Créez d'abord une ligne pending, générez un object_key unique, puis laissez le client uploader directement vers le stockage avec une permission court terme. Après l'upload, le client appelle un endpoint de finalisation pour que le serveur vérifie la taille et le checksum (si utilisé) avant de marquer la ligne uploaded.
Parce que les uploads réels échouent et se réessaient. Un champ d'état permet de distinguer les fichiers attendus mais absents (pending), complétés (uploaded), corrompus (failed) et supprimés (deleted) afin que l'interface, les jobs de nettoyage et les outils de support fonctionnent correctement.
Considérez original_filename comme un champ d'affichage uniquement. Générez une clé de stockage unique (souvent une arborescence basée sur UUID) pour éviter les collisions, les caractères étranges et les surprises de sécurité. Vous pouvez toujours afficher le nom original dans l'UI tout en gardant les chemins de stockage propres et prévisibles.
Utilisez une URL applicative stable comme /files/{file_id} comme garde d'accès. Après vérification des droits dans Postgres, retournez une redirection ou une permission de téléchargement signée courte durée pour que le client télécharge directement depuis le stockage d'objets, en gardant votre API hors du chemin critique.
L'egress et les téléchargements répétés dominent souvent la facture, pas le stockage brut. Limitez la taille des fichiers et appliquez des quotas, utilisez des règles de rétention, dédupliquez par checksum quand c'est pertinent, et suivez les compteurs d'usage pour pouvoir alerter avant l'explosion des coûts.
Stockez les permissions et la visibilité dans Postgres comme source de vérité, et gardez le stockage privé par défaut. Validez le type et la taille avant et après l'upload, utilisez HTTPS de bout en bout, chiffrez au repos, et conservez des champs d'audit pour pouvoir enquêter plus tard.
Commencez avec une table de métadonnées, un flux d'upload direct vers le stockage et un endpoint de téléchargement borné, puis ajoutez des jobs de nettoyage pour les objets orphelins et les lignes supprimées logiquement. Si vous voulez prototyper vite sur une stack React/Go/Postgres, Koder.ai peut générer les endpoints, le schéma et les tâches d'arrière-plan depuis un chat pour vous faire gagner du temps.