Skip to main content

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().


← Decision log index