← All case studies Amazon S3 → Cloudflare R2

Pagecord moved Rails Active Storage from S3 to Cloudflare R2

Olly Headey copied Pagecord off S3 with Super Slurper, turned on incremental fetch for new objects, and pointed Active Storage at an R2 endpoint plus storage.pagecord.com.

Olly Headey published Moving from AWS S3 to Cloudflare R2 for Active Storage on 10 January 2025. This is his own post, not a Cloudflare page.

He builds Pagecord, an independent blogging platform, and Feedgrab. Both sat on AWS because a $1,000 startup credit had lasted about 18 months. The credit was ending. He started shopping for compute and for object storage at the same time.

If you are looking at the same route, start with the Amazon S3 → Cloudflare R2 playbook. Cloudflare’s data migration page splits the job in two: Super Slurper for a one-time copy, Sippy for objects that arrive after you flip. Olly used both, then pointed Rails Active Storage at R2.

Why he left S3

He still calls S3 durable. The 11 nines figure is in the post. Cost and complexity are why he stopped treating it as the default for a Rails app. Egress, API requests, and the AWS console are the complaints he lists.

Hetzner was first choice for compute. He tried Hetzner Object Storage next, because keeping compute and disks in one place is tidy. Setup was easy. Active Storage direct uploads were not. He could not get them working. Direct uploads matter if you use Trix, because the editor puts files in the bucket from the browser. He does not think Hetzner supported that path at the time.

Cloudflare was already in the stack. DNS, Pages for static sites (including olly.world), and a proxy in front of the Rails apps. R2 is S3-compatible. He tried it and stayed.

The FreeAgent terabytes and the 37signals petabytes in the opening are history from other products. They are not Pagecord’s object count. He never publishes how much data Pagecord held on S3.

How he moved

He created R2 buckets in the dashboard. He left the region on Automatic.

Browser uploads need CORS. R2 CORS is a JSON policy on the bucket. His production rule allowed PUT from the app origin, with Origin, Content-Type, Content-MD5, and Content-Disposition in AllowedHeaders, and ETag in ExposeHeaders. MaxAgeSeconds was 3600. For local testing he added http://localhost:3000 to AllowedOrigins.

Then an API token. He used Object Read & Write, scoped to one bucket. Cloudflare’s Super Slurper job wanted a second token with Admin Read & Write on the destination, plus a narrow IAM user that could only read the S3 source. He created that extra AWS user, as Cloudflare’s guide asks.

Active Storage already talks S3. Rails documents an endpoint key for S3-compatible APIs. He added a cloudflare service in storage.yml:

cloudflare:
  service: s3
  endpoint: https://[YOUR-ID].r2.cloudflarestorage.com
  access_key_id: <%= ENV['CLOUDFLARE_R2_ACCESS_KEY_ID'] %>
  secret_access_key: <%= ENV['CLOUDFLARE_R2_SECRET_ACCESS_KEY'] %>
  region: auto
  bucket: [YOUR-BUCKET-NAME]

Production selected it with config.active_storage.service = :cloudflare.

Public reads went through a custom domain, storage.pagecord.com. The zone was already on Cloudflare, so R2 could write the CNAME. Connecting a custom domain makes the bucket publicly readable on that host. He then set ACTIVE_STORAGE_ASSET_HOST to https://storage.pagecord.com and added a rails_public_blob direct route that joins the host to blob.key when the env var is present. Action Text’s _blob.html.erb partial uses that helper. He also set config.active_storage.delivery_method = :proxy. That line is his. I am not treating it as current Rails API.

The bytes moved with Super Slurper. In the dashboard that is R2 Object Storage → Data Migration → Migrate Files. Source credentials, destination credentials, then start. He called it fairly quick on his data. He does not publish a size or a duration. Cloudflare says Super Slurper keeps source objects, copies custom metadata, skips objects over 1 TB, skips Glacier classes, and does not promise matching ETags after the copy.

Live writes were the remaining gap. Customers can add objects to S3 after the batch job starts. He turned on incremental migration on the R2 bucket, which is Sippy in the current docs. A request that misses in R2 is served from S3 and copied across. Put and delete on R2 stay normal. Sippy will not refresh an object that already landed in R2 if you later change it in S3.

He used the same R2 account for Active Storage files and for database backups.

What he gained

Pagecord served assets from R2 after the flip. He called them snappy. The Cloudflare dashboard was easier than AWS. Billing was clearer than Cost Explorer.

He does not publish an S3 invoice or a savings percent for this move.

A later post, What it actually costs to run Pagecord (March 2026), puts Cloudflare R2 at $0.68 a month for image storage plus backup storage. That is a March 2026 line item, after compute had already moved to Hetzner. It is not the January 2025 cutover bill. He still calls R2 better value than S3.

What broke or got skipped

Hetzner Object Storage was a failed first try. Direct uploads blocked it. He does not describe a partial Hetzner copy that he then threw away.

He does not describe a write freeze. Super Slurper plus Sippy is his answer to objects that appear during the copy. Sippy only fills on read. An object written to S3 and never requested will sit on S3 until Slurper, or a second job, gets it. Deletes that you only run on R2 can come back from S3 on the next GET. Cloudflare’s Sippy docs say that.

ETags can change. If you compare checksums across the two buckets, do not use ETag equality as the pass. Super Slurper and Sippy both warn about this.

Versions, lifecycle rules, bucket policies, and KMS keys are not in his write-up. The playbook treats those as separate rebuilds.

He also does not describe leaving S3 attached as a mirror, or how long the incremental fetch stayed on. I would keep S3 readable until the miss rate on Sippy is the leftover you accept.

Custom-domain public access is a real switch. Anything that could guess storage.pagecord.com plus a key can fetch that object. Scoped tokens on the API side do not hide a public hostname.

What I would copy

Stay on the S3 service in storage.yml and only change the endpoint, keys, region, and bucket. That is the Rails-compatible path.

Set CORS for the methods the browser sends. PUT plus Content-MD5 is the Active Storage direct-upload set he used. Add localhost only in development.

Create two tokens. A narrow Object Read & Write key for the app. A separate Admin Read & Write key for Slurper, then revoke it.

Run Super Slurper first, then enable Sippy before you point Rails at R2. Attach the custom domain in the R2 settings, not as a raw CNAME to r2.dev.

After cutover, request a file that existed before the copy and a file uploaded after it. The first checks Slurper. The second checks Sippy.

Run the Amazon S3 → Cloudflare R2 playbook. Use Olly’s post for the Active Storage storage.yml, the CORS rule, and the Super Slurper plus incremental pair on a live Rails app.