Moving Assets to Cloudflare R2

Binary files in Git are a trap. They bloat your repo, slow clones, and create false promises of version control. When you change one pixel in a 5MB image, Git stores another 5MB. Do this enough and your repository becomes a burden.

My sheet music project was heading there. Each piano score generated five files: SVG, PDF, MIDI, MP3, and the LilyPond source. Plus a bird guessing game with 13 bird photos and 8 audio clips. 32 files, 33MB, all checked into Git.

Cloudflare R2 fixes this. Zero egress fees, S3-compatible API, cheap storage. Here's the setup.

The architecture

Environment Files served from URL base
Development public/music/ and public/projects/ /music
Production R2 bucket https://cdn.goodbran.com/music

Same code, different sources. Rails environment determines where files come from.

Uploading with rclone

R2 uses S3-compatible API. rclone speaks that natively.

Configure once:

rclone config
# Choose "Amazon S3", enter your R2 access key and secret
# Endpoint: https://<account_id>.r2.cloudflarestorage.com

Then sync:

rclone sync public/music/ r2:goodbran/music/
rclone sync public/projects/ r2:goodbran/projects/

I added rake tasks for convenience:

rails assets:sync_music      # Sync sheet music
rails assets:sync_projects   # Sync project files
rails assets:sync_all        # Both

Environment-aware URLs

The Content::Sheet model generates asset URLs. In production, they point to the CDN. In development, they point to local files.

class Content::Sheet < Perron::Resource
  CDN_BASE = Rails.env.production? ? 
    "https://cdn.goodbran.com/music" : "/music"

  def pdf_url
    "#{CDN_BASE}/#{pdf_filename}" if pdf_filename.present?
  end
end

No configuration files, no environment variables. The code knows where it lives.

Removing from Git

After syncing to R2, I removed the assets from Git tracking:

# Update .gitignore
echo "/public/music/*" >> .gitignore
echo "/public/projects/*" >> .gitignore

# Remove from tracking but keep local files
git rm -r --cached public/music/
git rm -r --cached public/projects/

Local files stay for development. Fresh clones get an empty public/music/ directory. Run rails lilypond:compile or rclone sync to populate it.

The workflow now

Adding new sheet music:

  1. Create .ly file in app/content/lilypond/
  2. Run rails lilypond:compile[new-piece]
  3. Files appear in public/music/ — works immediately in dev
  4. Run rails assets:sync_music — now on CDN for production

The bird game works the same way. The ProjectsController builds URLs based on environment, feeding JSON to a Stimulus controller that plays sounds and shows images.

Cost and limits

R2's free tier: 10GB storage, 10 million reads per month. My entire asset collection is under 35MB. It costs nothing.

Even if it grows to 1GB, that's $0.015/month for storage. Egress is free. The only risk is going over 10 million monthly reads, which would cost $0.36 per million after that. For a personal site, unreachable.

Summary

  • 33MB removed from Git repository
  • Deploys are faster (fewer files to copy)
  • CDN serves assets globally
  • Local development still works offline
  • Cost: $0

The repo contains source files. The CDN serves generated files. Separation of concerns, finally.