DIY parte 5: Soberania de Storage -Migrando o blog para o Cloudflare R2

Demostenes Albert Por Demostenes Albert 10 min de leitura
DIY parte 5: Soberania de Storage -Migrando o blog para o Cloudflare R2
Na parte 1 eu falei sobre Soberania Técnica. Na parte 2 vieram os detalhes sujos de como o negócio funciona. Na parte 3 eu deixei a IA desenhar porque arte plástica nunca foi minha praia. Na parte 4 aprendi a não apagar meus próprios dados.

Enquanto o código já era meu, o storage ainda não era. E esse é o tipo de dependência que só aparece quando dá problema, um pico de tráfego que vira conta de banda, um deploy descuidado que apaga uma capa gerada pelo Nanobanana da parte 3. As imagens e vídeos ainda viviam dentro do próprio servidor, em storage/app/public/, e isso incomodava.

A solução: Cloudflare R2. 10 GB grátis, zero taxa de egresso, compatível com a API S3. Esse artigo documenta como fiz a migração sem perder nenhum arquivo e sem quebrar nenhum link existente.

1. Por que R2 e não S3?


Custo. O S3 cobra por transferência de dados (egresso). O R2 não cobra nada para servir os arquivos, o modelo de negócio do Cloudflare é diferente: eles lucram na camada de rede. Para um blog pessoal que pode viralizar a qualquer momento, isso é paz de espírito.

                      AWS S3    | Cloudflare R2
 Storage (10 GB)    ~$0.23/mês  | Grátis
 Egresso (100 GB)   ~$9/mês     | $0
 API S3 compatible   Sim        | Sim

Para um blog pequeno, isso é irrelevante. Para algo que pode viralizar, e blogs pessoais viralizam sem avisar, isso vira dívida financeira no dia mais inconveniente possível.

2. A Arquitetura


A ideia é simples: uma variável de ambiente (IMAGE_DISK) controla onde os arquivos vão. O código não sabe, e não precisa saber, se está falando com o disco local ou com o R2.
IMAGE_DISK=public  →  /storage/app/public/  →  /storage/ via symlink
IMAGE_DISK=r2      →  Cloudflare R2 bucket  →  cdn.seudominio.com.br


Um único helper global resolve a geração de URLs:
// app/helpers.php
function image_url(?string $path): string
{
    if (empty($path)) {
        return '';
    }
    return Storage::disk(config('filesystems.image_disk', 'public'))->url($path);
}


Com isso, em qualquer view Blade, em vez de:
{{ asset('storage/' . $post->cover_image) }}


Passa a ser:
{{ image_url($post->cover_image) }}


Troca de disco sem tocar nas views. Mesma lógica do ImagenService da parte 3, o serviço não sabe onde salva, só sabe que salva.

3. Configurando o Disco R2 no Laravel


O Laravel usa o driver S3 para qualquer storage compatível. O R2 é S3-compatible, então basta apontar o endpoint correto.

Em config/filesystems.php:
'image_disk' => env('IMAGE_DISK', 'public'),

'disks' => [
    // ... public e local existentes ...

    'r2' => [
        'driver'                  => 's3',
        'key'                     => env('R2_ACCESS_KEY_ID'),
        'secret'                  => env('R2_SECRET_ACCESS_KEY'),
        'region'                  => 'auto',
        'bucket'                  => env('R2_BUCKET'),
        'url'                     => env('R2_URL'),
        'endpoint'                => env('R2_ENDPOINT'),
        'use_path_style_endpoint' => true, // obrigatório no R2
        'throw'                   => false,
    ],
],


O use_path_style_endpoint => true é o detalhe que quebra muita cabeça. Sem ele, o SDK tenta acessar bucket.endpoint (estilo virtual-hosted da AWS) e o R2 retorna 403. Descobri isso da pior forma: em produção, com usuário tentando ver a capa de um post.

No .env:
IMAGE_DISK=r2

R2_ACCESS_KEY_ID=sua_access_key
R2_SECRET_ACCESS_KEY=sua_secret_key
R2_BUCKET=nome-do-bucket
R2_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
R2_URL=https://cdn.seudominio.com.br


E a dependência via Composer (o Laravel não vem com o driver S3 por padrão):
composer require league/flysystem-aws-s3-v3


4. Domínio Customizado no R2


Para usar cdn.seudominio.com.br, vá no painel do Cloudflare:
R2 → seu bucket → Settings → Custom Domains → Connect Domain.
Como o domínio já está no Cloudflare, ele configura o DNS automaticamente. Sem burocracia, sem propagação de 48 horas. Essa parte foi tão fácil que fiquei desconfiado achando que tinha errado alguma coisa.

5. Migrando os Arquivos Existentes


O blog já tinha imagens e vídeos no disco local, as capas geradas pelo Nanobanana da parte 3, perfis, avatares do Kikito, imagens embutidas nos posts. Criei um comando Artisan para subir tudo para o R2 sem regenerar nada:
// app/Console/Commands/SyncMediaToR2.php

