D82 — Local S3-compatible object storage: MinIO in the dev stack (RESOLVED)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
The S3-backed features in flight (documents direct-to-S3 signed uploads/downloads, payments async
exports, the audit WORM export) need a real S3 endpoint to exercise the non-fake driver locally.
MinIO joins the dev docker-compose stack as that endpoint (service minio, one-shot minio-init
bucket bootstrap, named volume minio_data, host ports 19000 API / 19001 console per the 1XXXX
scheme; the app reaches it as http://minio:9000 on stables_dev).
Evaluation (MinIO vs SeaweedFS S3 gateway vs comparable). Criteria: presigned PUT/GET, SSE knobs, Object-Lock/WORM, footprint/startup.
- MinIO — full presigned PUT/GET; SSE-C out of the box and SSE-S3/SSE-KMS via its KES/KMS hooks
(enough to emulate the SSE-KMS design locally); complete Object-Lock/WORM support
(compliance + governance modes,
mc mb --with-lock) — the audit WORM export can be exercised end-to-end locally; single static-binary container, sub-second startup, one named volume. - SeaweedFS (S3 gateway) — lighter on RAM at scale and fast, but its S3 gateway's Object-Lock/retention coverage is limited/partial, which silently under-tests the WORM path — disqualifying for the audit-export task; also more moving parts (master/volume/filer/S3 gateway) for a dev stack.
- LocalStack — emulates far more than S3 (heavier image, slower start) and its S3 fidelity adds nothing over MinIO here; Garage/Zenko — no (or immature) Object-Lock.
Decision: MinIO. Best S3-API coverage on exactly the knobs the follow-up tasks need (presign +
SSE + Object-Lock) at the smallest operational footprint. Notes: the image is pinned to a release
tag (MinIO publishes no floating major tag; bump deliberately); MinIO is AGPLv3 — fine here, it is
dev-stack tooling only, never linked into or distributed with the app, and production talks to
real AWS S3; recent community releases trimmed the admin web console to an object browser, which is
all local dev needs. The one-shot minio-init container (reusing the minio image's bundled mc,
alias preconfigured via MC_HOST_local) creates the stables-local bucket so make up needs no
manual step — future buckets (e.g. a --with-lock WORM bucket for the audit export) are added to
its entrypoint command.
App wiring. The stock env-driven s3 disk in config/filesystems.php is used unchanged;
.env.example now carries local defaults (AWS_ENDPOINT=http://minio:9000,
AWS_USE_PATH_STYLE_ENDPOINT=true, dev-only credentials, bucket stables-local) — production
overrides with real S3 values via 1Password → SSM. The missing Flysystem adapter
league/flysystem-aws-s3-v3 (^3.35) was added to composer (the s3 driver is inert without it).
ObjectStorageIntegrationTest proves write/read + presigned GET + presigned PUT against the real
driver and skips cleanly when the endpoint is absent or unreachable
(TestCase::skipUnlessObjectStorageReachable(), memoised per endpoint) — CI and devs without the
service stay green; unit-level code keeps Storage::fake().