Skip to content

[Nom de la Ressource de Stockage Cloudflare]

Type de ressource : Bucket Cloudflare R2 / Bibliothèque Cloudflare Stream
Fichier source : services/[worker-name]/wrangler.toml ou configuration associée
Statut : [Actif / Déprécié / En développement]

[Description concise du rôle de cette ressource de stockage dans l’architecture du site]

  • [Objectif principal : ex. stocker les images du blog et du portfolio]
  • [Bénéfice pour l’architecture : ex. découplage des médias du CMS Sanity]
  • Type de médias : [Images / Vidéos / Documents / Backups / Les deux]
  • Accès : [Public / Privé / URLs signées / Les deux]
  • Consommateurs : [Sanity / Astro / Workers L3 / Les deux]
  • Optimisation : [Cloudflare Polish / Cloudflare Images / Cloudflare Stream / Aucune]
[Sanity Studio]
│ (upload via Worker L3)
▼
[Worker media-upload (L3)]
│ • Génération URL présignée R2
│ • Ou Direct Upload Token Stream
▼
[Cloudflare R2 / Stream]
│ • Stockage structuré
│ • Optimisation Edge
▼
[Sanity]
│ • Stocke uniquement URL + métadonnées
▼
[Astro / Site]
│ • Consomme les URLs R2/Stream
▼
[Utilisateur final]
Composant Rôle Couche
Bucket R2 / Bibliothèque Stream Stockage des médias Infrastructure
Worker media-upload Génération d’URLs présignées L3
Worker media-cleanup Nettoyage des orphelins L3
Worker sanity-backup Backups vers R2 L3
Sanity Stockage des métadonnées + URLs L1
Astro Consommation des URLs L2
Cloudflare CDN Distribution Edge Infrastructure
Variable Description Obligatoire Valeur par défaut
R2_ACCOUNT_ID ID du compte Cloudflare ✅ -
R2_BUCKET_NAME Nom du bucket (ex: pub-medias) ✅ -
R2_ACCESS_KEY_ID Clé d’accès API R2 ✅ -
R2_SECRET_ACCESS_KEY Clé secrète API R2 ✅ -
R2_PUBLIC_DOMAIN Domaine public (ex: pub-medias.votredomaine.com) ✅ -

3.2. Configuration Wrangler (wrangler.toml)

Section titled “3.2. Configuration Wrangler (wrangler.toml)”
services/media-upload/wrangler.toml
name = "media-upload"
main = "src/index.ts"
compatibility_date = "2026-09-01"
[[r2_buckets]]
binding = "R2_BUCKET"
bucket_name = "pub-medias"
preview_bucket_name = "pub-medias-staging"
[vars]
R2_ACCOUNT_ID = "your-account-id"
R2_BUCKET_NAME = "pub-medias"
R2_PUBLIC_DOMAIN = "pub-medias.votredomaine.com"
[env.production]
vars = { R2_BUCKET_NAME = "pub-medias" }
[env.staging]
vars = { R2_BUCKET_NAME = "pub-medias-staging" }
Terminal window
# Injection des credentials R2 via Wrangler CLI
wrangler secret put R2_ACCESS_KEY_ID --env production
wrangler secret put R2_SECRET_ACCESS_KEY --env production
wrangler secret put R2_ACCESS_KEY_ID --env staging
wrangler secret put R2_SECRET_ACCESS_KEY --env staging

Dans le Dashboard Cloudflare :

  1. R2 → Bucket pub-medias → Settings
  2. Custom domains → Connect Domain
  3. Domaine : pub-medias.votredomaine.com
  4. Cloudflare proxy activé (orange cloud)
