Mutisite and Cloudflare R2 Objects

Using Cloudflare R2 on just one site in a Discourse multisite install

If you run a multisite Discourse install and only want one of your sites on Cloudflare R2 (uploads, backups, and optionally static assets), the standard Configure an S3 compatible object storage provider for uploads guide doesn’t quite cover the multisite wrinkle. This post walks through what actually happens, what stays per-site vs. cluster-wide, and a few sharp edges I hit along the way that cost real time to figure out.

The core thing to understand: GlobalSetting vs SiteSetting

Everything in this setup comes down to one distinction:

  • app.yml env vars (DISCOURSE_*) become GlobalSettings — read once at container boot, from the process environment, shared by every site in the cluster. RAILS_DB has no effect on them.
  • Admin UI fields are ordinary SiteSettings — stored per-site in each site’s own database, genuinely scoped to that one site.

If a GlobalSetting exists for something, it silently overrides and hide the matching SiteSetting field in the Admin UI. This means: whatever you put in app.yml applies to every site, no exceptions, no RAILS_DB workaround.

Part 1 — Uploads and backups (genuinely per-site, easy)

This part works exactly as you’d hope. enable_s3_uploads, s3_upload_bucket, backup_location, s3_backup_bucket, and the credential fields are all ordinary site settings. Configure them only through Admin → Settings → search “S3”, logged into the specific site you want on R2, and leave app.yml untouched. Other sites in the cluster keep storing locally.

Sample values for R2:

Enable S3 uploads = true
Enable direct S3 uploads = true
S3 access key ID / secret access key = <your R2 token>
S3 region = auto
S3 upload bucket = <bucket name>
S3 endpoint = https://<account-id>.r2.cloudflarestorage.com
S3 CDN URL = https://uploads.yourdomain.com
S3 use ACLs = false   (R2 uses bucket-level permissions, not object ACLs)
S3 backup bucket = <backup bucket name>
Backup location = S3

Set your bucket’s CORS policy directly in the Cloudflare dashboard (R2 doesn’t need Discourse’s CORS rake task):

