Cloudflare R2 + Laravel : stockage de médias à faible coût sans compromis

R2 est un stockage compatible S3 avec des prix prévisibles et un CDN à portée de clic. Ce guide montre comment l'intégrer dans Laravel pour les fichiers publics et privés, comment configurer une mise en cache correcte et comment envoyer votre build Vite directement vers R2 avec des versions vraiment atomiques.

Cloudflare R2 + Laravel : stockage de médias à faible coût sans compromis

Pourquoi R2 convient aux projets Laravel

Presque tous les produits posent les mêmes questions : où conserver les fichiers des utilisateurs, comment ne pas surpayer la bande passante, comment faire en sorte que les images et les scripts arrivent instantanément, et comment publier le frontend sans liens "cassés". R2 parle le dialecte familier de S3 et s'associe parfaitement à un CDN global, ce qui signifie des changements minimes dans votre application et un chemin court vers des en-têtes de mise en cache idéaux. Si vous servez des actifs à partir de votre serveur aujourd'hui et que vous les déplacez vers la périphérie demain, la transition se fera en douceur et avec peu de risques.

Bien entendu, toute "magie" infrastructurelle comporte des compromis. Une intégration étroite du CDN est fantastique pour la vitesse et pour des mises à jour bon marché et adaptées au cache, mais vous devez respecter les règles du fournisseur (par exemple, la manière dont l'accès public est géré) et renoncer à quelques subtilités du S3. Vous trouverez ci-dessous un aperçu honnête de ce que vous obtenez dans la boîte et des points sur lesquels une petite adaptation peut s'avérer utile.

Les points forts et les points faibles de R2

  • Points forts. Compatibilité S3 sans friction ; les actifs sont servis à partir d'un CDN POP proche sous votre domaine personnalisé ; sortie prévisible lors de l'utilisation du CDN ; parfait pour les actifs hachés, les sites statiques, les prévisualisations publiques et les blobs multimédias.
  • Compromis. Les fonctionnalités ne sont pas identiques à celles de S3 classique (par exemple, l'accès public est configuré au niveau du godet/domaine plutôt que via les en-têtes ACL par objet) ; l'écosystème tiers plus large des plus grands fournisseurs de S3 est encore plus important ; les meilleurs résultats sont obtenus lorsque vous adoptez l'association CDN du fournisseur.

Ce que cela signifie en pratique : traitez les fichiers publics comme du contenu de bord pouvant être mis en cache (noms immuables, TTL long), traitez les fichiers privés comme des requêtes S3 signées (liens éphémères ou un proxy de backend), et poussez vos artefacts de construction vers le seau avec les en-têtes que vous voulez que les utilisateurs voient.

Concepts R2 en 2 minutes

  • Bucket : un conteneur pour vos objets. Pour la diffusion publique, vous attachez un domaine personnalisé au bucket et activez l'accès public au niveau du bucket.
  • Point de terminaison : Point de terminaison compatible S3 tel que https://<ACCOUNT_ID>.r2.cloudflarestorage.com. Les SDK s'y connectent ; les navigateurs utilisent généralement votre domaine personnalisé (CDN).
  • Public ou privé : les objets publics sont simplement récupérés par URL via le CDN ; les objets privés sont demandés via des requêtes signées (signature S3 temporaire) ou servis par votre propre backend/Worker.
  • Mise en cache : les POP de bordure respectent les en-têtes de vos objets (Cache-Control, ETag, Content-Type). Noms immuables = zéro invalidation.

Câbler R2 dans Laravel

Prérequis (adaptateur S3 pour Flysystem) :

composer require league/flysystem-aws-s3-v3:^3.0

Configurez un disque S3 normal (les éléments importants sont endpoint et l'accès de type chemin) :

// config/filesystems.php
'disks' => [
    'r2' => [
        'driver' => 's3',
        'key' => env('R2_KEY'),
        'secret' => env('R2_SECRET'),
        'region' => 'auto',
        'bucket' => env('R2_BUCKET'),
        'endpoint' => env('R2_ENDPOINT'), // https://<ACCOUNT_ID>.r2.cloudflarestorage.com
        'use_path_style_endpoint' => true,
        'url' => env('R2_PUBLIC_BASE'),   // https://assets.example.com (your CDN domain)
        'visibility' => 'private',
    ],
],
# .env
R2_KEY=...
R2_SECRET=...
R2_BUCKET=your-bucket
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
R2_PUBLIC_BASE=https://assets.example.com

Utilisez-le comme n'importe quel disque S3 :

// Public file
Storage::disk('r2')->put('media/avatars/u123.jpg', $binary);
$url = Storage::disk('r2')->url('media/avatars/u123.jpg');

// Private file (serve via a temporary signed URL)
$link = Storage::disk('r2')->temporaryUrl(
    'private/invoice-2025-09.pdf',
    now()->addMinutes(5)
);

Modèle à deux disques (optionnel mais pratique)

Pour rendre l'intention explicite, créez les disques r2_public et r2_private avec des URL de base différentes et des en-têtes par défaut lors du téléchargement :

// config/filesystems.php
'disks' => [
  'r2_public' => [
    'driver' => 's3',
    'key' => env('R2_KEY'),
    'secret' => env('R2_SECRET'),
    'region' => 'auto',
    'bucket' => env('R2_BUCKET'),
    'endpoint' => env('R2_ENDPOINT'),
    'use_path_style_endpoint' => true,
    'url' => env('R2_PUBLIC_BASE'),
    'visibility' => 'public',
  ],
  'r2_private' => [
    'driver' => 's3',
    'key' => env('R2_KEY'),
    'secret' => env('R2_SECRET'),
    'region' => 'auto',
    'bucket' => env('R2_BUCKET'),
    'endpoint' => env('R2_ENDPOINT'),
    'use_path_style_endpoint' => true,
    'visibility' => 'private',
  ],
],

Public vs privé : divisé par des préfixes

Une convention simple et durable :

  • build/ - les actifs frontaux (JS/CSS/fonts/icons), publics, mis en cache "pour toujours".
  • media/ - images/révisions publiques, TTL modéré.
  • private/ - documents fermés et originaux, servis uniquement via temporaryUrl().

Pourquoi des préfixes ? Ils rendent l'automatisation du cycle de vie triviale : les en-têtes au moment du téléchargement, les règles CDN et les politiques d'accès peuvent cibler des chemins simples. En outre, le nettoyage et le retour en arrière sont plus sûrs lorsque les ressources sont regroupées.

Cache de bordure : quels en-têtes définir

  • Pour les actifs hachés (par exemple, app.78e2a3.js) : Cache-Control: public, max-age=31536000, immutable
  • Pour les manifestes, les configurations, le JSON et d'autres fichiers "vivants" : Cache-Control: public, max-age=60

Définissez les en-têtes au moment du téléchargement pour qu'ils restent attachés à l'objet. Les navigateurs + CDN respecteront également ETag et serviront des 304 le cas échéant. Le nommage immuable est ce qui élimine les invalidations de cache.

Le type de contenu est important (polices/images)

Les navigateurs sont difficiles avec font/woff2 et image/svg+xml. Lors du téléchargement, passez explicitement ContentType:

Storage::disk('r2_public')->put(
  'build/assets/app.78e2a3.woff2',
  $bytes,
  ['visibility' => 'public', 'ContentType' => 'font/woff2', 'CacheControl' => 'public, max-age=31536000, immutable']
);

CORS pour les polices et XHR

Si votre application fonctionne sur app.example.com et les actifs sur assets.example.com, configurez CORS sur le bucket pour permettre aux polices et aux récupérations JSON de se charger sans erreur :

[
  {
    "AllowedMethods": ["GET", "HEAD", "OPTIONS"],
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag", "Content-Length"],
    "MaxAgeSeconds": 86400
  }
]

Envoyez votre version de Vite directement à R2 (versions atomiques)

L'idée : exécuter npm run build en CI et laisser un téléchargeur Vite compatible S3 pousser public/build vers R2. Le chemin de votre bucket et l'URL publique doivent correspondre à l'URL de Vite base.

Variables d'environnement

R2_ACCOUNT_ID=xxxxxxxxxxxxxxxxxxxx
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=my-cdn
R2_PUBLIC_BASE=https://assets.example.com          # CDN domain
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
UPLOAD_ENABLED=1                                    # enable upload in CI

Configuration de Vite

Points clés : définissez base à CDN-domain + /build/; utilisez un accès de type chemin pour le client S3 ; ne vous fiez pas aux ACL pour l'accès public (configurez plutôt bucket/domain) ; effectuez deux téléchargements pour l'atomicité - les actifs d'abord (TTL long), puis manifest.json (TTL court).

// vite.config.ts
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import { ViteS3 } from '@froxz/vite-plugin-s3'

const enabled = !!process.env.UPLOAD_ENABLED

const clientConfig = {
  region: 'auto',
  endpoint: process.env.R2_ENDPOINT!,
  forcePathStyle: true, // safer for R2
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
}

export default defineConfig({
  base: `${process.env.R2_PUBLIC_BASE}/build/`,
  build: { manifest: true, outDir: 'public/build', emptyOutDir: true },
  plugins: [
    laravel({
      input: ['resources/js/app.ts', 'resources/css/app.css'],
      refresh: true,
      buildDirectory: 'build',
    }),

    // 1) Hashed assets — long TTL + immutable
    ViteS3(enabled, {
      directory: 'public/build',
      basePath: '/build',
      include: [
        /\/assets\/.+\.[a-f0-9]{8,}\.(js|css|woff2|png|jpe?g|webp|svg|gif|ico|map)$/i
      ],
      clientConfig,
      uploadOptions: {
        Bucket: process.env.R2_BUCKET!,
        CacheControl: 'public, max-age=31536000, immutable',
      },
    }),

    // 2) manifest.json — short TTL; uploaded after assets
    ViteS3(enabled, {
      directory: 'public/build',
      basePath: '/build',
      include: [/manifest\.json$/],
      sequentialUploads: true,
      clientConfig,
      uploadOptions: {
        Bucket: process.env.R2_BUCKET!,
        CacheControl: 'public, max-age=60',
      },
    }),
  ],
})

Pipeline CI

npm ci
npm run build    # the uploader pushes the build to R2

Conservez UPLOAD_ENABLED=0 localement et activez-le uniquement dans CI. Stockez tous les secrets de R2_* dans le gestionnaire de secrets de votre CI.

Des retours en arrière qui fonctionnent

Parce que les actifs sont immuables, le retour en arrière est juste un changement de manifeste. Conservez l'ancien manifest.json; pour revenir en arrière, téléchargez à nouveau l'ancien manifeste (TTL court) tandis que les actifs restent mis en cache avec un TTL long. Pas d'invalidation, pas d'attente.

Modèles de livraison privée

Pour les fichiers sensibles, utilisez l'un de ces modèles :

  1. Liens temporaires de type S3. Utilisez Storage::disk('r2')->temporaryUrl(). Simple et rapide. La révocation se fait en expirant le lien rapidement (quelques minutes).
  2. Itinéraire de proxy dorsal. Autorisation dans Laravel, puis flux à partir de R2 avec les informations d'identification de votre application. Vous pouvez définir Content-Disposition(en ligne ou en pièce jointe) et enregistrer l'accès de manière centralisée.
  3. Cloudflare Worker. Une minuscule fonction périphérique qui valide un cookie JWT/session et récupère à partir de R2, en gardant le trafic binaire à la périphérie.

Exemple : Contrôleur proxy Laravel

// routes/web.php
Route::get('/files/{path}', App\Http\Controllers\PrivateFileController::class)
  ->where('path', '.*')
  ->middleware(['auth']);

// app/Http/Controllers/PrivateFileController.php
public function __invoke(string $path)
{
    abort_unless(Str::startsWith($path, 'private/'), 404);

    $stream = Storage::disk('r2_private')->readStream($path);
    abort_unless($stream, 404);

    return response()->stream(function () use ($stream) {
        fpassthru($stream);
    }, 200, [
        'Content-Type' => Storage::disk('r2_private')->mimeType($path) ?? 'application/octet-stream',
        'Content-Disposition' => 'inline', // or 'attachment'
        'Cache-Control' => 'private, max-age=0, no-store',
    ]);
}

Exemple : idée minimale de Worker

Pseudocode : vérifier un jeton signé (HMAC/JWT), récupérer private/<key> à partir de R2 avec les informations d'identification du service, définir Content-Disposition, renvoyer le flux. Le résultat vit près de l'utilisateur et ne touche jamais votre runtime PHP.

Transformations d'images : trois options solides

  1. Conversions en arrière-plan dans Laravel. Téléchargez l'original, mettez en file d'attente un travail, générez des tailles/formats, utilisez des noms à empreintes digitales comme photo@800w.a1b2c3.jpg.
  2. Transformation des bords. Redimensionnez les prévisualisations publiques à la périphérie (Workers/Images) et mettez le résultat en cache sur le CDN.
  3. Hybride. Tailles fréquentes à la périphérie ; tailles exotiques dans les tâches d'arrière-plan. Les noms à empreintes digitales vous permettent de conserver immutable en toute sécurité.

Conseil : quelle que soit l'approche choisie, conservez l'URL dérivée (ou le chemin) que vous rendez réellement. Cela permet d'éviter les recalculs surprises et de garder vos modèles simples.

Sécurité et accès

  • Divisez les identifiants. Un pour l'application web (lecture/écriture de préfixes spécifiques), un autre pour le CI (écriture uniquement sur build/).
  • Principe du moindre privilège : refusez tout, puis autorisez PutObject pour build/* vers CI ; autorisez Put/Get/List/Delete pour media/* vers l'application si vous avez besoin de modifications.
  • Région/juridiction. Utilisez la variante de point de terminaison correcte si votre panier est verrouillé à une juridiction spécifique pour éviter les problèmes de signature/redirection.
  • Hygiène des secrets : gardez les clés dans les coffres env/CI, jamais dans vite.config.ts ou repo.

Pièges courants

  • Types MIME pour les polices/images. Assurez-vous que Content-Type est correct sur les objets - les navigateurs sont pointilleux sur font/woff2 et image/svg+xml.
  • manifeste "collant". Gardez un TTL court et téléchargez le manifeste en dernier.
  • Chemins d'accès non concordants. Le site base doit correspondre au chemin réel du CDN (par exemple, https://assets.example.com/build/…).
  • Horloge décalée et signatures. Les URL temporaires peuvent échouer si l'horloge de votre serveur dérive. NTP corrige les erreurs mystérieuses de SignatureDoesNotMatch.
  • Mauvais mode d'adressage. R2 préfère le style de chemin (forcePathStyle). Les requêtes hébergées virtuellement peuvent être 403/redirigées.
  • CORS. Si les polices échouent sur Safari/Firefox, ajoutez CORS pour l'origine de votre application et exposez ETag.

Liste de contrôle de la mise en œuvre

  1. Créez un bucket et activez Public Bucket; attachez un Custom Domain.
  2. Installez l'adaptateur S3 et configurez les disques r2 (ou divisez public/privé) avec endpoint, use_path_style_endpoint=true, et url défini à votre domaine CDN.
  3. Divisez les préfixes : build/, media/, private/.
  4. Activez Vite → R2 : définissez base pour le CDN ; effectuez deux téléchargements (actifs → manifeste) ; préférez un client de type chemin.
  5. Définissez les en-têtes : assets hachés - immutable + 1y; manifest - TTL court ; media - TTL pratique (par exemple, heures-jours).
  6. Facultatif : conversion des images d'arrière-plan et de bord.
  7. Isolez les informations d'identification de l'application et de la CI. Ajoutez CORS pour l'origine de votre application.
  8. Planifiez les retours en arrière : conservez le manifeste précédent ; retour en arrière = rechargement de l'ancien manifeste.

Mentalité en matière de coûts et de limites (non exhaustive)

Pensez en termes d'opérations et de sorties. Les économies les plus importantes apparaissent lorsque la majeure partie du trafic public est desservie par le CDN avec un cache à longue durée de vie ; les poussées de l'IC sont minuscules par rapport aux lectures des utilisateurs. Si vous transférez les téléchargements des utilisateurs vers media/ avec un TTL modéré et que vous préférez les vignettes/aperçus aux originaux dans l'interface utilisateur, vous réduisez le nombre d'octets et le temps nécessaire à la première peinture. Suivez le nombre d'objets, les taux de requêtes et le taux de réussite du cache pour éviter les surprises.

Référence rapide pour le dépannage

  • 403 sur l'URL temporaire : vérifiez le décalage d'horloge, les informations d'identification et que l'hôte de la demande correspond à l'hôte signé (le SDK signe souvent le point de terminaison, pas votre domaine CDN).
  • Police bloquée : ajoutez/relâchez CORS, exposez ETag, confirmez Content-Type.
  • 404 après le déploiement : probablement une erreur de base ou vous avez téléchargé manifest.json avant les actifs.
  • Premier résultat lent : cache de bord froid attendu. Utilisez le préchauffage (optionnel) ou acceptez la taxe du premier échec ; les résultats suivants sont instantanés.

Conclusion

Si vous voulez un frontend rapide, un stockage peu coûteux et moins de pièces mobiles, le combo Laravel + R2 tient ses promesses : des API de fichiers familières, une livraison CDN prête à l'emploi et des versions Vite bien rangées sans chemins brisés. Vous renoncerez à quelques avantages spécifiques à S3, mais en échange vous obtiendrez la vitesse, la simplicité et la prévisibilité des coûts - les choses que les équipes apprécient le plus au jour le jour.


Contactez-nous

Un projet en tête?

Indiquez le contexte et l’objectif visé. Nous répondons sous 1 jour ouvrable avec la prochaine étape la plus simple (planning, budget indicatif ou audit rapide).

En envoyant, vous acceptez que nous traitions vos données pour répondre à votre demande et, le cas échéant, prendre des mesures précontractuelles à votre demande (RGPD art. 6(1)(b)) ou sur la base de nos intérêts légitimes (art. 6(1)(f)). Évitez de partager des données sensibles. Voir notre Politique de confidentialité.
Réponse sous 1 jour ouvrable.