pub-medias/
├── medias/
│ ├── images/
│ │ ├── blog/ # Images des articles de blog
│ │ │ └── 2026-09-03-article-title-image.webp
│ │ ├── portfolio/ # Images des projets portfolio
│ │ │ └── project-name-gallery-01.webp
│ │ └── pages/ # Images des pages vitrine
│ │ └── hero-background.webp
│ ├── videos/ # Fichiers sources (si besoin)
│ │ └── raw-video.mp4
│ ├── documents/
│ │ ├── pdf/ # Documents PDF
│ │ │ └── whitepaper-2026.pdf
│ │ └── autres/ # Autres documents
│ │ └── presentation.pptx
│ └── temp/ # Uploads temporaires (nettoyage auto)
│ └── upload-123456.jpg
└── backups/
└── sanity/ # Backups Sanity
└── backup-2026-09-03.tar.gz
Type Format Exemple
Images blog YYYY-MM-DD-slug-image.webp 2026-09-03-mon-article-hero.webp
Images portfolio project-name-gallery-XX.webp projet-alpha-gallery-01.webp
Images pages page-slug-block-image.webp accueil-hero-background.webp
Documents document-name-YYYY.pdf whitepaper-2026.pdf
Temp upload-{timestamp}-{filename} upload-1693735200-image.jpg
Backups backup-YYYY-MM-DD.tar.gz backup-2026-09-03.tar.gz
  • ✅ Toujours préfixer par medias/ pour les médias du site
  • ✅ Séparer par type (images, videos, documents)
  • ✅ Séparer par contexte (blog, portfolio, pages)
  • ✅ Utiliser des noms de fichiers descriptifs et en kebab-case
  • ✅ Inclure la date dans le nom pour les articles de blog
  • ✅ Le dossier temp/ est réservé aux uploads temporaires
Aspect Configuration Rationale
Formats WebP, AVIF (auto via Cloudflare) Performance optimale
Optimisation Cloudflare Polish (lossy/lossless) Réduction de taille automatique
Redimensionnement Cloudflare Images (si besoin) Transformation à la volée
Dimensions max 1920x1080 (contenu), 3840x2160 (hero) Équilibre qualité/performance
Poids max 500 KB (contenu), 2 MB (hero) Performance de chargement
Métadonnées Alt, hotspot, dimensions dans Sanity Accessibilité + cropping intelligent

URL publique :

https://pub-medias.votredomaine.com/medias/images/blog/2026-09-03-article-image.webp

URL avec transformation Cloudflare Images :

https://pub-medias.votredomaine.com/cdn-cgi/image/width=800,height=600,fit=cover/medias/images/blog/2026-09-03-article-image.webp
Aspect Configuration Rationale
Stockage Cloudflare Stream (pas R2) Transcoding + adaptive bitrate
Upload Direct Upload Token via Worker L3 Évite de transiter par le Worker
Stockage métadonnées UID Stream dans Sanity Référence vers la vidéo
Lecture Iframe Stream ou SDK officiel Player intégré
Durée max 3600 secondes (1 heure) Limite Stream

UID Stream stocké dans Sanity :

{
"_type": "video",
"streamUid": "abc123def456",
"title": "Présentation du projet"
}

Embed dans Astro :

<iframe
src="https://iframe.videodelivery.net/abc123def456"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture"
allowfullscreen
></iframe>
Aspect Configuration Rationale
Stockage Cloudflare R2 Simplicité + performance
Accès URL publique ou signée Selon sensibilité
Prévisualisation Miniature générée par Worker (optionnel) UX améliorée
Poids max 10 MB Limite raisonnable

URL publique :

https://pub-medias.votredomaine.com/medias/documents/pdf/whitepaper-2026.pdf

URL signée (si sensible) :

https://pub-medias.votredomaine.com/medias/documents/pdf/confidentiel.pdf?X-Amz-Algorithm=...
Aspect Configuration Rationale
Stockage Bucket R2 dédié (backups-sanity) Isolation des backups
Fréquence Quotidienne (Cron: 0 2 * * *) RPO de 24h
Format .tar.gz Compression efficace
Rétention 30 jours (Lifecycle Rules R2) Équilibre coût/sécurité
Streaming Pipe direct Sanity → R2 (pas de RAM) Évite OOM