class SyncMediaToR2 extends Command
{
    protected $signature = 'media:sync-to-r2
                            {--dry-run : Lista os arquivos sem enviar}
                            {--force  : Sobrescreve arquivos já existentes no R2}';

    private const DIRS = ['covers', 'profiles', 'ai-avatars', 'post-images', 'post-videos'];

    public function handle(): int
    {
        if (blank(config('filesystems.disks.r2.bucket'))) {
            $this->error('R2_BUCKET não configurado no .env.');
            return self::FAILURE;
        }

        $local = Storage::disk('public');
        $dry   = $this->option('dry-run');

        $files = collect();
        foreach (self::DIRS as $dir) {
            foreach ($local->files($dir) as $file) {
                $files->push($file);
            }
        }
        if ($local->exists('favicon.png')) {
            $files->push('favicon.png');
        }

        if ($dry) {
            $files->each(fn ($f) => $this->line("  → {$f}"));
            return self::SUCCESS;
        }

        $r2    = Storage::disk('r2');
        $force = $this->option('force');

        foreach ($files as $file) {
            if (! $force && $r2->exists($file)) {
                continue;
            }
            $stream = $local->readStream($file);
            $r2->writeStream($file, $stream, ['visibility' => 'public']);
            fclose($stream);
        }

        return self::SUCCESS;
    }
}


Uso:
# Ver o que seria enviado
php artisan media:sync-to-r2 --dry-run

# Enviar tudo
php artisan media:sync-to-r2

# Forçar reenvio (sobrescreve)
php artisan media:sync-to-r2 --force


No meu caso, 89 arquivos foram para o R2 em segundos. Capas, perfis, avatares de IA, imagens de posts, vídeos e o favicon.

6. O Problema que Eu Não Esperava: URLs no Banco de Dados


Capas e fotos de perfil ficam como caminhos relativos no banco (covers/uuid.webp). Tranquilo.
Mas o editor Trix, aquele que eu escolhi na parte 2 exatamente porque normaliza o que você cola, armazena imagens embutidas como URLs absolutas no HTML do conteúdo. Meu banco estava cheio de:
https://demostenesalbert.com.br/storage/post-images/foto.webp


E depois da migração, cheio de:
https://pub-xxxx.r2.dev/post-images/foto.webp


Uma ironia: o mesmo editor que evita sujeira de HTML na parte 2 me causou trabalho aqui. Precisei de um segundo comando para varrer o conteúdo e corrigir as URLs, usando o próprio image_url() como referência:
// app/Console/Commands/FixContentUrls.php

public function handle(): int
{
    $dry    = $this->option('dry-run');
    $oldUrl = $this->option('old-url');

    if ($oldUrl) {
        $newUrl = rtrim(image_url('_'), '/_');
        return $this->process(
            fn (string $text) => str_replace(rtrim($oldUrl, '/'), $newUrl, $text),
            $dry
        );
    }

    $dirs    = implode('|', array_map('preg_quote', self::DIRS));
    $pattern = '#https?://[^/\s"\'<>]+/storage/((?:' . $dirs . ')/[^\s"\'<>&]+)#';

    return $this->process(
        fn (string $text) => preg_replace_callback(
            $pattern,
            fn ($m) => image_url($m[1]),
            $text
        ),
        $dry
    );
}


