Replacing the Bundled MinIO: Options Assessment and Migration Plan
Date: 2026-10-04 Scope: Which S3-compatible store the stack should bundle now that MinIO Community Edition is dead, and how existing self-hosted users move over. Status: Research / proposal. No code or config has been changed. Recommendation: Garage (single-node mode) as the bundled default, SeaweedFS as the fallback, BYO S3 stays first-class. Several claims below are marked [unverified]; section 7 lists the spike that must run before implementation.
0. TL;DR
- Why now. The pinned image
quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z(docker-compose.infra.yml:36,deploy/helm/.../values.yaml:55-56) is the last community release. Upstream stopped publishing images in Oct 2025, the repo is archived (reported 2026-04-25; sources disagree between 2026-02-13 and 2026-04-25, which does not change the conclusion), Docker Hubminio/miniois gone, and the frozen quay image carries unpatched CVEs. One source also reports quay.io began requiring auth for anonymous pulls on 2026-09-24 [unverified; single SEO-grade source]. A fresh clone that cannot pull its object store is a broken product. - Our S3 surface is tiny. Six commands (
PutObjectwith user metadata,HeadObject,GetObjectstreamed,DeleteObject(s),ListObjectsV2,HeadBucket), path-style, SigV4, objects written with a single PUT. No presigned URLs, multipart, versioning, object lock or ACLs. Almost every candidate satisfies this, so the decision is about operability, license, maintenance health and migration cost, not feature coverage. - Garage wins on those axes. v2.3.0 added
garage server --single-node --default-bucketplusGARAGE_DEFAULT_{ACCESS_KEY,SECRET_KEY,BUCKET}, which removes thecreatebucketsinit container entirely. ~20-27 MB image on amd64/arm64/arm/386, one static Rust binary, AGPL-3.0 (same license as MinIO, no change in our posture), sponsored non-profit maintainer, an official Helm chart in-tree, and ~23 MiB idle RAM versus ~450 MiB for MinIO in one third-party measurement. - Zero application code change.
S3_*env vars,@aws-sdk/client-s3,forcePathStyleall stay. The change is compose, Helm, docs, and a migration script. - Real costs. No web console, no SSE, no versioning/object lock (we use none), key IDs must be Garage-shaped (cannot reuse
minioadmin) [unverified], region must be set to match, and Garage’s own docs call a single node “not for production” (the same caveat applies to single-drive MinIO, which is what we run today). - Side finding (bug). The dev/prod compose
createbucketsjob installs a hard 365-day bucket expiry (docker-compose.infra.yml:62) that fights the app’s per-team retention overrides (up toORG_MAX_RETENTION_DAYS, default 730;apps/ingest/src/config.ts:97-99). And the Helm chart never creates the bucket at all. Both disappear in the Garage design.
1. Inventory: what the project actually uses from S3
1.1 Client and addressing
| Aspect | Finding | Evidence |
|---|---|---|
| Client library | @aws-sdk/client-s3 v3 in ingest, web and the seed script. Not Bun’s native S3Client. |
apps/ingest/src/index.ts:3,22-30, apps/web/src/lib/s3.ts:3,7-15, packages/db/src/seed.ts:18 |
| Addressing | Path-style. Ingest reads S3_FORCE_PATH_STYLE, default true (apps/ingest/src/config.ts:103-107). Web hard-codes forcePathStyle: true and ignores the env var (apps/web/src/lib/s3.ts:12). Helm default for external S3 is false (values.yaml:81). |
cited |
| Auth | Static access key + secret via env; SigV4 (SDK default). No STS/IAM roles. | config.ts:100,111, index.ts:23-26 |
| Region | S3_REGION default us-east-1 (config.ts:110). MinIO accepted any region; Garage rejects a mismatch (section 2.2). |
cited |
| Endpoint | Required, URL-validated (config.ts:102). Defaults point at the compose service name http://minio:9000 (docker-compose.app.yml:56,90, docker-compose.prod.yml:53,83), docs/deploy/binaries.md:87,130. |
cited |
1.2 Operations actually issued
| Operation | Where | Notes |
|---|---|---|
PutObject (single PUT, Metadata, ContentType, optional ServerSideEncryption/SSEKMSKeyId) |
apps/ingest/src/lib/s3.ts:30-53; seed packages/db/src/seed.ts |
Whole transcript buffered in memory, “single-PUT … simpler than streaming multipart” (transcript-pipeline.ts:35-36). No multipart. User metadata holds the upload’s sha256, used for idempotency (routes/transcripts.ts:22,212-217). Metadata round-trip is therefore load-bearing. |
HeadObject |
lib/s3.ts:12-28 |
Reads user metadata; 404/NotFound mapped to null. |
GetObject (streamed, then zstd-decompressed) |
apps/web/src/lib/s3.ts:25-54, three web routes |
Proxied through the web app; no presigned URLs anywhere (grep for presign/getSignedUrl returns nothing). |
DeleteObject |
apps/ingest/src/jobs/sweep-retention.ts:95 |
Retention sweep. |
DeleteObjects (batch up to 1000) |
jobs/run-deletions.ts:58-73 |
GDPR-style user deletion. |
ListObjectsV2 (prefix transcripts/, MaxKeys: 500, continuation token) |
jobs/sweep-retention.ts:125-145 |
Orphan sweep. |
HeadBucket |
apps/ingest/src/index.ts:37-39 |
/readyz checks.s3. |
Key layout: transcripts/{yyyy}/{mm}/{dd}/{userId}/{sessionId}.jsonl.zst (lib/s3.ts:61-66). Objects are already zstd-compressed, so store-side compression gains nothing.
1.3 Features NOT used
Presigned URLs, multipart upload, bucket versioning, object lock/retention/legal hold, bucket policies/ACLs, object tagging, S3 events/notifications, website hosting, CopyObject. SSE is optional and off by default: only sent when S3_SSE_ALGORITHM is set (config.ts:112-115, app.ts:93-97); SECURITY.md:149-154 already tells MinIO users not to set it. A store without SSE therefore loses nothing today.
1.4 Lifecycle / retention
- Authoritative retention is application-side:
sweep-retentiondeletes by session age with per-team overrides clamped byORG_MAX_RETENTION_DAYS(config.ts:97-99,.env.example:65-69). - A redundant store-side rule is installed only by the dev/prod init container:
mc ilm rule add --expire-days 365 local/transcripts(docker-compose.infra.yml:62). It is documented as a feature indocs/runbooks/minio-full.md:~40andDESIGN_DOC.md:1070. Because it ignores team overrides, any team with retention above 365 days silently loses transcripts at 365. I recommend not reproducing it (section 4.4). - Helm creates no bucket and no lifecycle rule (
grep -rn -i bucket deploy/helmreturns only the value plumbing). Bundled-MinIO Helm installs depend on someone creatingtranscriptsby hand.
1.5 Operational surface
| Item | Today | Evidence |
|---|---|---|
| Healthcheck | curl -sf http://localhost:9000/minio/health/live, 5s/5s/12 retries |
docker-compose.infra.yml:46-50 |
| Init container | createbuckets using quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z: mc alias set, mc mb --ignore-existing, mc ilm rule add |
docker-compose.infra.yml:53-68; prod overlay re-declares its env (docker-compose.prod.yml:25-27) |
| Console | MINIO_CONSOLE_PORT=9001; used in runbook and on-call docs |
docker-compose.infra.yml:43, .env.example:30, docs/runbooks/minio-full.md, docs/on-call.md:16,59 |
| Data dir | bind mount ./data/minio:/data (MinIO’s xl.meta format; not readable by any other server) |
docker-compose.infra.yml:45; .gitignore:28,65-66; scripts/clean.ts:19; AGENTS.md:136 |
| Root creds | MINIO_ROOT_USER/MINIO_ROOT_PASSWORD are reused as the app’s S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY |
docker-compose.app.yml:54,59,88,92; docker-compose.prod.yml:54-55,84-85 |
| External-S3 escape hatch | S3_ENDPOINT_OVERRIDE, S3_ACCESS_KEY_ID_OVERRIDE, S3_SECRET_ACCESS_KEY_OVERRIDE |
docker-compose.prod.yml:53-55,83-85; .env.production.example:31-34 |
| Traefik overlay | minio: ports: !reset [] |
docker-compose.traefik.yml:14-15 |
| Helm | StatefulSet + ClusterIP Service on 9000/9001, probes on /minio/health/live, minio.* values, externalS3.* values, helpers pick external when externalS3.endpoint is set |
templates/minio.yaml:1-98, values.yaml:49-83, _helpers.tpl:91-140, NOTES.txt:55-58 |
| CI | Helm render with --set minio.enabled=false |
.github/workflows/helm-chart.yml:65 |
| Air-gapped / build docs | name quay.io/minio/minio and quay.io/minio/mc as images to mirror |
docs/deploy/air-gapped.md:147, build-from-source.md:16,45,79 |
| Tests | Ingest tests stub the S3 client (apps/ingest/test/helpers.ts:42); nothing in CI exercises a real S3 server. A replacement therefore ships with no automated compatibility proof unless we add one (section 5, step 6). |
2. Candidates
Facts are from the project pages and the sources listed at the end; anything resting on a single secondary source is flagged. “Fit” is against section 1, not general S3 completeness.
2.1 Summary matrix
| Candidate | License | Health (Oct 2026) | Fit for our 6 ops | Single-node footprint | Multi-arch image | Helm | Migrate from MinIO | Verdict |
|---|---|---|---|---|---|---|---|---|
| Garage | AGPL-3.0 | Deuxfleurs non-profit, since 2020, frequent releases (v2.2.0 2026-01-26, v2.3.0, v2.4.1 ~Sep 2026), 4.6k stars | Full: Put/Get/Head/Delete/DeleteObjects/ListV2/multipart/presign/path+vhost. Lifecycle: Expiration + AbortIncompleteMPU only. No SSE, versioning, object lock, tagging, policies | ~20-27 MB image, ~23 MiB idle RAM (third-party) | amd64, arm64, arm, 386 | Official in-tree chart, cluster-oriented (CRD, RF=3 default) | rclone copy; different on-disk format | Default |
| SeaweedFS | Apache-2.0 (separate paid enterprise edition) | 35k stars, 15k commits, since 2012; but a 2026 run of S3 CVEs (path traversal in CopyObject and DeleteObjects, unauthenticated filer IAM gRPC) fixed in 4.34 | Full incl. lifecycle, versioning, object lock, SSE-S3/KMS/C | weed mini/weed server -s3: master+volume+filer+S3+admin UI in one process; heavier than Garage |
Docker Hub chrislusf/seaweedfs, multi-arch |
Community chart | rclone | Fallback |
| RustFS | Apache-2.0 | 1.0.0 GA 2026-09-16 (3 weeks old); 34k stars; critical hardcoded-gRPC-token CVE-2025-68926 (fixed alpha.78) and 12 listed CVEs | Core S3, lifecycle, versioning, lock, SSE. S3 coverage “partial, validate your workload” | Rust, light; container runs UID 10001 | amd64+arm64 | Helm for k8s | rclone (MinIO on-disk compat is preview, rio-v2 flag) |
Watch list; too young |
| pgsty/minio (community fork) | AGPL (code); one source says CC BY 4.0 for docs [unverified] | Single maintainer (Pigsty’s Ruohang Feng) using AI agents; RELEASE.2026-04-17 closed 4 CVEs | Identical to MinIO, console restored | Same as MinIO | Docker Hub pgsty/minio, arm64, deb/rpm |
Existing chart works | Zero-copy: same data dir | Documented stop-gap only |
Chainguard cgr.dev/chainguard/minio |
Chainguard terms over AGPL source | Rebuilt daily from frozen upstream source; “free” per Chainguard docs | Identical to MinIO | Same | Multi-arch | n/a | Same data dir | Patches the image, not the code; no upstream to take fixes from. Stop-gap only |
| versitygw | Apache-2.0 | v1.5.0 2026-06-02; Versity (company) | Full S3 façade over POSIX dir or another S3 | Tiny; files stay as plain files on disk | Docker Hub | none known | Trivial: objects become files | Interesting niche (see 2.8); no durability story of its own |
| Ceph RGW | LGPL-2.1/3 | Very healthy | Full | RGW 256-512 MB per worker, OSDs 2-8 GB; 4 GB/40 GB is a demo | yes | Rook | rclone | Wrong scale (needs a cluster). Rejected |
| Zenko CloudServer (Scality) | Apache-2.0 | Active (last commit 2026-08-10, 81 commits last quarter) | Full for dev/test; file/in-memory backends | Node.js, hundreds of MB | amd64 mostly [unverified arm64] | none | rclone | Positioned as dev/test and a multi-cloud gateway, not a durable single-node store. Rejected |
| BYO S3 only (AWS S3, R2, B2, Tigris, any managed) | n/a | n/a | Whatever the provider supports | zero (no container) | n/a | n/a | rclone | Already supported; stays the production recommendation |
2.2 Garage in detail (the recommendation)
Fit. Every operation in 1.2 is on Garage’s supported list: PutObject, GetObject, HeadObject, DeleteObject, DeleteObjects, ListObjectsV2, multipart, SigV4, path-style and vhost-style, presigned URLs, GetBucketLocation. Unsupported (versioning stub, object lock, SSE, ACLs/policies, tagging, SigV2) are all outside section 1.3.
Gotchas that affect us
- Region must match. Default
s3_region = "garage"; a client sendingus-east-1gets “Authorization header malformed, unexpected scope”. We sets3_region = "us-east-1"in the shippedgarage.tomlso our existingS3_REGIONdefault keeps working, and so users who setS3_REGIONmust also change the Garage config (documented). - Key IDs are Garage-shaped. Garage access keys are
GK+ hex. We cannot feedminioadminintoGARAGE_DEFAULT_ACCESS_KEY[unverified: exact accepted format and whether single-node mode validates it; the quick-start example generatesGK$(openssl rand -hex 16)]. Consequence: the default dev credentials change, and.env.exampleships generated-looking defaults instead ofminioadmin. - No shell in the image.
dxflrs/garageis a single binary; healthcheck must be exec-form (["CMD", "/garage", "status"][unverified path/exit codes]) rather thancurl. Admin APIGET :3903/healthexists but there is nothing in the image to call it. - No console. Replaced in runbooks by
docker exec … /garage bucket info transcripts,/garage status,/garage stats. Optional third-party web UIs are out of scope. - Single node warning. Garage docs: single-node “provides no redundancy and should not be used in production.” Equivalent to today’s single-drive MinIO, which has no redundancy either, but we should say it plainly and keep BYO S3 as the production path (our docs already do:
values.yaml:50-51). - LMDB sensitivity. Default
db_engine = lmdb“can become corrupted after unclean shutdowns”; docs recommend sqlite as the more robust option and a checksumming FS or snapshots for metadata. We should default the shipped config to sqlite (slower, but our write rate is ~1 PUT per session) and document backups. LMDB files are also architecture-dependent: an amd64 to arm64 move of./data/garageis not possible with LMDB. - Lifecycle worker runs daily at midnight UTC. Irrelevant if we drop the bucket rule.
- XFS preferred, ext4 discouraged for large object counts (inode exhaustion). Transcripts are small and numerous; note in the runbook.
- Licensing. AGPL-3.0, same as MinIO. We run it unmodified as a separate container, so there is no linking/derivative concern; no change in posture.
2.3 SeaweedFS (the fallback)
Apache-2.0, 14 years old, broad S3 coverage including lifecycle/versioning/lock/SSE. Choose it over Garage if a user needs SSE or an Apache license. Reasons it is not the default: (a) a cluster of 2026 S3-layer CVEs (CVE-2026-55874, -55873, -58372, -72920), all fixed in 4.34, which is reassuring on response time and unreassuring on attack surface; (b) a multi-component architecture (master, volume, filer, S3, admin) even in weed mini; (c) a separate paid enterprise edition. Pin >= 4.34.
2.4 RustFS
GA on 2026-09-16 after beta in April and RC in August. Apache-2.0, MinIO-like ports (9000/9001) and console, active. Rejected as default because it is 3 weeks past GA, has a critical hardcoded-token CVE history from the alpha series, and the vendor itself says “S3 API coverage is partial; workload-level validation is recommended.” Re-evaluate in 6 months. Adoption signal worth noting: Laravel Sail ships it.
2.5 pgsty/minio and Chainguard (keep-MinIO options)
These make “do nothing to the data” possible. Neither fixes the structural problem: one maintainer with AI-assisted patching (pgsty), or a rebuilt-from-frozen-source image with no upstream fixes (Chainguard). I propose supporting them as migration sources and as a documented opt-out (OBJECT_STORE_IMAGE override, section 4.2), not shipping either as the default.
2.6 BYO-only
Removing the bundled store entirely makes just dev-infra-up and the quickstart depend on a cloud account, breaks the air-gapped and homelab stories in DESIGN_DOC.md:99, and removes the “same code path in dev and prod” rationale (DESIGN_DOC.md:1066-1072). Rejected as the default, retained as the production recommendation (it already works: S3_ENDPOINT_OVERRIDE, externalS3.*).
2.7 Pinning the last MinIO image
Rejected: unpatched CVEs (CVE-2025-62506; CVE-2026-40344 per one source), quay may now require authentication for pulls, and the deployment would silently rot. Acceptable only as a transient migration source.
2.8 versitygw (noted, not chosen)
Appealing for “objects as plain files you can ls”, which suits a bind-mount philosophy (AGENTS.md:136). But it adds a gateway whose durability is entirely the filesystem’s, with no integrity checking of its own, and a small project footprint compared with Garage. Revisit if users ask for browsable files.
3. Recommendation
Default: Garage dxflrs/garage:v2.4.x (pin to an exact tag, digest in release builds), single-node mode, sqlite metadata, region us-east-1, bucket transcripts.
Fallback: SeaweedFS >= 4.34 if the section 7 spike finds a blocker in Garage (key format, healthcheck, metadata round-trip), or for users who require SSE or Apache-2.0.
Always: BYO S3 is the production-grade path; the bundled store is for dev, homelab and small teams.
Why this over the alternatives, in one line each: smallest footprint and image; removes the init container and the cluster of MinIO-specific tooling (mc); license unchanged; no feature we use is missing; dedicated non-profit maintainer with steady releases; the app code is untouched.
Riskiest assumptions are listed in section 8.
4. Design
4.1 Naming
Keep S3_* as the application contract (no code change). Introduce neutral names for the bundled store so the next swap does not repeat this exercise:
| Old | New | Back-compat |
|---|---|---|
compose service minio |
objectstore |
add network alias minio on the service so user override files, S3_ENDPOINT=http://minio:9000 and docs/deploy/binaries.md keep resolving during the deprecation window |
MINIO_ROOT_USER / MINIO_ROOT_PASSWORD |
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY (already the app’s names; the bundled store is now seeded from the same pair) |
compose: GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY_ID:-${MINIO_ROOT_USER}} so existing prod env files still start; a preflight warns if the value is not Garage-shaped (4.3) |
MINIO_PORT (host S3 port, default 9000) |
S3_PORT (default 9000, maps to container 3900) |
${S3_PORT:-${MINIO_PORT:-9000}}. Keeping host 9000 means developers’ S3_ENDPOINT=http://localhost:9000 in .env needs no edit |
MINIO_CONSOLE_PORT |
removed | ignored with a one-line note in .env.example |
| container S3 port 9000 | 3900 | S3_ENDPOINT default inside the network becomes http://objectstore:3900; the minio:9000 alias is not reproducible on port 9000 inside the network, so an explicit S3_ENDPOINT=http://minio:9000 in a user’s override file breaks. Mitigate by publishing Garage’s S3 listener on 9000 inside the container too: set api_bind_addr = "[::]:9000" in the shipped garage.toml. This is the key compat trick: no endpoint string anywhere changes |
Helm minio.* |
objectStore.* (bundled) and unchanged externalS3.* |
minio.enabled=false honoured for one minor: enabled := and (not (hasKey .Values "minio" and not .Values.minio.enabled)) .Values.objectStore.enabled; if minio.enabled=true is set explicitly, fail with a pointer to the migration doc |
S3_ENDPOINT_OVERRIDE, *_OVERRIDE |
unchanged | none needed |
Decision to settle in review: binding Garage’s S3 listener to 9000 (above) versus the conventional 3900. 9000 buys zero-edit upgrades; 3900 matches Garage docs and every third-party snippet. I lean 9000 for one release, flagged in the config comment.
4.2 Compose
docker-compose.infra.yml replaces minio + createbuckets with one service:
objectstore: image: ${OBJECT_STORE_IMAGE:-dxflrs/garage:v2.4.1} command: ["/garage", "server", "--single-node", "--default-bucket"] environment: GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY_ID:?...} GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_ACCESS_KEY:?...} GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-transcripts} GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET:-<derived>} volumes: - ./infra/garage/garage.toml:/etc/garage.toml:ro - ./data/garage/meta:/var/lib/garage/meta - ./data/garage/data:/var/lib/garage/data healthcheck: test: ["CMD", "/garage", "status"] networks: { default: { aliases: [minio] } }Details to verify in the spike: whether --single-node auto-generates rpc_secret/admin token (so we do not need the env), the exact --default-bucket semantics for an existing bucket (idempotent on restart?), and whether the data dir needs a specific UID.
docker-compose.prod.yml:18-27 drops the two MinIO overrides and gets one objectstore block with :? guards on the S3 creds. docker-compose.app.yml:45,54,59,88,92 and prod.yml:53-55,67,83-85 swap MINIO_ROOT_* for S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY and depends_on: minio for objectstore (condition service_healthy; today they depend on minio, not on createbuckets, which is a latent race this removes). docker-compose.traefik.yml:14 renames the key. docker-compose.self-hosted.yml:11 drops quay.io/minio/* from its image list in the comment.
4.3 Preflight (the data-loss guard)
The dangerous failure is silent: user pulls the new release, ./data/minio still holds their transcripts, Garage starts empty, and /readyz is green. DB rows point at keys that 404.
Add scripts/check-object-store.ts, called by just dev-infra-up, dev-up, prod-up, prod-source-up before docker compose up:
- If
./data/miniois non-empty and./data/garagedoes not exist: refuse, print the migration command (4.5).OBJECT_STORE_ALLOW_EMPTY=1overrides for users who truly want a fresh store. - If
S3_ACCESS_KEY_IDis not Garage-shaped: print theopenssl randone-liners to generate a pair. (Exact regex from the spike.) - If
MINIO_*vars are present: warn once with their replacements.
Compose alone cannot express (1); this is why it lives in the Justfile layer, consistent with “Justfile is the preferred interface” in CLAUDE.md.
4.4 Lifecycle and retention
Do not recreate the 365-day bucket rule. Retention remains owned by sweep-retention (TRANSCRIPT_RETENTION_DAYS, per-team overrides, ORG_MAX_RETENTION_DAYS). Update .env.example:65, runbooks/minio-full.md and DESIGN_DOC.md:1070 (“Lifecycle rules support easy 1-year retention enforcement”) accordingly. For users who want a store-side backstop, BYO S3 lifecycle rules remain available and Garage supports Expiration if they set one with aws s3api/rclone. State in the migration notes that the old MinIO rule is not carried over, and why. This is a behaviour change for anyone relying on the 365-day cap with longer team overrides configured; call it out.
4.5 Data migration for existing self-hosted users
Garage cannot read MinIO’s on-disk layout, so this is a copy, not a swap.
Script: scripts/migrate-minio-to-garage.sh (documented in a new docs/deploy/migrate-from-minio.md), invoked via just migrate-object-store:
- Stop
ingestandweb(writers) to get a consistent copy; transcripts are write-once per session key so a brief stop is simple and safe. - Rename
./data/minioto keep it (./data/miniostays untouched until the user deletes it; we never delete). - Start the old server read-only as the source on a private network:
MIGRATION_SOURCE_IMAGEdefaults toquay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z; if the pull fails (auth), the script falls back topgsty/miniothencgr.dev/chainguard/minioand says which one it used. Users whose MinIO container is still running and cached can skip this and point at their live endpoint. - Start Garage on the new data dir (default-bucket mode creates
transcripts). rclone copy --metadata --immutable minio:transcripts garage:transcriptsin anrclone/rclonecontainer.--metadatais required so the sha256 user metadata (section 1.2) survives; [unverified that rclone preservesx-amz-meta-*on S3-to-S3 with these flags; the spike must prove it]. Do not use--checksumon the final pass (a reported rclone/Garage interaction, per the jacar.es write-up).- Verify:
rclone check --download(multipart ETags are not comparable, though ours are single-PUT so plainrclone checkalso works), then compare three counts: source object count, destination object count, andSELECT count(*) FROM sessions WHERE transcript_s3_key IS NOT NULL. A mismatch against the DB is the check that actually matters, because it catches pre-existing orphans and missing objects. - Spot-check one object:
HeadObjectmetadata equals the source,GetObjectdecompresses withzstd -d. - Print how to remove the old data dir and old images.
Scale sanity check: a third-party measurement copied ~10k objects (1 GB) in 42 s at 24 MiB/s; even with 1M transcripts this is an hours-scale, restartable (rclone copy is idempotent) job.
Rollback: ./data/minio is untouched; set OBJECT_STORE_IMAGE to the pgsty image and restore the old compose file. Document it.
Kubernetes: the bundled-MinIO PVC (data-<release>-minio-0) is orphaned by the StatefulSet rename. Procedure: (1) keep the old StatefulSet one more release behind objectStore.legacyMinio.enabled=true (image configurable, no new features); (2) ship an opt-in migrate-minio Job template (rclone, same logic as above); (3) after success, flip legacyMinio.enabled=false, leaving the PVC for the user to delete. NOTES.txt prints the commands. Users on external S3 (minio.enabled=false, the production-recommended setup) need only the values rename shim.
Binary installs (docs/deploy/binaries.md:87-89,130-132,157-158,215-216): the docs use minioadmin credentials against http://minio:9000; replace with the BYO-S3 or Garage example. No runtime change.
4.6 Helm
Replace templates/minio.yaml with templates/object-store.yaml: a single-replica StatefulSet running garage server --single-node --default-bucket, config via ConfigMap (garage.toml) and a Secret for the key pair and RPC secret (the current chart puts credentials into plain env.value, templates/minio.yaml:32-35; moving them to a Secret is a small improvement worth making while the file is rewritten). Probes: exec /garage status (no httpGet possible against the S3 port without credentials; the admin :3903/health endpoint could serve httpGet if enabled, which is cleaner. Decide in the spike). Service exposes 9000 only (no console port). Update _helpers.tpl:91-140 (s3Endpoint currently builds http://<fullname>-minio:9000), NOTES.txt:55-58, values.yaml:49-71, and add externalS3.region/forcePathStyle guidance. The in-tree official Garage chart is not used: it is cluster-oriented (CRD garagenodes.deuxfleurs.fr, default replication 3), which is the wrong shape for a single-node bundled dependency and would add a CRD to every install.
Because Garage now creates the bucket itself, the “bucket never created” gap in the current chart is fixed as a side effect. helm-chart.yml:65 changes to --set objectStore.enabled=false and gets a second render without it, plus one render with --set minio.enabled=false to prove the shim.
4.7 Docs and runbooks
docs/runbooks/minio-full.mdbecomesobject-store-full.md(keep a one-line redirect file at the old path, sincedocs/on-call.md:59anddocs/runbooks/ingest-down.md:45link to it). Replace console steps withgarage status,garage bucket info,garage stats; replacedu -sh ./data/miniowith./data/garage; replace “re-run createbuckets” with “restartobjectstore(default-bucket is idempotent)” [to verify]; drop themc ilmline; add the region-mismatch and key-format failure modes to Diagnose.docs/deploy/air-gapped.md:147,build-from-source.md:16,45,79: one image (dxflrs/garage) replaces two (minio,mc).docs/deploy/kubernetes.md:46,92-104,229-232: values block, “same for MinIO/external S3”, the MinIO Operator pointer (replace with “use external S3 for HA”), backups note (PVC snapshots; Garage metadata snapshot caveat).SECURITY.md:66,149-154: “S3-backed deployments (not MinIO)” becomes “(not the bundled Garage, which has no SSE)”; the sentence about MinIO default SSE behaviour changes accordingly.DESIGN_DOC.md:99,131,733,1063-1075: the 11.3 decision record gets a dated supersession note (do not rewrite history): MinIO chosen then; replaced 2026-10 by Garage because of upstream archival; lifecycle rationale updated per 4.4.README.md:47,docs/PROJECT_OVERVIEW.md:86,docs/on-call.md:16,59,docs/deploy/README.md:43,54,82,AGENTS.md:136,138,packages/db/AGENTS.md:269,PLAN.md,presentation/index.html,scripts/package-web.sh:91,scripts/clean.ts:19,.gitignore:28,65-66(adddata/garage;infra/.minio-data/is already a dead path),tasks/*(leave historical task files alone excepttasks/INDEX.mdand a new task file).- Add a task (next free number in
tasks/, listed intasks/INDEX.md) so status is tracked per the rootCLAUDE.mdrule.
5. Implementation sequence
- Spike (section 7). Gate on results; fall back to SeaweedFS if a blocker appears.
- Compat test first. Add
apps/ingest/test/s3-compat.integration.test.tsexercising exactly the six operations plus user-metadata round-trip, againstS3_ENDPOINTfrom env, skipped when unset. This is the missing proof from 1.5 and makes any future store swap a one-line CI matrix change. - CI. Add a
garageservice container (ordocker runstep) to a job in.github/workflows/ci.ymlrunning that test. - Compose +
infra/garage/garage.toml+ preflight + Justfile recipes. - Migration script and
docs/deploy/migrate-from-minio.md; test on a seeded MinIO volume built frompackages/db/src/seed.ts. - Helm chart + CI render matrix.
- Docs/runbooks/DESIGN_DOC supersession note.
- Release as
feat(deploy)!: replace bundled MinIO with Garage. The bang is deliberate: the default store changes and existing bundled-store users must run a migration, even though env/endpoint back-compat softens the rest. (RootCLAUDE.md: the parser reads subjects only, and!triggers major.) - Gate:
bun run check,typecheck,build,testlocally (CI skipsbuild).
Also opportunistic, separate commit: make apps/web/src/lib/s3.ts:12 honour S3_FORCE_PATH_STYLE like ingest does (currently hard-coded true; harmless for Garage, wrong for AWS virtual-host-only setups).
6. Files that would change
Compose/env: docker-compose.infra.yml, docker-compose.app.yml, docker-compose.prod.yml, docker-compose.traefik.yml, docker-compose.self-hosted.yml (comment), .env.example, .env.production.example, .gitignore, Justfile, new infra/garage/garage.toml, new scripts/check-object-store.ts, new scripts/migrate-minio-to-garage.sh, scripts/clean.ts (comment), scripts/package-web.sh:91 (comment).
Helm: deploy/helm/ai-agents-observability/templates/minio.yaml (replaced by object-store.yaml, optional migrate-minio-job.yaml), values.yaml, templates/_helpers.tpl, templates/NOTES.txt, Chart.yaml (version bump), .github/workflows/helm-chart.yml.
App (comments/tests only, no behaviour change): apps/ingest/src/config.ts:112-115 and transcript-pipeline.ts:35 (comments mention MinIO), new apps/ingest/test/s3-compat.integration.test.ts, .github/workflows/ci.yml, optional apps/web/src/lib/s3.ts:12.
Docs: docs/deploy/{README,kubernetes,air-gapped,binaries,build-from-source}.md, new docs/deploy/migrate-from-minio.md, docs/runbooks/minio-full.md to object-store-full.md, docs/runbooks/ingest-down.md, docs/on-call.md, docs/PROJECT_OVERVIEW.md, README.md, SECURITY.md, DESIGN_DOC.md, PLAN.md, AGENTS.md, packages/db/AGENTS.md, presentation/index.html, tasks/INDEX.md + new task file.
7. Pre-implementation spike (must pass before step 4)
Run in a scratch directory with Garage v2.4.x, no repo changes:
garage server --single-node --default-bucketwithGARAGE_DEFAULT_*: does it start, create the bucket, and persist across restart (idempotent)? Does it needrpc_secret/admin token in config or env?- Accepted access-key/secret formats; can the default key be set to a 32-hex ID without a
GKprefix? What does it do withminioadmin? - Set
s3_region = "us-east-1"andapi_bind_addr = "[::]:9000"; run the compat test (step 2 above) with the AWS SDK andforcePathStyle: true. PutObjectwithMetadata: {sha256: ...}thenHeadObject: metadata key casing and presence. Our idempotency check depends on it.ListObjectsV2pagination withMaxKeys: 500over >1000 keys;DeleteObjectswith 1000 keys.- Healthcheck:
["CMD","/garage","status"]exit codes when healthy/unhealthy and during layout bootstrap; or:3903/healthvia a minimal probe. rclone copy --metadatafrom a MinIO container to Garage: metadata preserved?rclone checkclean?- Container UID/permissions on a bind-mounted
./data/garageon Linux and macOS (theAGENTS.md:136bind-mount philosophy). - Memory and startup time with sqlite vs lmdb.
Any failure in 1, 3, 4 or 7 flips the default to SeaweedFS and reuses sections 4.1-4.7 with image and probe swapped.
8. Riskiest assumptions
--single-node --default-bucketand theGARAGE_DEFAULT_*env contract behave as the quick-start describes (from the official docs; release notes themselves were not retrievable). If not, we are back to a layout/bucket/key init container using thegarageCLI, which needs a shell image we do not have (a sidecar or adocker execentrypoint).- Key-ID format. If Garage insists on
GK…, every existing prod env file must change credentials; the preflight handles it but it is friction. - User metadata round-trips through Garage and through rclone. The idempotency path relies on it (
routes/transcripts.ts:212-217); a miss would cause re-uploads, not data loss, but the migration correctness story depends on it. - Source reliability. Several dates and CVE claims (archival date, Docker Hub deletion on 2026-09-11, quay auth change on 2026-09-24, CVE-2026-40344, “MinIO RAM 452 MiB vs Garage 22.6 MiB”) come from blog posts and aggregator pages, not primary sources. None of them change the decision (the image is unmaintained regardless), but treat the numbers as indicative.
- Garage’s maintainer continuity. A volunteer non-profit; mitigated by AGPL source, a compat test that makes swapping cheap, and BYO S3 as the production path.
- Single-node Garage is acceptable for homelab/dev. Same redundancy as today’s single-drive MinIO, but Garage’s own docs say not to use it in production; our docs must say the same and point at BYO S3.
- Dropping the 365-day store rule is the right call. It is a behaviour change for deployments that rely on it as a hard cap; I argue it is a latent bug against
ORG_MAX_RETENTION_DAYS, but a reviewer may prefer to carry it over via a post-startPutBucketLifecycleConfigurationstep. - Binding Garage to container port 9000 keeps endpoints stable but deviates from Garage convention; worth a second opinion.
Sources
- MinIO status: pinggy.io, MinIO archived, stormdevelopments.ca, madewithlove, HN: MinIO stops distributing Docker images, minio/minio repo
- Garage: S3 compatibility, quick start, configuration reference, real-world deployment, GitHub mirror, Docker Hub tags, Helm chart, MinIO to Garage migration write-up
- SeaweedFS: repo, CVE-2026-55874, CVE-2026-55873, CVE-2026-72920
- RustFS: repo, 1.0 GA, CVE-2025-68926
- Forks: pgsty/minio “promise kept”, OpenMaxIO, Chainguard minio
- Others: versitygw, Zenko CloudServer, Ceph RAM planning
Adversarial review (Opus)
Reviewer: Opus, 2026-10-04. I verified the codebase claims at file:line, checked the external claims against primary sources (deuxfleurs Gitea release API, GitHub repo/advisory API, registry APIs), and ran an empirical spike. Docker is not installed on the review machine, so I did not run the container image. I ran the same release binaries natively instead, extracted (not installed) from Homebrew bottles into the scratchpad: Garage cargo:2.4.1 [bundled-libs, k2v, lmdb, metrics, sqlite], SeaweedFS 4.48, MinIO RELEASE.2025-10-15 as the migration source, and rclone 1.75.1. The S3 client was the repo’s own @aws-sdk/client-s3 3.1110.0 with forcePathStyle: true. Container-only behaviour (image path, UID, bind mounts on Linux) is checked against the upstream Dockerfile, not observed.
Verdict: recommendation stands, with changes.
The choice of Garage survives, and the spike made the case stronger. Garage passed every operation the app issues, and the metadata round-trip that idempotency depends on works. It sat at about 20 MiB RSS idle and 44 MiB after the spike, against 468 MiB for SeaweedFS and 415 MiB for MinIO measured in the same session. The SeaweedFS fallback looks worse than the proposal says. Several of the proposal’s load-bearing premises are wrong, however, and so is its migration verification. The plan as written would ship a dev stack that crash-loops on every existing .env, and a migration check that cannot detect the failure it was written to catch.
Spike results
Garage single-node config: db_engine = "sqlite", replication_factor = 1, rpc_bind_addr = 127.0.0.1:39101, [s3_api] s3_region = "us-east-1", api_bind_addr = 127.0.0.1:39100, [admin] api_bind_addr = 127.0.0.1:39103. Command: garage -c garage.toml server --single-node --default-bucket with GARAGE_RPC_SECRET, GARAGE_DEFAULT_{ACCESS_KEY,SECRET_KEY,BUCKET}.
| Test | Garage 2.4.1 | SeaweedFS 4.48 (weed mini) |
|---|---|---|
Starts without rpc_secret |
No: rpc_secret value is missing |
n/a |
rpc_secret format |
Exactly 64 hex (32-hex and non-hex both rejected: “expected 32 bytes of random hex”). Rotating it on existing single-node data is harmless | n/a |
Key ID minioadmin |
Accepted. So did AKIAIOSFODNN7EXAMPLE and a 32-hex ID without GK. v2.3.0 notes: “relax requirements on imported access keys … transition from other S3 storage providers (#1262)” |
Accepted |
Secret minioadmin (10 chars) |
Rejected, fatal: Secret keys should be at least 16 characters long |
Accepted |
| Restart, same env | Idempotent: no error, bucket and key kept | Idempotent (-bucket creates only if missing) |
| Restart, changed secret | Fatal, so a crash loop under restart: unless-stopped: Access key minioadmin is associated with a secret key different than the one given |
n/a |
| Restart, changed key ID | Starts and creates a second key. The old key stays valid | n/a |
| HeadBucket | 200 | 200 |
PutObject + Metadata{upload-sha256} then HeadObject |
Round-trips exactly, lower-case key, ContentType kept | Same |
| Overwrite replaces metadata (re-ship path) | Yes | Yes |
| GetObject bytes equal | Yes | Yes |
| HeadObject missing key | NotFound/404, instanceof S3ServiceException, so lib/s3.ts:20-25 maps it to null |
Same |
40 MiB single PUT (cap is 200 MB, routes/transcripts.ts:18) |
OK | OK |
ListObjectsV2 MaxKeys:500, prefix, 1202 keys |
3 pages, exact, no prefix leak | Same |
| DeleteObjects 1000 keys / incl. a missing key | 1000/0 errors; missing key reported Deleted | Same |
ServerSideEncryption: AES256 sent |
Accepted silently | Accepted silently |
Client region garage against server us-east-1 |
400 (mismatch rejected, as the doc predicts) | Accepted (region-agnostic) |
| Wrong secret | 403 | 403 |
garage status / garage health (2.4.0+) |
exit 0 healthy, exit 1 server down | n/a |
Admin GET :3903/health, no token configured |
200 “Garage is fully operational”, so an httpGet probe works for Helm |
n/a |
rclone copy --metadata --immutable MinIO→store, 300 transcripts + 20 rationales |
320/320, 300/300 upload-sha256 intact |
300/300 intact |
Same copy without --metadata (control) |
0/300 metadata survive | not run |
rclone check / check --download on the metadata-stripped control |
“0 differences found” | not run |
| RSS idle / after spike | 20 MiB / 44 MiB | 468 MiB after spike |
Also verified:
- Pulls:
quay.io/minio/minioandquay.io/minio/mcreturn 401 to an anonymous pull token, while a control (quay.io/prometheus/node-exporter) returns 200.hub.docker.com/v2/repositories/minio/minioreturns 404. The GitHub API showsminio/minioarchived: true, last pushed 2026-04-24. - Garage image:
dxflrs/garage:v2.4.1is multi-arch (arm64, amd64, 386, arm). The upstream Dockerfile isFROM scratch,COPY … /garage,CMD ["/garage","server"], so the binary path is/garage, there is no shell, and it runs as root.
Findings
BLOCKER 1. The credential premise is wrong, and the real constraint breaks every existing dev .env.
- The doc’s gotcha 2 says key IDs must be
GK…. The spike says otherwise: any key ID is accepted,minioadminincluded. The real constraint is a secret of at least 16 characters. - Today
.env.example:26,37shipsminioadmin/minioadmin. Every developer’s copied.envtherefore hands Garage a 10-character secret, and Garage refuses to start. - MinIO’s minimum is 8 characters, so prod
MINIO_ROOT_PASSWORDvalues of 8-15 characters (.env.production.example:23) break in the same way. - Changing the secret after first boot is also fatal, which means a crash loop.
- Changing the key ID silently leaves the old key working. That is a revocation footgun.
Required:
- The preflight checks
len(secret) >= 16, not aGKregex. - Keep
minioadminas the dev key ID and change only the dev secret. - Document rotation as
garage key delete <old>plus an env change, not an env edit alone. - Drop the “cannot reuse minioadmin” text from §0, §2.2, §4.3 and §8.2.
BLOCKER 2. rpc_secret is mandatory and has a strict format. <derived> (§4.2) cannot be built in Compose.
Compose interpolation has no hash function, and the secret must be exactly 64 hex. Required: ship a fixed, documented constant default and bind RPC to loopback inside the container (rpc_bind_addr = "127.0.0.1:3901", rpc_public_addr = "127.0.0.1:3901"). The healthcheck exec still reaches it, as I did in the spike, and a public constant then grants nothing to other containers on the network. Without the loopback bind, a constant secret gives any container on the Compose network full cluster admin. Helm: generate the secret into the Secret with randAlphaNum-style hex and lookup it so it survives upgrades.
BLOCKER 3. “Unverified” pull breakage is verified. main is broken for fresh installs today, and the plan has no bridge.
just dev-infra-upon a machine without a cached image cannot pullminioormc(anonymous 401, confirmed against a working control).- The migration script’s default
MIGRATION_SOURCE_IMAGE(§4.5 step 3) is that same unpullable image. The air-gapped mirror list (docs/deploy/air-gapped.md:147) and Helm’s default image (values.yaml:55-56) fail the same way. legacyMinio.enabled=true(§4.5, Kubernetes) would schedule an unpullable StatefulSet.
Required: split the release.
- Ship a
fix(deploy):patch now that swaps the image topgsty/minio:RELEASE.2026-08-04T00-00-00Zandpgsty/mc(Docker Hub, amd64 and arm64, latest tag 2026-08-04; the proposal’s “2026-04-17” is stale). This works on the same data directory with zero migration. - Ship Garage as the
feat(deploy)!:major afterwards. - Make pgsty the default migration source and the default legacy-Helm image.
MAJOR 1. The S3 inventory misses a second object family, and that breaks the migration check.
What was missed:
apps/ingest/src/lib/judge-rationales.ts:30,83,141writesjudge-rationales/{sessionId}/v{n}.json(PutObject) and batch-deletes it. The prefix is deliberately outsidetranscripts/so the orphan sweep leaves it alone (judge-rationales.ts:24-27).- Ingest also issues
GetObject(jobs/index-transcripts.ts:45, which feeds index, judge, backfill and embed). jobs/embed-transcripts.ts:171-179builds its ownS3Client.
Impact:
- §1.2’s claim that GetObject happens only in web is wrong.
- The key-layout line is incomplete.
- §4.5 step 6’s three-count check (
objects == sessions with transcript_s3_key) can never match on any deployment that ran the judge. It must counttranscripts/objects againstsessions.transcript_s3_key, andjudge-rationales/objects againstscores.rationale_ref.
MAJOR 2. The migration verification is blind to the one property it has to protect.
The spike shows rclone check, with or without --download, reports “0 differences” on a copy that lost all user metadata, and the step 7 single-object spot check is a sample of one.
Required:
- Run
rclone lsjson -R -Mon source and destination and diff theupload-sha256field for every object. The output is diffable, as checked in the spike. - Fail the script on any mismatch.
- Make
--metadatanon-optional in code, not only in prose.
Two side effects to note:
- rclone adds
mtime/btimeuser metadata on the destination. This is harmless because the app reads onlyupload-sha256. - The destination
LastModifiedbecomes the migration time.
MAJOR 3. The SeaweedFS fallback is mis-assessed. >= 4.34 is not a safe floor.
The GitHub advisory API lists about 25 SeaweedFS advisories in roughly four months. Critical ones published after 4.34 include:
- 2026-09-18: “S3 gateway gRPC PutIdentity and RemoveIdentity accept unauthenticated requests”
- 2026-09-09: “Missing Authorization in seaweed-volume”
- 2026-09-12: “ArangoDB AQL Injection in S3 Listing…”
- 2026-09-23: “SFTP accepts an empty password…”
- 2026-09-11: an unauthenticated SSRF reaching IMDS
Several show no patched version. Separately, weed mini by default:
- runs the Admin UI with auth disabled when
-admin.passwordis empty (its own-h); - also opens Iceberg (:8181) and Lance (:9101) listeners, and Lance had a path-traversal advisory on 2026-09-18.
In SeaweedFS’s favour, the spike confirms it accepts minioadmin/minioadmin and any region, so it has zero credential friction, and it has a UI.
Required:
- Restate the fallback as “latest release at implementation time, every non-S3 listener bound to loopback, admin password mandatory”.
- Rewrite §2.3’s CVE paragraph from the advisory list.
- This strengthens Garage as the default.
MAJOR 4. §4.1 contradicts itself on the S3 port, and the compose sketch is incomplete.
- The table row says container port 3900 and
S3_ENDPOINT=http://objectstore:3900. The very next row says bind 9000. - The §4.2 sketch has no
ports:mapping and nogarage.tomlcontent. - The spike shows
api_bind_addrcan be any port, so 9000 works.
Decide 9000. Publish ${S3_PORT:-${MINIO_PORT:-9000}}:9000, delete the 3900 row, and include the full garage.toml (sqlite, replication_factor = 1, region, loopback RPC, admin on 3903) in the doc.
MAJOR 5. Renaming the Compose service is a break that the network alias does not cover.
The minio alias covers DNS only. Anything that keys on the service name breaks once the service is renamed:
- user override files that set
minio:(Compose then errors on a service with no image); depends_on: minio;docker compose logs minio.
The repo’s own docker-compose.traefik.yml:14 is one such reference. Either keep the service key minio for one major release, with a neutral container_name/comment, or list this explicitly in the breaking-change notes.
MAJOR 6. The plan is over-engineered in Helm and in the migration script.
Cut:
objectStore.legacyMinio.enabled(its image is unpullable; see Blocker 3);- the
migrate-minioJob template; - the three-image fallback chain.
Replace them with:
failwhenminio.enabled=true;- a documented one-off
kubectl run rclone/rclonerecipe; - a single pgsty source image.
Keep the Compose preflight: it is the one real data-loss guard. Note that it only protects users who go through just, so the README and deploy docs must say so.
MINOR findings
- Lifecycle drop: verified safe, and agreed.
- App retention defaults to 365 days (
config.ts:138-139). - The sweep is seeded as enabled (
scheduler.ts:347). - The sweep honours team overrides up to
ORG_MAX_RETENTION_DAYS= 730 (config.ts:98,sweep-retention.ts:66-78), and0means disabled. - The
mc ilmrule (docker-compose.infra.yml:62) is bucket-wide. It therefore also expiresjudge-rationales/and silently overrides a team’s 730 days or an operator’s 0, leaving DB rows that point at 404s. The bug is real. - The only thing lost is a backstop for deployments that disabled the sweep. Say so in the release notes.
- Do not reintroduce a store-side rule after migration: the copy resets
LastModified, so it would restart every object’s clock.
- App retention defaults to 365 days (
- “Helm never creates the bucket”: confirmed.
templates/has no bucket job; the only hits arevalues.yaml:61,80and_helpers.tpl:117-121. Default-bucket mode fixes it. - Healthcheck: use
["CMD","/garage","health"]. It was added in 2.4.0 and is verified at exit 0 healthy and 1 down; container env providesGARAGE_RPC_SECRETto the exec. For Helm, usehttpGet :3903/health(verified unauthenticated 200). Pin>= 2.4.0, which also fixes DeleteObjects reporting missing keys (#1460). - SSE is silently accepted by both Garage and SeaweedFS: no error, and no encryption is implied. Today
SECURITY.md:149-154only warns MinIO users. Make it say thatS3_SSE_ALGORITHMagainst the bundled store does not encrypt, and consider having ingest warn at startup when SSE is set and the endpoint is the bundled host. - Source corrections:
- RustFS has 35 GitHub advisories, not 12. GHSA rates CVE-2025-68926 medium, not critical. 1.0.0 (2026-09-16) is confirmed and 1.0.1 shipped on 2026-10-03.
- Garage v2.2.0 is dated 2026-01-24, not 01-26. v2.3.0 (2026-04-16) is confirmed to add
--single-node/--default-*, and 2.4.1 is dated 2026-09-08. - “Frequent releases” means about one minor per quarter.
- Garage 2.3.0 also fixed “silent write errors (#1360)”. That is a recent durability bug, which is one more reason the bundled store stays non-production.
- No Prometheus or Grafana dependency on MinIO metrics (
infra/prometheus/prometheus.ymlscrapes only the apps), so nothing to migrate there. The seed (packages/db/src/seed.ts:41-49,931) writes withoutupload-sha256metadata, which is harmless. - Breaking-change framing: justified for the Garage step, because existing bundled-store data needs a copy. It is not needed for the immediate fix (Blocker 3), which should be a patch.
Required changes before implementation
- Split delivery: a
fix(deploy):patch to pgsty images now, then Garage asfeat(deploy)!:. - Rewrite the credential guidance around “secret of at least 16 characters, any key ID”. Change the dev secret only, and document key rotation.
- Ship a constant
rpc_secretwith loopback RPC bind in Compose, and a generated,lookup-persisted secret in Helm. - Fix the inventory (judge rationales, ingest GetObject, the embed client) and the migration counts per prefix.
- Replace
rclone checkwith a fulllsjson -Mmetadata diff that is fatal on mismatch. - Resolve the port to 9000 and put the complete
garage.tomlin the doc. - Downgrade and requalify the SeaweedFS fallback: latest release, loopback non-S3 listeners, admin password required.
- Keep the
minioservice key or call out the rename as breaking. - Cut the Helm legacy StatefulSet, the migrate Job and the image fallback chain.
- Use the
garage health/:3903/healthprobes and pin>= 2.4.0. - Add the SSE-is-a-no-op warning to
SECURITY.md.
Implementation review (Opus)
Reviewer: Opus, 2026-10-04, against the uncommitted Garage implementation in this worktree.
Verdict: ship after the fixes below. I applied all of them in the worktree.
Docker is still unavailable on the review machine. Everything marked verified ran natively, using the same release binaries as in the first review (Garage 2.4.1, MinIO RELEASE.2025-10-15, rclone 1.75.1), plus Helm v3.18.6 (the version CI uses) and Helm 4.3.0. Anything that only exists inside a container is listed under “Still unverified”.
Verified
- Garage boots with the shipped config. I ran
infra/garage/garage.tomlwith only the paths and ports remapped (a diff confirmed nothing else changed), the exact compose command line (garage -c … server --single-node --default-bucket), and the compose defaults (minioadmin/minioadmin-dev-secret/transcripts/ the 64-hex RPC constant):- It starts with
[admin]set and noadmin_token. - It creates the key and the bucket.
- It listens on S3, plus RPC and admin on loopback only.
- It starts with
- Healthcheck.
garage -c <conf> health, invoked exactly as the healthcheck does, exits 0. It exits 1 whenGARAGE_RPC_SECRETis absent, and 1 when the server is down. - Admin API.
/healthreturns 200./v2/GetClusterStatusreturns 403 without a token./metricsis open, but only on loopback. - Repo S3 client. I drove the store with the repo’s client from
apps/ingest, using the env names compose passes to ingest. All 17 checks passed: HeadBucket, Put with metadata then Head, overwrite, Get, the 404 mapping, a 40 MiB PUT, judge-rationale PUT, ListV2 pagination, DeleteObjects ×1000, DeleteObject, SSE accepted, region mismatch → 400, wrong secret → 403. - Migration copy and verifier. The script’s rclone env-var remotes,
copy --metadata --immutable,lsjson -R -M --files-only, thenscripts/verify-object-copy.ts:- Real MinIO→Garage copy (300 transcripts + 20 rationales): passes. Extra destination objects are tolerated. rclone’s
mtime/btimeare ignored, andtier/content-typematch. - Copy made without
--metadata: fails with “300 with differing size/metadata”.rclone checkpasses that same broken copy.
- Real MinIO→Garage copy (300 transcripts + 20 rationales): passes. Extra destination objects are tolerated. rclone’s
- Helm (3.18.6 and 4.3.0):
- lint and default render are clean.
- external-S3 render emits no object-store resources.
--set minio.enabled=falsefails with the migration pointer.- a short secret fails.
- I ran the CI job steps locally and they pass.
- The
lookup-basedrpc-secretpersistence, simulated by substituting lookup results: an existing secret is reused, while an empty or missing key generates a new 64-hex value. - The bucket is created by default-bucket mode (verified above). The chart passes it through
GARAGE_DEFAULT_BUCKET=objectStore.bucket= ingest’sS3_BUCKET.
- Compose interpolation (verified from source, not executed). In compose-go
template/template.go,withDefaultWhenAbsencesubstitutes the default lazily, only when the outer variable is unset or empty. So${S3_SECRET_ACCESS_KEY:-${MINIO_ROOT_PASSWORD:?msg}}raises only when both are missing.getFirstBraceClosingIndexhandles nesting, so${S3_PORT:-${MINIO_PORT:-9000}}resolves as intended. - Gates:
bun run check: clean.- typecheck, build and test with
--filter='!@ai-agents-observability/hook': all pass (14 test tasks), andbun run test:scriptspasses 4/4. @ai-agents-observability/hook#buildfails becausecargois not installed on this machine. This predates the change:apps/hookhas no diff, andscripts/build.tsshells out tocargo build.
Findings and fixes
-
MAJOR (fixed): the Compose data-loss guard could be bypassed. It checked “
./data/garageis non-empty”, so it let the stack start in two cases where Garage held a partial or empty store while/readyzwas green:- after a migration that died half-way;
- after a single
docker compose uprun outsidejust.
Fix: the migration script now writes
data/garage/.minio-migration-completeonly after verification passes.scripts/check-object-store.shrequires that marker whenever./data/miniohas data.OBJECT_STORE_ALLOW_EMPTY=1records the decision in the same file, so it is needed only once. I tested 7 scenarios in a scratch tree: fresh clone, MinIO-only, half-migrated, allow-empty, after allow-empty, marker present, old short-secret.env. -
MAJOR (fixed): Helm upgrades with default values silently lost data. The
hasKey "minio"check only catches users whose values file mentionsminio:. An install on defaults would delete the MinIO StatefulSet, start an empty Garage, and orphandata-<fullname>-minio-0. Fix:validatenow does alookupof that PVC and fails unlessobjectStore.legacyMinioPvcAcknowledged=true. The flag is new invalues.yaml, and the migration doc’s upgrade step sets it. Verified by substituting the lookup result: the render fails, passes with the acknowledgement, and passes on external S3. -
MINOR (fixed):
objectStore.enabled=falsewithoutexternalS3.endpointrendered ingest pointing at a Service that does not exist. It now fails, and CI asserts that.scripts/verify-object-copy.test.tsran in no gate, because roottestis onlyturbo run test. Roottestnow also runstest:scripts.- The env-file parser counted an unquoted trailing
# commentas part of the secret, which could false-pass the 16-character check. Unquoted comments are now stripped as Compose does; quoted values are left intact. - The migration doc understated disk space. It said 1× the old store; the real figure is about 2× (scratch source plus the new store).
- The doc’s PVC and Service names disagreed between steps. They now consistently use
<fullname>. SECURITY.mdsaid “no secrets are hardcoded” while compose ships a fixedGARAGE_RPC_SECRET. It now documents that exception and the loopback bind that makes it safe.
-
MINOR (accepted, not changed):
assertNoLegacyMinioEnvonly fires when the legacy names are set andS3_*is missing. Those processes would have failed anyway, so it cannot break BYO-S3 or Helm, and it readsprocess.envonly insideloadConfig(), as CLAUDE.md requires. Web has no equivalent; that is harmless.- Keeping the service key
minio, with aliasobjectstore, is mildly confusing but is the compatible choice; it is documented in the compose comment and the runbook. - The prod overlay still always starts the bundled store, even for BYO-S3 users, and seeds it with whatever
S3_*holds. This predates the change; the MinIO overlay did the same. - Renaming the runbook breaks the published site URL
docs/runbooks/minio-full. s3_regionis fixed atus-east-1ingarage.toml. This is documented.
Still unverified (needs Docker or a cluster)
- The
dxflrs/garage:v2.4.1container itself, including:- that the healthcheck exec sees
GARAGE_RPC_SECRET; - root-owned bind mounts under
./data/garageon Linux; - the
0.0.0.0:9000bind behind the published port.
- that the healthcheck exec sees
docker compose configon the edited files, to confirm the interpolation behaves as the source reading says.- The Docker-dependent half of
scripts/migrate-minio-to-garage.sh: thepgsty/miniosource on acp -ascratch copy, the container networking, the running-container refusal, and cleanup of root-owned scratch files. lookupagainst a live API server, and the Kubernetes migration pod recipe.