when using S3 or cloudflare R2 for uploads alongside a custom CDN URL, custom emoji uploads do not respect the CDN configuration and attempt to load directly from the raw bucket URL.
The issue
when an admin uploads a custom emoji, the uploader creates an upload record and saves the raw bucket URL to the database (e.g., //my-bucket.s3.amazonaws.com/... or //my-bucket.r2.cloudflarestorage.com/...) - this is standard Discourse behavior.
however, when app/models/emoji.rb generates the emoji cache for /site.json, it passes the upload.url directly to the emoji object:
e.url = emoji.upload&.url
because it skips the CDN helper, the frontend receives the raw bucket URL. so depending on how strict the bucket’s access policies are, this results in broken images or forces Discourse to route the emojis through the internal avatar_proxy.
Solution
i have opened a PR that wraps the URL assignments in Discourse.store.cdn_url(), which makes the custom emoji loader align with how standard post images and avatars are routed.
Interim fix
while waiting for the PR to be reviewed and merged, i created a lightweight theme component that swaps the raw bucket URL for the proper CDN URL right before the custom emoji renders in the DOM (works for both posts and chat).
if you are experiencing this bug on your site, you can install this component and configure your S3 strings in the theme admin settings to fix any broken custom emojis:
Note: if you have already uploaded custom emojis that are currently borked, running discourse remap "//my-raw-bucket-url.com" "https://my-cdn.com" in the container will fix the old ones in the database, while the theme component will fix any newly uploaded ones until the PR fix is merged into core.
yea maybe it’s a cloudflare R2 only thing then. they are breaking on my instance. it only seems to break for new ones uploaded too.
without the theme component fix, i have to run the remap every time i upload a new one. the PR might need a bit of work - i’m not super good with the emoji code.
Discourse uses a two CDN setup, one for assets and one proxying the app.
Standard emoji uses one CDN, custom emoji uses the other CDN, but both are protected by a CDN on a properly configured website with a working two CDN setup.
i do have them set, but for gods sake i had a typo in my cloudflare config for the cdn-specific dns record that DISCOURSE_CDN_URL points to and i never tested it when i set it up because my site was working what a gong show.
at least i learned more about emoji code creating that pointless PR…
wow what a ride this was. i basically reconfigured my whole Cloudfare R2 object storage and Discourse instance, and i think this bug is legit for R2. when i fixed my cloudflare dns record and rebuilt the instance so that DISCOURSE_CDN_URL actually pointed to it, it borked a bunch of other stuff like translation strings and threw multiple errors in the console including some CORS errors. it led me down many rabbit holes today. so i guess using DISCOURSE_CDN_URL seems incompatible with Cloudflare R2. (this was very weird - when i originally setup my original dns entry, i had incorrectly entered the cdn.mysite.com dns record so that it was resolving as cdn.mysite.com.mysite.com). setting DISCOURSE_CDN_URL correctly seems incompatible with Cloudflare R2 object storage. there may be some other stuff i’m not fully understanding here.
anyways, when i rebuild with my PR branch it is all fixed because it wraps the assignment in Discourse.store.cdn_url() so that custom emojis uploads obey the same S3 CDN routing and fallback logic as standard post image uploads.
i reopened the PR and edited the description. but i guess if Discourse team chooses not to merged it, that is fine because the theme component fixes the issue at the client level. Note the PR fix should only affect R2 object storage configurations for custom emoji and not other regular S3 compatible like AWS.