Chemin de backup :

backups-sanity/backups/sanity/backup-2026-09-03.tar.gz

6. Upload et génération d’URLs présignées

Section titled “6. Upload et génération d’URLs présignées”

6.1. Flux d’upload (Worker media-upload)

Section titled “6.1. Flux d’upload (Worker media-upload)”
// services/media-upload/src/index.ts (extrait)
import { AwsClient } from 'aws4fetch';
export interface Env {
R2_BUCKET: R2Bucket;
R2_ACCOUNT_ID: string;
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
R2_BUCKET_NAME: string;
CLOUDFLARE_STREAM_API_TOKEN: string;
CLOUDFLARE_ACCOUNT_ID: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { type, filename, contentType } = await request.json();
// Case 1: Upload vidéo via Cloudflare Stream Direct Upload
if (type === 'video') {
const streamRes = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CLOUDFLARE_ACCOUNT_ID}/stream/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${env.CLOUDFLARE_STREAM_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ maxDurationSeconds: 3600 }),
}
);
const streamData = await streamRes.json();
return new Response(JSON.stringify({
uploadUrl: streamData.result.uploadURL,
uid: streamData.result.uid
}), { status: 200 });
}
// Case 2: Upload images/documents via URL présignée R2
const aws = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
service: 's3',
region: 'auto',
});
const key = `medias/${type}s/${Date.now()}-${filename}`;
const url = new URL(`https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.R2_BUCKET_NAME}/${key}`);
url.searchParams.set('X-Amz-Expires', '900'); // Valide 15 minutes
const signedRequest = await aws.sign(new Request(url.toString(), {
method: 'PUT',
headers: { 'Content-Type': contentType },
}), { aws: { signQuery: true } });
return new Response(JSON.stringify({
uploadUrl: signedRequest.url,
fileUrl: `https://${env.R2_PUBLIC_DOMAIN}/${key}`,
key,
}), { status: 200 });
},
};
sanity-studio/plugins/media-upload/input.tsx
const handleUpload = async (file: File) => {
const res = await fetch('https://api.votredomaine.com/media-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: file.type.startsWith('image/') ? 'image' : 'document',
filename: file.name,
contentType: file.type,
}),
});
const { uploadUrl, fileUrl, key } = await res.json();
// Upload direct vers R2 (sans transiter par le Worker)
await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
// Retourne l'URL publique à Sanity
return {
asset: {
_type: 'file',
url: fileUrl,
metadata: {
key,
originalFilename: file.name,
},
},
};
};

7.1. Worker media-cleanup (Cron hebdomadaire)

Section titled “7.1. Worker media-cleanup (Cron hebdomadaire)”

Spécifications :

  • Fréquence : 0 3 * * 0 (tous les dimanches à 3h00)
  • Objectif : Supprimer les fichiers orphelins (non référencés dans Sanity depuis 30 jours)
  • Safety Locks :
    1. Annulation si Sanity renvoie moins de 5 documents (protection panne API)
    2. Annulation si suppression > 10% du bucket (protection contre erreur massive)

Code de production :

