13. Object Storage Runs on RustFS
Status
Accepted
Date
2026-10-02
Context
Uploaded avatars, organization logos and opportunity banners live in S3-compatible object storage. Until now that was MinIO, in Aspire, in CI and on the staging host.
MinIO stopped being something this project can run:
-
The community repository is archived and receives no fixes.
-
Docker Hub deleted
minio/minioandminio/mcon 2026-09-11, andquay.io/minio/miniohas refused anonymous pulls since 2026-09-24. Every Aspire job in CI failed at its image pull, and every staging deploy failed atdocker compose pull. -
The successor, AIStor, needs a license key even in its free tier.
A Chainguard rebuild (cgr.dev/chainguard/minio) kept CI green for a few days. It is a fork of an abandoned server, published only as latest, and not a place to stay.
The app’s needs from the storage are small but specific. The backend’s storage service writes objects with Content-Type and Cache-Control, moves them between public/ and quarantined/ with copy and delete, and sets a bucket policy that allows anonymous GetObject under public/ only. Browsers then load images straight from path-style URLs (https://storage.<domain>/<bucket>/public/<key>), and those URLs are stored in the database.
Decision
Run RustFS (rustfs/rustfs, Apache-2.0) as the object storage in Aspire, in CI and on the staging host, and drop every MinIO component with the server:
-
The backend talks to the storage through
AWSSDK.S3(Apache-2.0) inS3FileStorageService, in place of theMinioNuGet package. The URL scheme and the URLs already stored stay as they are. -
On the staging host,
storage-initcreates the bucket, the policy and the bucket-scoped account with RustFS’s own CLI (rustfs/rc). Snapshots, offsite mirrors and restores userclone.
The Aspire resource and the compose service are named storage, not after the product, so the next swap touches one block in each place.
The checks this decision rests on are kept as FileStorageContractTests in IntegrationTests, so any future image swap is measured against the same contract in CI.
Rationale
Each candidate ran the backend’s real storage service, then still on the Minio SDK, through twelve checks:
-
bucket setup with the policy
-
upload, and an anonymous read that returns
Content-TypeandCache-Control -
quarantine and unquarantine, delete
-
an 8 MiB multipart upload
-
the negative cases: anonymous listing, anonymous writes, and anonymous reads of
quarantined/must all be refused
| RustFS 1.0.0 | SeaweedFS 4.48 | VersityGW 1.8.0 | Garage 2.4.1 | |
|---|---|---|---|---|
Root credentials |
12/12 |
12/12 |
12/12 |
5/12 - |
Bucket-scoped app account |
12/12, set up through the admin API for users and policies |
12/12, scoped by a static identities file |
5/12 - once the policy is set, the bucket owner is locked out of its own bucket |
- |
Memory after the run |
112 MiB |
128 MiB |
11 MiB |
- |
-
RustFS is the only drop-in. Like MinIO it has one port, root credentials from the environment and an admin API for users and policies, so the staging host keeps its bucket-scoped account.
-
No MinIO code is left.
S3FileStorageServiceonAWSSDK.S3passes the same checks against RustFS 1.0.0, with the root credentials and with the bucket-scoped account, plus one more: a quarantine round trip keepsContent-TypeandCache-Control.AWSSDK.S3is the S3 client AWS maintains for .NET; its AWS-only defaults (trailing checksums, virtual-hosted URLs) are switched off in one place.rcloneis vendor-neutral and the tool Hetzner documents for its Object Storage, so backups depend on no storage vendor’s tooling.rcis used only where the RustFS admin API leaves no alternative. -
The decision is cheap to reverse. The app speaks plain S3, the contract tests prove what a replacement has to do, and backups are plain-file mirrors of the bucket. Moving to another server means swapping one image and restoring from a mirror.
Considered Alternatives
SeaweedFS
-
Pros
-
Mature: more than ten years old, weekly releases, broad adoption
-
Passed all twelve checks with a bucket-scoped account
-
-
Cons
-
Several components (master, volume, filer, S3) with ports of their own. Each needs to stay unreachable from other containers: CVE-2026-73080 (fixed in 4.24) was an unauthenticated SSRF on the volume server’s gRPC port
-
Accounts move from an init script against the admin API to an identities file holding secrets, so more of the ops repository changes
-
The documented fallback if RustFS has to go
-
VersityGW
-
Pros
-
Very small (11 MiB), and objects are plain files on disk
-
-
Cons
-
Works only if the app holds root credentials, or after a change to how the app sets its bucket policy
-
Garage
-
Cons
-
Has no bucket policies. Anonymous reads exist only through its website endpoint, which would change the URL scheme and the data already stored
-
Stay on the Chainguard MinIO rebuild, or license AIStor
-
Cons
-
The first keeps an abandoned server, available only as
latest. The second puts a license key into CI and onto the host, from the vendor that just changed the terms
-
Keep the MinIO client tools
-
Cons
-
The
MinioNuGet package’s last release was 7.0.0 on 2025-11-05, andmcis available only as a Chainguardlatest-devrebuild or under the AIStor license, which Hetzner’s own documentation warns about -
It would keep two dependencies on the vendor this decision moves away from
-
Managed object storage (Hetzner)
-
Cons
-
Development and CI would still need a local S3 server, and it adds a running cost. Worth revisiting if self-hosting the storage stops paying off
-
Consequences
-
RustFS is young: 1.0.0 was published on 2026-09-16. Its pre-1.0 releases had serious advisories (for example CVE-2026-40937, an authorization bypass in the admin API, fixed in 1.0.0-alpha.94), all fixed before 1.0. Renovate proposes every stable release.
-
Revisit this decision if RustFS changes its license, stops releasing for three months, or ships a security fix late. The replacement starts with SeaweedFS and must pass
FileStorageContractTests. -
rcis pre-1.0 (0.1.36 at the time of writing). It runs only instorage-init, which stops the deploy if the policy is not attached afterwards; backups do not depend on it. -
AWS publishes
AWSSDK.S3almost daily, so Renovate takes it weekly. Since all Renovate runs are weekly now, the package needs no rule of its own and falls into thebackend dependenciesgroup. -
Staging starts on RustFS with empty storage and a reset database rather than a migration of the old bucket.