MinIO — Object Storage Infrastructure
Audiences: developer, internal
MinIO is the S3-compatible object store that holds every binary the platform produces — patient documents, attachments, audio recordings, thumbnails — partitioned into one bucket per clinic so a tenant's binary data sits in an isolated namespace that the application code cannot accidentally cross.
Business Purpose
Healthcare platforms accumulate large binaries fast: scanned referrals, lab PDFs, intake photos, ultrasound clips, voice-consultation recordings, signed consent forms. Putting these in PostgreSQL is unworkable (cost, backup time, replication lag); putting them in cloud S3 is unacceptable for German on-prem clinics. MinIO runs on the clinic's own hardware, speaks S3, and gives Dudoxx HMS one bucket per tenant so a customer's data is physically scoped to a known namespace. The "one bucket per clinic" rule is also a compliance lever — a clinic can be exported, deleted, or migrated by manipulating one bucket, not by trawling a shared store with row-level filters.
Audiences
- Investor: S3-compatible on-prem storage, tenant-scoped buckets, dynamic provisioning during tenant onboarding — this is how Dudoxx HMS sells "your data, your hardware" without giving up the SDK ecosystem and DX of S3.
- Clinical buyer: Every uploaded document, photo, or recording is stored only in your clinic's bucket. Tenant bucket isolation is a structural promise, not a configuration toggle.
- Developer/partner: NestJS owns the only path to MinIO — never call MinIO directly from Next.js.
StorageService(ddx-api/src/platform/storage/storage.service.ts) is the canonical seam; bucket naming is{clinic-slug}-{type}(e.g.ddx-hamburg-clinic-documents); type is resolved bygetBucketTypeForAttachment()instorage/utils.ts:126. - Internal (ops/support): One MinIO container (
:6000API (MINIO_PORT),:6001console (MINIO_CONSOLE_PORT)), one named volume. System buckets created at first boot; tenant buckets created onPOST /super-admin/tenants. SSE-S3 encryption is enabled at the bucket level; versioning is enabled on system buckets.
Architecture
┌──────────────────────────── ddx-001-network ────────────────────────────┐
│ │
│ ddx-001-minio-1 (minio/minio:latest) │
│ ├─ API :6000 (host, MINIO_PORT) → container :9000 │
│ ├─ Console :6001 (host, MINIO_CONSOLE_PORT) → container :9001 │
│ └─ Volume ddx-001-minio-data-1 → /data │
│ │
│ ddx-001-minio-1-init (mc, one-shot) │
│ └─ creates system buckets: clinic-default | clinic-recordings | │
│ clinic-temp; service account ddx_api_user; policy `all-clinics-rw` │
│ │
│ ⤺ Tenant buckets created DYNAMICALLY by NestJS TenantService │
│ on POST /super-admin/tenants — never by ops, never by the seeder │
│ directly. │
│ │
└─────────────────────────────────────────────────────────────────────────┘
▲
│ S3Client (@aws-sdk/client-s3) from NestJS only
│ Browser NEVER calls MinIO directly
NestJS (ddx-api :6100)
├─ StorageService — generic CRUD + ACL + signed URLs
├─ AudioStorageService — recording-specific helpers
└─ StorageController — REST: upload, stream, ACL, FHIR sync
Architecture anchor: coding_context/ddx-hms-context.md for HMS topology. Bucket-naming and attachment-type → bucket-type rules are documented as a load-bearing reference in ddx-api/CLAUDE.md (§MinIO / File Storage).
Tech Stack & Choices
- Engine:
minio/minio:latest(production deployments pin to a digest). Image set atddx-docker-minio/docker-compose.yml:24. MinIO chosen for S3 compatibility (we can switch to AWS S3 in cloud deployments without code changes) and on-prem first-class support. - Client:
@aws-sdk/client-s3 ^3.985.0(the actual one used). The package list also includesminio ^8.0.6; the live client construction is the AWS SDK — seeddx-api/CLAUDE.md§MinIO. Construction lives inStorageServiceconstructor (storage.service.ts:92-131). - Compression: MinIO compresses
.pdf, .txt, .csv, .xml, .jsonandtext/*, application/json, application/xmlmime types on the server side (docker-compose.yml:42-44) — cheap latency cost for visible disk savings. - Encryption: SSE-S3 enabled at the system-bucket level by the init container (
mc encrypt set sse-s3 …atdocker-compose.yml:102-104). Tenant buckets created at runtime inherit the same policy via theall-clinics-rwpolicy + service account. - Service account:
ddx_api_user(created in the init step atdocker-compose.yml:108) has theall-clinics-rwpolicy attached, granting full CRUD on anyclinic-*bucket — so NestJS can create new tenant buckets without needing the root admin credentials. - Browser is never a client: The CLAUDE.md forbidden-patterns table makes this explicit. Frontend always goes Next.js → NestJS → MinIO, with signed URLs handed to the browser for direct read of large binaries.
Data Flow
Tenant bucket provisioning (one-time, on tenant creation):
- Operator (or seeder) calls
POST /super-admin/tenants. - NestJS
TenantService.createTenant()runs; among its steps it invokesprovisionMinioBucket(slug)which issuesS3Client.send(new CreateBucketCommand({ Bucket: 'clinic-{slug}' })). - The bucket is now visible in the MinIO console at
:6001and immediately usable by every per-tenant write.
Upload flow (binary write):
- Browser POSTs the file to Next.js (multipart).
- Next.js forwards to NestJS
StorageController.uploadFile()atddx-api/src/platform/storage/storage.controller.ts:86. - Controller calls
StorageService.uploadFile()(storage.service.ts:136); the service resolves the destination bucket viagetBucketTypeForAttachment()(storage/utils.ts:126) to pick{slug}-documents,{slug}-attachments,{slug}-reports,{slug}-recordings, etc. S3Client.send(new PutObjectCommand(...))writes to MinIO.- A Prisma row is written in
ddx_api_mainreferencing the bucket + key + attachment type. - If the file is image-like,
generateAndStoreThumbnail()runs (storage.service.ts:462) and stores the thumbnail under the same bucket.
Read flow (signed download URL):
- UI requests a download URL via
StorageController.getDownloadUrl()(storage.controller.ts:228). StorageService.getDownloadUrl()(storage.service.ts:156) generates a time-bounded presigned URL using@aws-sdk/s3-request-presigner.- Browser fetches the binary directly from MinIO using the signed URL (no NestJS proxying for large files — RAM and bandwidth saving).
ACL flow (per-attachment permissions):
StorageService.grantAcl()/revokeAcl()/listAcl()(storage.service.ts:266,:324,:282) maintain per-attachment, per-principal permission rows. ACL writes also append to the audit log (grantAclWithAuditat:416).
No SSE on this layer: storage operations do not directly fan out SSE events. Higher-level features (Document Explorer, Recordings) publish SSE after the storage write succeeds.
Implicated Code
≥3 file:line citations required. Counts: 11 below.
ddx-docker-minio/docker-compose.yml:23—minioservice (ddx-001-minio-1).ddx-docker-minio/docker-compose.yml:30— host port mapping:${MINIO_PORT:-15000}:9000API +${MINIO_CONSOLE_PORT:-15001}:9001console (live 6000/6001).ddx-docker-minio/docker-compose.yml:68—minio-initone-shot container that creates system buckets, theddx_api_userservice account, and theall-clinics-rwpolicy.ddx-docker-minio/docker-compose.yml:90— system bucket creation (clinic-default,clinic-recordings,clinic-temp).ddx-api/src/platform/storage/storage.service.ts:85—StorageServiceclass (lines 85–616), the canonical seam to MinIO.ddx-api/src/platform/storage/storage.service.ts:92— constructor (lines 92–131) constructss3Client, reads endpoint/region fromConfigService.ddx-api/src/platform/storage/storage.service.ts:136—uploadFile()write path.ddx-api/src/platform/storage/storage.service.ts:156—getDownloadUrl()signed-URL generator.ddx-api/src/platform/storage/storage.service.ts:462—generateAndStoreThumbnail()— image post-processing.ddx-api/src/platform/storage/storage.controller.ts:86—uploadFile()REST endpoint (lines 86–164).ddx-api/src/platform/storage/storage.controller.ts:228—getDownloadUrl()REST endpoint (lines 228–290).ddx-api/src/platform/storage/storage.module.ts:27—StorageModuleprovider declarations.ddx-api/src/platform/storage/audio-storage.service.ts—AudioStorageService(recording-specific bucket logic).
Business → Technical match. Business outcome: a doctor uploads an ECG PDF to a patient record; only authorized staff at that clinic can ever fetch it back, and the file never sits in a bucket that contains another clinic's data. Technical mechanism: storage.controller.ts:86 accepts the upload, storage.service.ts:136 writes it to clinic-{slug}-reports (resolved by getBucketTypeForAttachment() from attachmentType=ECG_REPORT), a Prisma row records the bucket + key, and download requests pass through storage.controller.ts:228 which generates a time-limited signed URL — the URL cannot point at another tenant's bucket because the slug is derived from the authenticated X-Clinic-ID.
Operational Notes
- Tenant buckets are NOT pre-created. The init container deliberately only seeds system buckets. Tenant buckets are dynamic — produced by
TenantService.provisionMinioBucket(slug)on tenant creation. Manuallymc mbis a smell; the right move isPOST /super-admin/tenants. - Browser never calls MinIO directly. The platform's Trusted Gateway pattern stops at NestJS; signed URLs are the only mechanism by which a browser ever talks to port 6000.
- Attachment-type → bucket-type mapping:
getBucketTypeForAttachment()atstorage/utils.ts:126is the single source of truth. DIAGNOSTIC_REPORT / ECG_REPORT →{slug}-reports; PRESCRIPTION / MEDICAL_RECORD / etc. →{slug}-documents; AVATAR / PROFILE_IMAGE →{slug}-attachments; audio →{slug}-recordings. - Bucket naming convention:
{clinic-slug}-{type}(e.g.ddx-hamburg-clinic-attachments); slug → bucket prefix is the only mapping you need to memorise. - Encryption: SSE-S3 at the bucket level; the underlying disk is not encrypted by MinIO. Use filesystem-level encryption on the host for at-rest protection.
- Console:
http://localhost:6001(root creds fromMINIO_ROOT_USER/MINIO_ROOT_PASSWORDin.env.docker; compose defaultsddx_admin/DdxSecure2024!are overridden — the live dev stack usesdudoxx_adminpercontext/ENVIRONMENT.md. Change in production). The console is for ops only; never use it to manage tenant data. - Healthcheck:
mc ready localevery 30s (docker-compose.yml:50-55). - Validation commands:
mc alias set ddx http://localhost:6000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"(values from.env.docker)mc ls ddx— list all bucketscurl http://localhost:6000/minio/health/live
Related Topics
- PostgreSQL — Multi-Schema Database Infrastructure — every MinIO object has a Prisma row in
ddx_api_main(attachments table); the row is the source of truth, the binary is the payload. - Qdrant — Vector Search Infrastructure — OCR'd document content stored in MinIO becomes embeddings stored in Qdrant; the join is the attachment ID.
- Canonical bucket-naming + attachment-type rules (not duplicated here):
ddx-api/CLAUDE.md§MinIO / File Storage.