// services/media-cleanup/src/index.ts (extrait)
export default {
async scheduled(event: ScheduledEvent, env: Env): Promise<void> {
// 1. Requête GROQ pour récupérer toutes les URLs référencées
const query = encodeURIComponent(`*[_type in ["post", "project", "page"]]{
"images": [mainImage.asset->url, gallery[].asset->url, pageBuilder[].images[].asset->url]
}`);
const sanityRes = await fetch(
`https://${env.SANITY_PROJECT_ID}.api.sanity.io/v2025-01-01/data/query/${env.SANITY_DATASET}?query=${query}`,
{ headers: { Authorization: `Bearer ${env.SANITY_READ_TOKEN}` } }
);
const sanityData = await sanityRes.json();
const referencedUrls = new Set<string>();
sanityData.result.forEach(doc => {
doc.images?.flat().filter(Boolean).forEach(url => referencedUrls.add(url));
});
// Safety Lock #1
if (referencedUrls.size < 5) {
console.error('SAFETY LOCK : Volume Sanity trop faible. Annulation.');
return;
}
// 2. Listing des objets R2
const objects = await env.R2_BUCKET.list({ prefix: 'medias/' });
const toDelete: string[] = [];
const now = Date.now();
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
for (const obj of objects.objects) {
const publicUrl = `https://${env.R2_PUBLIC_DOMAIN}/${obj.key}`;
const isOrphan = !referencedUrls.has(publicUrl);
const isOlderThan30Days = (now - obj.uploaded.getTime()) > THIRTY_DAYS_MS;
if (isOrphan && isOlderThan30Days) {
toDelete.push(obj.key);
}
}
// Safety Lock #2
if (toDelete.length > (objects.objects.length * 0.10)) {
console.error(`SAFETY LOCK : Suppression > 10%. Annulation.`);
return;
}
// 3. Suppression
for (const key of toDelete) {
await env.R2_BUCKET.delete(key);
}
},
};
  • Fréquence : Quotidienne (Cron: 0 4 * * *)
  • Rétention : 7 jours
  • Méthode : Lifecycle Rules natives de R2 ou Worker dédié
Règle Condition Action
Suppression temp/ Fichiers > 7 jours Delete
Archivage backups/ Fichiers > 30 jours Delete
Transition videos/raw/ Fichiers > 90 jours Delete (après transcoding Stream)
Type Usage Configuration
Public Images du site, documents non sensibles Domaine public R2 + CDN Cloudflare
Signé Documents sensibles, previews URLs présignées avec expiration
Privé Backups, fichiers internes Accès via Worker uniquement
// Exemple de génération d'URL signée (valide 1 heure)
const aws = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
service: 's3',
region: 'auto',
});
const url = new URL(`https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.R2_BUCKET_NAME}/${key}`);
url.searchParams.set('X-Amz-Expires', '3600'); // 1 heure
const signedRequest = await aws.sign(new Request(url.toString(), {
method: 'GET',
}), { aws: { signQuery: true } });
return signedRequest.url;
  • ✅ Les credentials R2 ne sont jamais exposés côté client
  • ✅ Les credentials sont injectés via wrangler secret put
  • ✅ Les URLs signées ont une durée de vie limitée (1h à 24h max)
  • ✅ Le domaine public R2 est protégé par le proxy Cloudflare (WAF, DDoS)
  • ✅ Les buckets de backups sont isolés des buckets publics
  • ✅ Les uploads temporaires sont automatiquement nettoyés
# Configuration CORS dans le Dashboard R2
[cors]
allowed_origins = ["https://votresite.com", "https://studio.votresite.com"]
allowed_methods = ["GET", "PUT", "POST"]
allowed_headers = ["*"]
max_age = 3600

Sanity ne stocke que les métadonnées et les URLs R2/Stream :