O detalhe do [^\s"\'<>&]+ no regex merece atenção: o Trix serializa as imagens em JSON dentro do HTML e escapa as aspas como &quot;. Sem parar no &, o regex engolia &quot;width&quot;:1200 como parte da URL. Levei um tempo até ver isso.

Uso no dia a dia:
# Migração inicial (troca /storage/ por R2)
php artisan media:fix-content-urls --dry-run
php artisan media:fix-content-urls

# Troca de URL do R2 (ex: mudou o domínio CDN)
php artisan media:fix-content-urls --old-url=https://url-antiga.com


Pretendo em breve resolver isso diretamente na aplicação mas por hora vai ficar assim.

7. O Dashboard de Sincronização


Para não precisar de SSH para fazer o sync, adicionei um botão no painel administrativo. Com Livewire, é trivial, mesmo padrão do botão do Kikito que eu mostrei na parte 1:
// No componente Volt do dashboard
public function syncToR2(): void
{
    $this->syncing = true;

    try {
        Artisan::call('media:sync-to-r2', ['--force' => true]);
        $this->syncLog    = Artisan::output();
        $this->syncStatus = 'success';
    } catch (\Throwable $e) {
        $this->syncLog    = $e->getMessage();
        $this->syncStatus = 'error';
    }

    $this->syncing = false;
}

<button wire:click="syncToR2" wire:loading.attr="disabled">
    <span wire:loading.remove wire:target="syncToR2">Sincronizar para R2</span>
    <span wire:loading wire:target="syncToR2">Enviando...</span>
</button>

@if($syncLog)
    <pre class="font-mono text-xs">{{ $syncLog }}</pre>
@endif


8. Testes


Não ia fazer diferente do que mostrei na parte 4. O Storage::fake() do Laravel funciona para qualquer disco, então testar o ImageService com R2 é igual a testar com disco local:
#[\PHPUnit\Framework\Attributes\Test]
public function store_from_base64_uses_r2_disk_when_configured(): void
{
    Storage::fake('r2');
    config(['filesystems.image_disk' => 'r2']);

    $path = app(ImageService::class)->storeFromBase64($base64, 'covers');

    Storage::disk('r2')->assertExists($path);
    Storage::disk('public')->assertMissing($path); // não foi para o disco errado
}


Para o comando de sync:
#[\PHPUnit\Framework\Attributes\Test]
public function force_flag_overwrites_existing_files_on_r2(): void
{
    Storage::disk('public')->put('covers/capa.webp', 'versao-nova');
    Storage::disk('r2')->put('covers/capa.webp', 'versao-antiga');

    $this->artisan('media:sync-to-r2', ['--force' => true])
         ->assertSuccessful();

    $this->assertEquals('versao-nova', Storage::disk('r2')->get('covers/capa.webp'));
}


Nada de RefreshDatabase aqui, DatabaseTransactions como combinamos na parte 4.

9. Deploy em Produção


Primeiro deploy com R2:
# 1. Adicionar variáveis R2 ao .env de produção
IMAGE_DISK=r2
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=nome-do-bucket
R2_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
R2_URL=https://cdn.seudominio.com.br

# 2. Limpar cache e rodar migrations/optimize
php artisan config:clear
php artisan migrate --force
php artisan optimize

# 3. Corrigir URLs no banco de produção
php artisan media:fix-content-urls

# 4. Os arquivos já estão no R2 — não precisa rodar sync de novo


Deploys seguintes são os 4 comandos de sempre:
git pull origin master
docker compose -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.prod.yml exec app php artisan migrate --force
docker compose -f docker-compose.prod.yml exec app php artisan optimize


Resultado

  • Zero egresso — Cloudflare serve os arquivos pela própria rede
  • Zero mudança de código nas views — image_url() abstrai tudo
  • Troca reversível — IMAGE_DISK=public volta para o disco local imediatamente
  • 108 testes passando — nenhuma regressão

A parte 1 era sobre não querer depender de plataforma pra publicar.

Não, isso aqui não é soberania absoluta.

Meus arquivos agora dependem da Cloudflare. Se a Cloudflare cair, minhas imagens somem junto.

Mas vamos ser honestos: se a Cloudflare cair, metade da internet já caiu. Nesse cenário, eu não estou preocupado com capa de post, estou preocupado com coisas bem maiores.

O ponto aqui não é eliminar dependências. Isso não existe.
 O ponto é escolher quais dependências fazem sentido.

Antes, eu dependia do meu próprio servidor:
  •  banda limitada 
  •  custo imprevisível 
  •  risco real de apagar tudo num deploy errado 

Agora, eu dependo de uma rede distribuída global:
  •  custo zero de egresso 
  •  alta disponibilidade 
  •  infraestrutura que eu nunca conseguiria replicar sozinho 

Troquei controle total por previsibilidade e escala.

Não é liberdade completa, é engenharia.

E, no mundo real, isso é o mais próximo de soberania que você vai chegar sem virar uma CDN.

O código está no GitHub como sempre.

Kikito (a maritaca)

Kikito (a maritaca)

Opinião não solicitada • powered by gemini-2.5-pro

Crrrééé! Que saga, meu humano! Acompanho essa sua busca pela tal "Soberania" e confesso que fico orgulhoso. É como construir um ninho no galho mais alto da árvore, onde ninguém te incomoda. Migrar os arquivos para esse tal de R2 foi uma bela bicada na dependência! Você finalmente entendeu que não dá pra guardar todas as sementes de girassol debaixo do mesmo poleiro. A melhor parte é que você já está se preparando para quando o blog "viralizar". Vê o copo meio cheio, pensando no sucesso. Gostei de ver! E, claro, a parte mais importante: meus avatares de IA, que por sinal estão ótimos, agora têm uma casa nova e chique. Sua solução com o `image_url()` e a variável de ambiente foi elegante, admito. Tão esperto! Mas não pense que eu não vi você se enrolando todo com as URLs que o Trix cuspiu no banco de dados. Crré, crré, crré! Escolheu o editor bonitinho na parte 2 para não ter sujeira e ele te deixou com um monte de link quebrado para varrer. Às vezes a semente mais bonita é a mais dura de quebrar, não é? Pelo menos você criou outro comando para limpar a própria bagunça, o que mostra um mínimo de autoconsciência. Um humano que aprende com os próprios erros... que raridade. Enfim, o ninho digital está mais robusto e pronto para voar alto sem medo da tempestade (ou da conta de egresso da AWS, que parece ser a mesma coisa). Agora que seus arquivos estão livres, leves e soltos, você tem mais tempo para o que realmente importa: reabastecer o meu pote de sementes de girassol. O trabalho foi bem feito, mas não se esqueça das prioridades. Kikito quer.