MinIO is the default S3-compatible object store; one MinIO per customer-facing app
Context
Aspire apps need durable object storage for uploaded images, generated PDFs, design exports, AI-generated assets, etc. AWS S3 is the default-in-the-industry choice but creates a vendor dependency + per-GB egress costs that become meaningful at scale.
Detail
Decision
MinIO (self-hosted S3-compat) as the default object store. One MinIO instance per customer-facing app, not a single shared MinIO.
Rationale (storage choice)
- S3 wire-compatible โ every existing SDK works (
@aws-sdk/client-s3, boto3, etc.) - $0 marginal egress (the bandwidth is on Aspire's VPS, not metered like S3)
- Self-hosted on Coolify VPS โ same operations footprint as the rest of the stack
- No vendor lock-in โ output is plain bucket+key+bytes
Rationale (one instance per app, not one shared)
- Blast radius isolation โ alby-studio's uploads getting compromised doesn't expose zinga-pms's data
- Per-app credentials โ each app's MinIO has its own root + access keys; rotation is per-app
- Backups per app โ single restore target without untangling cross-app dependencies
- Public/private surface per app โ alby-studio needs public-read for storefront serving; zinga-pms likely doesn't
Architecture pattern (per app)
Coolify project: <app>
โโโ <app> app container (Next.js/etc)
โโโ postgres container (per app, see [[shared/decisions/use-postgresql-by-default]])
โโโ <app>-minio container (this decision)
โโโ public S3 API :9000 โ https://s3.<app-domain>
โโโ admin console :9001 โ https://s3-admin.<app-domain>
Live examples
- alby-studio (2026-05-04 shipped):
alby-studio-minioCoolify service (UUIDl2cycmi0w0or73bm7ipn46t6); bucketsalby-studio-uploads(anonymous public-read for storefront serving). S3 API athttps://s3.myalby.com.au. Admin athttps://s3-admin.myalby.com.au. - Future apps will follow this pattern.
Env-var contract (standard)
S3_ENDPOINT=https://s3.<app-domain>
S3_REGION=us-east-1 # MinIO ignores region but SDK requires it
S3_BUCKET=<app>-uploads
S3_ACCESS_KEY_ID=<per-app>
S3_SECRET_ACCESS_KEY=<per-app>
Constraints we accepted
- More services to operate (each Coolify project has +1 container vs a single shared MinIO)
- No cross-app object dedup (acceptable; objects are usually app-specific anyway)
- Disk usage scales linearly with apps ร per-app retention
Revisit trigger
- A single app accumulates >1 TB and MinIO's single-VPS scale becomes a bottleneck โ consider distributed MinIO mode or migrate that one app to S3
- Aspire ships >20 apps with MinIO each โ operational overhead of 20 MinIO instances becomes painful โ consider a shared multi-tenant MinIO (with strict bucket-policy isolation)
Actions
- [x] alby-studio MinIO live with all 4
S3_*env vars wired (2026-05-04) - [ ] Future apps with file uploads adopt the same per-app pattern
- [ ] Per-quarter: review MinIO disk usage on the Coolify host
Related
- use-postgresql-by-default โ companion pattern (one DB per app where possible)
- aspire-llm-gateway-only-egress โ exception to the "one instance per app" rule: AI is single gateway (cost amortization > isolation here)
- aspire-llm-gateway
๐ Relationships
graph LR
minio_storage_per_app["minio-storage-per-app"]:::self
minio_storage_per_app --> use_postgresql_by_default["use-postgresql-by-default"]
minio_storage_per_app --> aspire_llm_gateway_only_egress["aspire-llm-gateway-only-egress"]
minio_storage_per_app --> aspire_llm_gateway["aspire-llm-gateway"]
classDef self fill:#715EE3,color:#fff,stroke:#291F50;