[
  {
    "AllowedOrigins": ["https://your-site.tld"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

Part 2 — Migrating existing local uploads

rake uploads:migrate_to_s3 only reads S3 config from environment variables — it has no fallback to site settings at all, regardless of what’s configured in the Admin UI. This is a real gap in the task, not a config mistake. Pass the credentials inline for a one-off run instead of touching app.yml:

./launcher enter app

RAILS_DB=default \
DISCOURSE_S3_REGION=auto \
DISCOURSE_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com \
DISCOURSE_S3_BUCKET=<bucket name> \
DISCOURSE_S3_ACCESS_KEY_ID=<key> \
DISCOURSE_S3_SECRET_ACCESS_KEY=<secret> \
rake uploads:migrate_to_s3

These env vars only exist for that shell process — nothing persists once you exit.

Checksum error on newer AWS SDK versions

If you hit:

Aws::S3::Errors::InvalidRequest: You can only specify one non-default checksum at a time.

this is a known incompatibility between recent aws-sdk-core versions (which default to sending a CRC32 checksum) and R2. Fix by adding two more env vars to the same command:

export AWS_REQUEST_CHECKSUM_CALCULATION=when_required
export AWS_RESPONSE_CHECKSUM_VALIDATION=when_required

Leftover “unmigrated” records after a mostly-successful run

If the task finishes with something like 1 of 1291 uploads are not migrated, don’t panic — everything else already migrated and the DB URLs were already rewritten. Find the straggler in rails c:

base_url = File.join(SiteSetting.Upload.s3_base_url, "original/")
Upload.by_users.where("url NOT LIKE '#{base_url}%'").pluck(:id, :url, :original_filename)

In my case it was a stray Upload record (a backup log zip) using R2’s raw endpoint-style URL instead of the CDN URL format the check expects — a false positive, not a real failure. Fix the URL or delete the record if it’s not meaningful content.

Part 3 — Static assets (JS/CSS) — the part that’s genuinely cluster-wide

This is where the “just for one site” goal runs into a hard wall. Compiled assets are shared across the whole multisite cluster — there’s one compiled JS/CSS bundle, not one per site. Whether assets are served from R2 or locally is decided once, at Rails boot, via GlobalSetting.use_s3? — there’s no per-site override for this.

If you want assets offloaded to R2, you have to put the connection details (not the upload-enable flag) in app.yml:

env:
  DISCOURSE_USE_S3: true
  DISCOURSE_S3_REGION: auto
  DISCOURSE_S3_ENDPOINT: https://<account-id>.r2.cloudflarestorage.com
  DISCOURSE_S3_ACCESS_KEY_ID: "xxx"
  DISCOURSE_S3_SECRET_ACCESS_KEY: "xxx"
  DISCOURSE_S3_BUCKET: <bucket name>
  DISCOURSE_S3_CDN_URL: https://uploads.yourdomain.com
  AWS_REQUEST_CHECKSUM_CALCULATION: when_required
  AWS_RESPONSE_CHECKSUM_VALIDATION: when_required

hooks:
  after_assets_precompile:
    - exec:
        cd: $home
        cmd:
          - sudo -E -H -u discourse bundle exec rake s3:upload_assets
          - sudo -E -H -u discourse bundle exec rake s3:expire_missing_assets

Notes:

  • Don’t set DISCOURSE_CDN_URL. Only DISCOURSE_S3_CDN_URL. Setting both, with your main domain proxied through Cloudflare, causes redirect loops per the main S3 guide’s own warning.
  • Use bundle exec rake, not bundle rake (easy typo) — and use sudo -E -H -u discourse (the -H sets HOME correctly for the discourse user; without it Bundler falls back to a temp dir every run).
  • The asset offload affects both sites. Your second site’s <script>/<link> tags will also start resolving to the R2 CDN URL, since it’s the same compiled bundle. Make sure your bucket’s CORS AllowedOrigins includes every site’s domain.
  • This does not force your second site’s actual uploads onto S3 — enable_s3_uploads stays a genuine per-site setting, independent of the asset-serving GlobalSetting. Verify with SiteSetting.Upload.enable_s3_uploads in rails c for that site’s DB after rebuilding.

USE_DB_S3_CONFIG — what it actually does (and doesn’t)

You’ll see USE_DB_S3_CONFIG=true referenced in some community setups (e.g. Bitnami’s chart) as a way to make s3:upload_assets read credentials from site settings instead of env vars. It works for the upload task itself — but it does not flip GlobalSetting.use_s3?, which is the flag that actually controls whether asset URLs get rewritten to the CDN at render time. So you can successfully push files to R2 with USE_DB_S3_CONFIG and still see your site serving assets locally, because the page-render check never sees “S3 is enabled.” If you want assets actually served from R2, you need the real DISCOURSE_USE_S3: true + connection env vars in app.yml, not just the DB-config workaround.

Part 4 — What still won’t be on R2, and why

Even with the hook working, s3:upload_assets only uploads what’s in Rails.application.assets.load_path — Rails’ Sprockets manifest. Three categories are generated outside that pipeline and never appear in this list, so they stay on local disk no matter what:

  • Theme CSS — compiled dynamically per theme/color-scheme by Discourse’s Stylesheet::Manager, not through Sprockets.
  • theme-javascripts — per-theme compiled JS from ThemeJavascriptCompiler.
  • extra-locale / locale JS files — generated by JsLocaleHelper.

This isn’t a config problem — these were never Sprockets assets to begin with, so there’s no env var that pulls them in. In practice this means: core Ember/vendor JS bundle → offloaded to R2 successfully; theme CSS/JS and locales → stay local, served directly by the app. This is a normal, working state, not a broken one.

Summary: what to actually put where

What Where Scope
enable_s3_uploads, s3_upload_bucket, backup_location, s3_backup_bucket Admin UI, per site Per-site
Credentials + DISCOURSE_S3_REGION/ENDPOINT/BUCKET/CDN_URL + DISCOURSE_USE_S3 app.yml, only if you want asset CDN offload Cluster-wide (unavoidable)
after_assets_precompile hook app.yml Cluster-wide
AWS_REQUEST_CHECKSUM_CALCULATION / AWS_RESPONSE_CHECKSUM_VALIDATION app.yml Cluster-wide (harmless SDK behavior flag)
CORS on the bucket Cloudflare dashboard Must include every site’s domain if assets are shared

If you don’t need asset CDN offload, skip Part 3 entirely — you can run a fully working, genuinely per-site R2 setup (uploads + backups only) without ever touching app.yml.

1 Like