{
"_type": "image",
"asset": {
"_type": "reference",
"_ref": "image-abc123"
},
"url": "https://pub-medias.votredomaine.com/medias/images/blog/2026-09-03-article.webp",
"metadata": {
"key": "medias/images/blog/2026-09-03-article.webp",
"dimensions": {
"width": 1920,
"height": 1080
},
"alt": "Description de l'image pour l'accessibilité",
"hotspot": {
"x": 0.5,
"y": 0.5,
"width": 0.5,
"height": 0.5
}
}
}
sanity-studio/schemas/fields/customImage.ts
export const customImageField = {
name: 'mainImage',
title: 'Image principale',
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'alt',
title: 'Texte alternatif',
type: 'string',
validation: Rule => Rule.required(),
},
{
name: 'r2Key',
title: 'Clé R2',
type: 'string',
readOnly: true,
description: 'Chemin du fichier dans R2 (généré automatiquement)',
},
],
};
*[_type == "post"]{
title,
mainImage {
url,
metadata {
dimensions,
alt,
hotspot
}
},
gallery[] {
url,
metadata {
dimensions,
alt
}
}
}
src/pages/blog/[slug].astro
---
import { getSanityClient } from '../../lib/sanity';
const post = await getSanityClient(Astro).fetch(`*[_type == "post" && slug.current == $slug][0]{
title,
mainImage {
url,
metadata {
dimensions,
alt
}
}
}`, { slug: Astro.params.slug });
---
<img
src={post.mainImage.url}
alt={post.mainImage.metadata.alt}
width={post.mainImage.metadata.dimensions.width}
height={post.mainImage.metadata.dimensions.height}
loading="lazy"
decoding="async"
/>

10.2. Utilisation de Cloudflare Images (transformation à la volée)

Section titled “10.2. Utilisation de Cloudflare Images (transformation à la volée)”
---
const imageUrl = post.mainImage.url;
const transformedUrl = `https://pub-medias.votredomaine.com/cdn-cgi/image/width=800,height=600,fit=cover,format=webp/${imageUrl.replace('https://pub-medias.votredomaine.com/', '')}`;
---
<img
src={transformedUrl}
alt={post.mainImage.metadata.alt}
width="800"
height="600"
loading="lazy"
/>
src/components/ui/OptimizedImage.astro
---
interface Props {
src: string;
alt: string;
width?: number;
height?: number;
loading?: 'lazy' | 'eager';
sizes?: string;
}
const { src, alt, width, height, loading = 'lazy', sizes } = Astro.props;
// Génération automatique du srcset pour le responsive
const srcset = width
? [
`${transformUrl(src, width * 2)} 2x`,
`${transformUrl(src, width)} 1x`,
`${transformUrl(src, width / 2)} 0.5x`,
].join(', ')
: undefined;
---
<img
src={src}
srcset={srcset}
sizes={sizes || '(max-width: 768px) 100vw, 50vw'}
alt={alt}
width={width}
height={height}
loading={loading}
decoding="async"
/>
Technique Implémentation Gain
Cloudflare Polish Activé sur la zone -30% à -50% de taille
Cloudflare Images Transformation à la volée Adaptation automatique
Formats modernes WebP/AVIF auto -25% à -40% vs JPEG
Lazy loading loading="lazy" Chargement différé
Srcset responsive srcset + sizes Adaptation à l’écran
Dimensions explicites width + height Évite le CLS
Type de fichier Cache-Control TTL
Images public, max-age=31536000, immutable 1 an
Vidéos Stream Géré par Stream N/A
Documents PDF public, max-age=86400 1 jour
Backups private, no-cache 0 (accès Worker uniquement)
  • ✅ Toujours fournir width, height et alt
  • ✅ Utiliser loading="lazy" sauf pour les images LCP (Hero)
  • ✅ Générer des srcset / sizes pour le responsive
  • ✅ Optimiser les images avant upload (max 500 KB)
  • ✅ Utiliser WebP/AVIF (conversion automatique via Cloudflare)
  • ✅ Ne jamais servir de vidéos depuis R2 (utiliser Stream)
