DOCS LLMs

Storage - R2, B2 and S3 with object lock, and when to use each

A RailsFast app can touch three kinds of object storage. They do different jobs, and each has a different best fit:

Job Backend Why
User uploads (Active Storage: avatars, attachments) Cloudflare R2 No egress fees, so serving files costs nothing extra. The template's default (config/storage.yml → cloudflare_r2).
Database backups (pg-backup) Backblaze B2 Cheap storage for large files that you write every night and almost never read. The template's default for config/backups/*.key.
Records that must never change (audit trails, signed documents, financial or legal records) AWS S3 with Object Lock S3 can lock each object for years and refuse an overwrite with a bucket policy, and nobody can shorten the lock. R2's bucket locks also refuse overwrites, but the account can lift them; B2 cannot refuse an overwrite at all.

Most apps need only the first two. Add the third only when a record must stay exactly as written, for years, even against someone who holds your app's key.

R2 for Active Storage

Set up in Configuration. Two rules:

  • One bucket and one R2 API token per app. Give the token Object Read & Write on that bucket only. A token per app means that a leak exposes one app, and that you can rotate it alone.
  • Keep the bucket private (public: false in config/storage.yml). Rails serves files through signed URLs.
cf r2 buckets create --name myapp-production --location-hint weur

B2 for database backups

Set up in Configuration (the config/backups/*.key files) and proven in Restoring your database backups.

  • No object lock on the backup bucket: pg-backup deletes dumps older than its retention (REMOVE_BEFORE), and a lock would refuse that.
  • B2 keeps every version of a file by default, so deleted dumps stay as hidden versions and still cost money. In the B2 console, set the bucket's lifecycle to Keep only the last version of the file.
  • Three keys, each restricted to the bucket: the read/write key of pg-backup, a list-only key for the app's "Database backups are fresh" health check (restricted to your app's prefix, for example myapp/), and your own key for restores. The app container never gets the read/write key.

S3 with Object Lock for compliance-grade records

Two controls together make a record write-once:

  1. Object Lock in compliance mode with a default retention. Nobody can delete a locked version or shorten its lock before it ends, not even the account's root user.
  2. A bucket policy that refuses a write without If-None-Match. Object Lock protects each version, but in a versioned bucket a plain PUT to an existing key adds a newer version, and a plain read returns the newer one. With this policy, S3 accepts a PUT only as "create if absent". An overwrite fails with 412 Precondition Failed.
CAUTION

Compliance-mode locks cannot be removed. Try the setup first in a scratch bucket with GOVERNANCE mode and a retention of one day. Then make the real bucket.

BUCKET=myapp-records-production

# This recipe turns Object Lock on when it creates the bucket (that also turns on versioning).
# S3 can also turn it on later for an existing versioned bucket; B2 only at creation.
aws s3api create-bucket --bucket "$BUCKET" --region us-east-1 --object-lock-enabled-for-bucket
aws s3api put-public-access-block --bucket "$BUCKET" --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api put-object-lock-configuration --bucket "$BUCKET" --object-lock-configuration \
  '{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Years":7}}}'
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file://records-policy.json

records-policy.json, the bucket policy pattern:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "RefuseOverwrites",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::myapp-records-production/*",
      "Condition": { "Null": { "s3:if-none-match": "true" } }
    },
    {
      "Sid": "RefuseDeletesAndLockChanges",
      "Effect": "Deny",
      "Principal": "*",
      "Action": ["s3:DeleteObject", "s3:DeleteObjectVersion", "s3:BypassGovernanceRetention", "s3:PutObjectRetention"],
      "Resource": "arn:aws:s3:::myapp-records-production/*"
    },
    {
      "Sid": "RefusePlainHttp",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": ["arn:aws:s3:::myapp-records-production", "arn:aws:s3:::myapp-records-production/*"],
      "Condition": { "Bool": { "aws:SecureTransport": "false" } }
    }
  ]
}

The app's IAM user gets s3:PutObject, s3:GetObject, s3:GetObjectVersion, s3:GetObjectRetention and s3:ListBucket on this bucket only: no delete, no BypassGovernanceRetention. A record whose retention must differ from the default (for example a short lock on a health-check probe) needs s3:PutObjectRetention: remove it from the deny statement, and remember that compliance mode still refuses to shorten a lock.

Write with the AWS SDK, not with Active Storage. Active Storage's default S3 service does not send If-None-Match, so this policy refuses its uploads. (Its upload: options in config/storage.yml can add if_none_match: "*" to single-part uploads, but direct uploads, multipart uploads and Active Storage's own purge and variant writes need separate handling.) Write each record yourself and keep its SHA-256 in your database, so you can check what you read back:

s3 = Aws::S3::Client.new(region: "us-east-1")
s3.put_object(bucket: "myapp-records-production", key: "records/#{record.id}.json",
              body: payload, if_none_match: "*", checksum_algorithm: "SHA256")

Why not B2 or R2 for this job

B2 supports Object Lock but has no bucket policies. So it cannot refuse a plain PUT: anyone with the app's key can add a newer version under an existing key. The locked original survives, but a plain read returns the newer version. If you accept that risk, detect it: list every version under your record prefixes each night (ListObjectVersions), and alert when a key has more than one version or a delete marker. Detection is not prevention.

R2 is closer. A bucket lock rule refuses both deletes and overwrites of the objects it covers for a duration, until a date, or indefinitely, and R2 keeps no versions, so a leaked app key cannot hide a record behind a newer copy. The difference is who can lift it: a lock rule is edited and removed with the account's R2 permissions (wrangler r2 bucket lock remove), so it protects against a leaked app key, not against an account takeover, and nobody will recognise it as a compliance mode. S3 in compliance mode lets nobody shorten a lock, root included. For records that must survive a leaked app key unchanged, R2 with an indefinite lock on the record prefix is acceptable; for records that must also survive the account, use S3 with the policy above.