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:
- Create
.lyfile inapp/content/lilypond/ - Run
rails lilypond:compile[new-piece] - Files appear in
public/music/— works immediately in dev - 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.