Symptôme Cause probable Solution
Image 404 Clé R2 incorrecte Vérifier le chemin dans Sanity et R2
Upload échoue URL présignée expirée Régénérer l’URL (valide 15 min)
CORS error Domaine non autorisé Ajouter le domaine dans la config CORS R2
Image non optimisée Polish non activé Activer Polish dans le Dashboard Cloudflare
CLS (Cumulative Layout Shift) Dimensions manquantes Toujours fournir width + height
Vidéo ne lit pas UID Stream incorrect Vérifier l’UID stocké dans Sanity
Terminal window
# Vérifier les objets dans R2 via Wrangler CLI
wrangler r2 object list pub-medias --prefix medias/images/blog/
# Vérifier les logs du Worker media-cleanup
wrangler tail media-cleanup
# Vérifier les logs du Worker media-upload
wrangler tail media-upload
Terminal window
# Test d'upload via URL présignée
curl -X PUT "https://[signed-url]" \
-H "Content-Type: image/webp" \
--data-binary @test.webp
# Test d'accès public
curl -I "https://pub-medias.votredomaine.com/medias/images/blog/test.webp"
# Attendu : 200 OK + headers de cache
# Test d'accès signé
curl -I "https://[signed-url]"
# Attendu : 200 OK (si URL valide)
  • ✅ Toujours utiliser des URLs présignées pour les uploads (pas de transit par Worker)
  • ✅ Valider les types de fichiers avant upload (images, vidéos, documents uniquement)
  • ✅ Logger tous les uploads et suppressions pour l’audit
  • ✅ Tester les safety locks du Worker media-cleanup en staging
  • ✅ Documenter les changements de configuration dans ce fichier
  • ✅ Optimiser les images avant upload (max 500 KB, dimensions adaptées)
  • ✅ Toujours fournir un texte alternatif descriptif
  • ✅ Utiliser des noms de fichiers descriptifs (pas IMG_1234.jpg)
  • ✅ Ne pas uploader de vidéos directement (passer par Cloudflare Stream)
  • ✅ Vérifier le rendu responsive avant publication
  • ⚠️ R2 facture le stockage + les opérations (lectures/écritures)
  • ⚠️ Stream facture le stockage + le transcoding + la lecture
  • ✅ Nettoyer régulièrement les orphelins (Worker media-cleanup)
  • ✅ Utiliser les Lifecycle Rules pour supprimer automatiquement les vieux fichiers
  • ✅ Optimiser les images pour réduire le stockage et la bande passante
  • [Évolution 1 : ex. Génération automatique de miniatures pour les PDF]
  • [Évolution 2 : ex. Support du format AVIF natif]
  • [Évolution 3 : ex. Dashboard d’audit des médias orphelins]
  • ❌ R2 ne supporte pas nativement la transformation d’images (nécessite Cloudflare Images)
  • ❌ Les URLs signées ont une durée de vie limitée (pas de partage long terme)
  • ❌ Le nettoyage des orphelins peut être lent avec beaucoup de fichiers (> 10 000)
  • Documentation Cloudflare R2 : Getting Started
  • Documentation Cloudflare Stream : Getting Started
  • Documentation Cloudflare Images : Getting Started
  • DAT L1 : §4.8 - Gestion des Médias (Cloudflare R2 + Stream)
  • DAT L2 : §5.4 - Consommation et optimisation des médias
  • DAT L3 : §5.3 - Worker media-upload
  • DAT L3 : §5.4 - Worker media-cleanup
  • DAT L3 : §5.5 - Worker sanity-backup
  • Fichiers concernés :
    • services/media-upload/wrangler.toml
    • services/media-cleanup/wrangler.toml
    • services/sanity-backup/wrangler.toml
    • Configuration Dashboard Cloudflare → R2 → Buckets
Date Version Description Auteur
YYYY-MM-DD 1.0 Création initiale [Nom]
YYYY-MM-DD 1.1 [Modification] [Nom]
---
**Points clés de ce template :**
- Documente l'architecture complète de stockage (R2 + Stream)
- Détaille l'organisation des chemins et conventions de nommage
- Inclut les mécanismes d'upload (URLs présignées, Direct Upload Stream)
- Explique le nettoyage automatique (orphelins, temp, backups)
- Fournit l'intégration avec Sanity (métadonnées) et Astro (consommation)
- Couvre la sécurité (accès public/signé/privé, CORS)
- Inclut les bonnes pratiques de performance et d'optimisation
Edit page

Last updated: