# Mutisite and Cloudflare R2 Objects

**URL:** https://meta.discourse.org/t/mutisite-and-cloudflare-r2-objects/412414
**Category:** Sysadmins
**Tags:** multisite, cloudflare, hosting
**Created:** [September 15, 2026, 2:26am UTC](https://meta.discourse.org/t/mutisite-and-cloudflare-r2-objects/412414 "2026-09-15T02:26:41Z")
**Posts on this page:** 1
**Showing post:** 1

<div class="post-metadata">

### Author: ![kcahtoor](https://avatars.discourse-cdn.com/v4/letter/k/9de0a6/32.png) [@kcahtoor](https://meta.discourse.org/u/kcahtoor)
#### Post date: [September 15, 2026, 2:26am UTC](https://meta.discourse.org/t/mutisite-and-cloudflare-r2-objects/412414/1 "2026-09-15T02:26:41Z")

</div>

# 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](https://meta.discourse.org/t/configure-an-s3-compatible-object-storage-provider-for-uploads/148916) 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 **`GlobalSetting`s** — 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 **`SiteSetting`s** — 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:

```plaintext
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):

```json
[
  {
    "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`:

```bash
./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:

```plaintext
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:

```bash
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`:

```ruby
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`:

```yaml
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`.

---

_[View the full topic](https://meta.discourse.org/t/mutisite-and-cloudflare-r2-objects/412414)._
