Softwaiz Stories
← All stories

RedwoodSDK · Part 2 of 2

Deploying RedwoodSDK to Production Without Cloudflare

5 min read
  • redwoodsdk
  • cloudflare
  • celld
  • durable-objects
  • docker
The same terracotta cell with concentric rings from the previous post, now connected by a line downward to two stacked squares — one hollow and rejected, one solid and accepted — evoking a storage backend swapped out before it reached production.

The last post answered whether RedwoodSDK’s dependency on Cloudflare was architectural or just a default — it’s a default. What it didn’t answer is whether a self-hosted deployment actually holds up once it’s running for real: a persistent node, a real storage backend, state that has to survive a restart. That’s the part that turns “this technically works” into “this is deployable.”

It mostly held up. The one place it didn’t was the storage backend — and the way it failed is worth walking through in full, because the failure mode was silent right up until it wasn’t.

Picking a storage backend got harder mid-project

celld needs an S3-compatible bucket for its state. The obvious self-hosted choice was MinIO — it’s the store celld’s own docs mention by name. It’s also, as of this writing, in the middle of unwinding its free distribution: Community Edition stopped getting new images in October 2025, and Docker Hub removed the minio namespace entirely in September 2026. What’s left are archived, pre-cutoff images with no future updates — usable, but a dead end.

Garage looked like the natural replacement: actively maintained, built specifically for self-hosting, with a straightforward Docker image. We wired it up, pointed celld at it, and it looked fine — the container started, the S3 API responded, buckets listed correctly.

The bug wasn’t in the setup

celld refused to serve from it anyway:

Error: the bucket does not keep the storage contract, so celld cannot serve
cells safely on it: the store overwrote an object although the write was
conditional on that object being absent; the store accepts If-Match /
If-None-Match and does not enforce it, so two nodes can own one cell.

celld’s entire ownership model rests on one guarantee: a conditional write — “create this object only if it doesn’t already exist” — either succeeds exclusively or fails outright. No consensus protocol, no membership service, just that one atomic operation. Garage accepts the If-None-Match header without actually enforcing it, so two nodes racing to claim the same cell could both believe they’d won. That’s not a rough edge — it’s the one thing the whole design depends on not going wrong.

In fairness to Garage, this is a genuinely new corner of the S3 API. AWS itself only shipped atomic put-if-absent in August 2024; MinIO didn’t correctly enforce it until a September 2024 release. Garage’s own compatibility docs don’t mention conditional writes at all, in either direction — this isn’t a bug so much as a feature that hasn’t landed yet.

Verifying the replacement instead of trusting the docs

SeaweedFS documents the exact primitive by name: If-None-Match: "*" as “a primitive for distributed leader election, exclusive file creation, or idempotent uploads.” That’s specific enough to sound right, and specific enough to be worth actually checking rather than taking on faith — especially right after Garage’s docs implied nothing was wrong either.

So we stood up a real SeaweedFS instance and ran celld diagnose against it — the same conditional-write probe that had just failed against Garage:

ok bucket conditional write: create, reject-create, update, reject-stale

celld’s own correctness check, passing against a live store. Not a doc claim — an actual pass.

Two Docker problems, one of them worth remembering

Getting the storage swap running in Docker surfaced two more issues, both more interesting than “we misconfigured something.”

The first: a bucket created via one container wasn’t visible to the container actually serving traffic. The cause wasn’t networking — docker-compose’s depends_on was fine. It was that the SeaweedFS image declares its own VOLUME /data, so a second container started from that image — with no explicit volume mount — silently got its own throwaway anonymous volume. The bucket-creation step “succeeded” against storage nobody else could see. The fix wasn’t a workaround; it was removing the separate container entirely and creating the bucket from inside the same process that serves the data, over localhost, where there’s no volume ambiguity to have in the first place.

The second: a local port override meant to replace 8080 with 18080 instead ran both — Compose merges list-valued keys like ports across override files by default rather than replacing them, so celld tried to bind two ports and failed on the one already taken by something unrelated. The fix is the !override merge tag, which does what most people assume depends_on-style overrides already do.

Neither of these is specific to celld. Both are the kind of thing that looks like a flaky environment until you find the one-sentence mechanism, and then it’s obvious.

What actually shipped

The final deploy path needs nothing custom — no bespoke image, no extra tooling beyond what already exists:

docker compose up -d seaweedfs
docker compose exec seaweedfs weed shell -master=127.0.0.1:9333
# > s3.bucket.create -name celld-fleet
docker compose up -d celld

Then the actual deploy, reusing the same ghcr.io/denoland/celld image already running the service, as a throwaway container on the same network:

docker compose run --rm --entrypoint celld \
  -v $(pwd)/dist:/project \
  celld deploy /project \
  --bucket s3://celld-fleet --endpoint http://seaweedfs:8333 --region us-east-1

celld picked up the deployment without a restart — the log line says as much: awaiting_initial_deployment flips to serving traffic the moment the bucket has something in it. The app itself was about as simple as a RedwoodSDK app gets: a single Durable Object counter, incremented through a real server action, in a real browser, against a real running node. It survived reloads. It survived us coming back to check on it later and finding the count higher than we’d left it, because state that persists is state someone can poke at.

Where this leaves it

Cloudflare was never the hard dependency — the storage layer’s correctness guarantees were, and those are exactly the kind of thing that looks fine until the one operation your whole design depends on turns out to be unenforced. Verify the primitive you’re actually relying on, not the one the docs imply you’re getting. What’s still ahead: this ran end-to-end against the real recipe, but not yet against the actual target machine it’s meant for — the difference between “this works” and “this works here” is the next thing to close.