Perhaps answering my own question, here are some notes from trying to use Garage with your runbook:
1. Garage doesn’t support “permission tags” on files (this is the real Garage gap)
Amazon lets you tag each file “public” or “private” individually. Garage doesn’t implement that.
Discourse tries to use it by default. The sneaky part: when Discourse said “save this file, mark it public,” Garage accepted the request and quietly ignored the tag. So basic uploads looked fine, and it would only have broken much later, the first time someone made an upload private.
Fix: tell Discourse to stop using tags (one setting). Cloudflare’s R2 has the same gap and gets the same fix. Garage handles permissions its own way, at the bucket level, which is all we need.
2. Discourse insists on naming buckets a specific way (not a Garage flaw)
Discourse won’t say “the server at garage, bucket uploads.” It insists on uploads.garage — bucket name glued to the front, like a subdomain. And there’s no option to turn that off.
Nothing on our network knew that name, so Discourse couldn’t connect at all — the install died partway through.
Fix: gave Garage that naming style and registered the names. Two lines of config.
This one is a known Discourse quirk, not a Garage one — it’s why Oracle’s storage is on Discourse’s official “won’t work” list.
3. Images needed a public web address (nothing to do with Garage)
Discourse writes each image’s address into the post permanently. Without telling it the public address up front, it saved internal addresses only our server can reach — so every image would be broken for visitors, forever, unless we rebuilt every post.
Fix: set the CDN address before the first upload. Would’ve been identical on Amazon, R2, anything.
Note: every one of these was silent. Nothing said “unsupported.” One accepted the request and ignored it, one looked like a network error, one looked like it worked fine.
So, just some initial things to keep in mind for a new user trying this for the first time, and/ or someone trying to follow Michael’s runbook with Garage.
Here are the full AI notes from what we experienced now that the forum has been deployed:
Running Discourse on self-hosted Garage (S3) behind a Cloudflare tunnel — notes
Garage isn’t on the “Configure an S3 compatible object storage provider” compatibility table, so here’s a data point. Setup: Garage v2.3.0, two-container Discourse, Debian 13,
single node, Cloudflare tunnel instead of an external nginx. Caveat up front: this is one
small deployment with no production traffic yet, so treat it as “works” and not “battle-
tested at Maker Forums scale”.
It works. Verified against Discourse’s own code paths, not a CLI: UploadCreator, OptimizedImage, ListObjectsV2, HeadObject/GetObject with byte-exact round trip, multipart, delete, remove_upload, plus BackupRestore::Backuper writing to the backups bucket and BackupStore#files / #download_file reading it back. Lifecycle configuration works, so s3_configure_tombstone_policy actually takes effect rather than being silently ignored. PutBucketCors works, so manual CORS is fine.
You must configure virtual-host-style addressing, and it will break db:migrate if you don’t. Discourse turns endpoint http://garage:3900 + bucket “uploads” into the host uploads.garage:3900, and there’s no path-style option anywhere in s3_helper.rb or site_settings.yml. Two halves are required: root_domain under [s3_api] in garage.toml, AND a resolvable DNS name per bucket (a Docker network alias per bucket, since Docker DNS has no wildcards). Symptom if you miss it is an Aws::Waiters error about getaddrinfo during SiteIconManager.ensure_optimized! — reads like a network fault, not a storage one. Every new bucket needs a new name.
Garage does not implement PutObjectAcl / GetObjectAcl, so set s3_use_acls to false — same as R2. Two traps here. First, PutObject with --acl public-read is silently ACCEPTED and the header ignored, so basic upload tests pass and it only breaks later on a secure-upload transition. Second, DISCOURSE_S3_USE_ACLS is not a shadowed global — putting it in app.yml does nothing. It came up true on my first boot despite being in the env. It has to be a site setting, and it needs re-checking after every rebuild because nothing warns you.
If you put a CDN in front, point it at Garage’s WEB endpoint (:3902), not the S3 API (:3900). Anonymous reads only work on the web endpoint; the S3 API correctly 403s unauthenticated requests, so a CDN aimed at :3900 fails on every image while your credentials are perfectly valid. You also need
garage bucket website --allowon the uploads bucket, and a root_domain under [s3_web]. Enable it on uploads only — the backups bucket must never be anonymously readable.ListObjectVersions returns NotImplemented on Garage. It doesn’t matter: grepping s3_helper.rb and file_store/s3_store.rb for list_object_versions / object_versions returns zero hits. Discourse doesn’t use S3 object versioning; tombstone expiry is lifecycle rules plus a prefix.
Testing with aws-cli or mc does NOT prove compatibility. Both default to path-style addressing against a custom endpoint; Discourse only does virtual-host style. My CLI pass came back 15/16 and looked like a green light, then the real install failed immediately on the addressing difference. Same store, same credentials, same operations. If you’re evaluating an unproven S3 backend, drive real Discourse — a throwaway single-container instance is enough, and doing it before any content exists is the whole point, since S3 → local is a one-way door.
On a Cloudflare tunnel specifically: the external-nginx-for-SSL section doesn’t apply (TLS terminates at the edge, no certbot, no inbound 80/443), but the real-IP outlet config is still essential and arguably more so. Use real_ip_header CF-Connecting-IP rather than X Forwarded-For — when the tunnel is the only ingress path it’s a single unambiguous edge-set value. Verify it by posting from a known public IP and checking what nginx logged; without it Discourse records the connector’s Docker address for every request and rate-limits the entire internet as one client. What you lose versus the external-nginx approach is the maintenance page during rebuilds — cloudflared can’t serve one.
Minor correction to something widely (mis)stated, including by me: DISCOURSE_S3_CDN_URL is not baked irreversibly into stored URLs. upload.url does store the raw S3 URL with the internal host, but Discourse substitutes the CDN host at render time via Discourse.store.cdn_url / UrlHelper.cook_url. Setting it late is recoverable — the cost is
rake posts:rebake, because posts.cooked caches the HTML from cook time. Still set it before the first upload; just don’t panic if you didn’t.Two operational notes unrelated to Garage. ./launcher echoes the full docker run line including DISCOURSE_S3_SECRET_ACCESS_KEY in plaintext, so bootstrap logs are secret-bearing — worth rotating keys after a noisy install. And a single-node Garage still requires
layout assign+layout applybefore it will store anything, with replication_factor = 1 meaning no replication at all, so your backup job is the only durability you have.