Getting Started
Steps on This Page
- Docker Desktop installed (for the local MinIO container), or AWS S3 / S3-compatible credentials for a remote bucket.
- A .NET 9 project (ASP.NET Core Web API or Worker Service recommended).
BizFirst.Ai.InfraHub.Storage.S3.Servicesproject referenced (or its NuGet package, if published to your internal feed).- Basic familiarity with dependency injection and
appsettings.json.
Start MinIO Locally
The fastest way to run InfraHub Storage locally is with MinIO in a Docker container. MinIO is an S3-compatible object store that runs on any machine with Docker installed. Run the following command in your terminal:
docker run -d \
--name minio \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=admin \
-e MINIO_ROOT_PASSWORD=admin123 \
minio/minio server /data --console-address ":9001"
What each flag does:
| Flag | Purpose |
|---|---|
-p 9000:9000 | S3 API port — this is the endpoint your .NET application connects to. |
-p 9001:9001 | MinIO Console (web UI) port. |
MINIO_ROOT_USER | The admin username (equivalent to AWS Access Key ID). |
MINIO_ROOT_PASSWORD | The admin password (equivalent to AWS Secret Access Key). Use a strong password in production. |
--console-address ":9001" | Binds the MinIO Console to port 9001. Without this, the console port is random. |
admin / admin123, navigate to Buckets → Create Bucket,
and name it bizfirst-files. Leave all other settings at their defaults.
You can also create the bucket programmatically using IS3BucketService.CreateBucketAsync
at application startup if you prefer code-first setup.
admin / admin123) are for local development only.
Never use these in a shared or production environment. See
Setup & Configuration for production credential management.
Register the Service
All InfraHub Storage services are wired into the .NET dependency injection container with a single
extension method call. Add the following to your Program.cs:
// Program.cs
builder.Services.AddObjectStorage(builder.Configuration);
This registers IObjectStorageProvider, ICidProvider,
IStorageCapacityGuard, and the underlying S3 client — all scoped correctly.
The configuration is read from the FileStorage section of
appsettings.json:
{
"FileStorage": {
"BucketName": "bizfirst-files",
"Endpoint": "http://localhost:9000",
"AccessKey": "admin",
"SecretKey": "admin123",
"Region": "us-east-1",
"ForcePathStyle": true,
"PrependTenantID": false,
"WriteStopThresholdPercent": 90.0
}
}
Key settings explained:
| Setting | Value Above | Why |
|---|---|---|
Endpoint |
http://localhost:9000 |
Points to your local MinIO container. For AWS S3, omit this (defaults to AWS regional endpoints). |
ForcePathStyle |
true |
Required for MinIO and other self-hosted S3 servers. AWS S3 uses virtual-hosted style; set to false for AWS. |
PrependTenantID |
false |
Disabled for this quickstart. Enable in multi-tenant deployments. See Step 7. |
WriteStopThresholdPercent |
90.0 |
Writes are blocked when MinIO disk utilization reaches 90%. Set to 0 to disable. See Capacity Guard. |
Region |
us-east-1 |
Required by the AWS SDK even when connecting to MinIO. Any valid region string works for MinIO. |
Store a File
Inject IObjectStorageProvider into any service or controller and call
StoreAsync with the file bytes and MIME content type.
The method returns a CID — a 64-character hex string that is the stable,
permanent address of this file. Store the CID in your database alongside whatever record
owns the file (invoice, user profile photo, report, etc.).
public class InvoiceService
{
private readonly IObjectStorageProvider _storage;
public InvoiceService(IObjectStorageProvider storage)
{
_storage = storage;
}
public async Task<string> SaveInvoicePdfAsync(byte[] pdfBytes, CancellationToken ct)
{
// StoreAsync computes a SHA-256 CID, checks if the object already exists,
// and uploads only if it is new. Returns the CID in both cases.
string cid = await _storage.StoreAsync(
pdfBytes,
contentType: "application/pdf",
ct: ct);
// Persist the CID in your database — this is the retrieval address.
// Example: invoice.FileCID = cid;
return cid; // e.g. "3a7f2e9b...b8c0d2e4"
}
}
Invoice.PdfCID NVARCHAR(64). You do not need to store the bucket name, endpoint,
or any S3 details. The CID is backend-agnostic; if you migrate from MinIO to AWS S3, the same
CID retrieves the same file from the new backend.
Common Content Types
| File Type | Content Type String |
|---|---|
application/pdf | |
| JPEG image | image/jpeg |
| PNG image | image/png |
| CSV | text/csv |
| Excel (.xlsx) | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| JSON | application/json |
| ZIP archive | application/zip |
| Unknown / binary | application/octet-stream |
Retrieve a File
Use GetStreamAsync to retrieve a file by its CID. The method returns a live
Stream directly from S3 — no intermediate buffering. This is important for large
files: you can pipe the stream directly to an HTTP response or write it to disk without
loading the entire file into memory.
public async Task<byte[]> LoadInvoicePdfAsync(string cid, CancellationToken ct)
{
Stream stream = await _storage.GetStreamAsync(cid, ct);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms, ct);
return ms.ToArray();
}
Streaming Directly to an HTTP Response
When serving files through an API endpoint, avoid buffering into a byte[]
and instead return the stream directly:
[HttpGet("invoices/{cid}/pdf")]
public async Task<IActionResult> DownloadInvoice(string cid, CancellationToken ct)
{
if (!await _storage.ExistsAsync(cid, ct))
return NotFound();
Stream stream = await _storage.GetStreamAsync(cid, ct);
return File(stream, "application/pdf", "invoice.pdf");
// ASP.NET Core disposes the stream after the response completes
}
GetStreamAsync and copy into a MemoryStream, dispose
the source stream explicitly (or use a using block) to release the underlying
S3 HTTP connection back to the connection pool.
When returning the stream from an IActionResult, ASP.NET Core handles disposal
automatically after the response is written.
Check Existence
ExistsAsync performs a lightweight S3 HEAD request — it confirms whether an object
exists without transferring any data. Use it before retrieval whenever the CID might refer to
a file that has been archived, deleted, or not yet written (e.g. when a CID comes from an
external source or an untrusted import).
public async Task<Stream?> TryGetFileAsync(string cid, CancellationToken ct)
{
if (!await _storage.ExistsAsync(cid, ct))
{
// File not found — log, return null, or throw a domain exception
return null;
}
return await _storage.GetStreamAsync(cid, ct);
}
When to Use ExistsAsync
| Situation | Recommendation |
|---|---|
| Serving a file in an API response and the CID comes from your own DB row | Optional — you trust your own data. Using it gives a cleaner 404 response. |
| CID comes from user input or an external integration | Recommended — validate existence before attempting a stream. |
| Implementing a "does this report exist yet?" UI indicator | Use ExistsAsync directly — much cheaper than GetStreamAsync. |
| Health check or pre-flight validation during startup | Use ExistsAsync to verify a known reference file as a connectivity probe. |
ExistsAsync before
GetStreamAsync adds only one round-trip, which is usually negligible compared
to the stream transfer itself.
Verify Deduplication
One of the most useful properties of InfraHub Storage is automatic deduplication. Store the same file bytes twice: you will receive back the same CID both times, and only one S3 object is created. This requires no application-level logic — the storage layer handles it transparently by checking existence before uploading.
byte[] content = File.ReadAllBytes("invoice.pdf");
// First call — object does not exist; bytes are uploaded to S3.
string cid1 = await _storage.StoreAsync(content, "application/pdf", ct: ct);
// Second call — same bytes, same CID computed; ExistsAsync returns true;
// no upload is performed.
string cid2 = await _storage.StoreAsync(content, "application/pdf", ct: ct);
// cid1 == cid2 — only one S3 object was written.
Debug.Assert(cid1 == cid2);
cid2 = "3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4"
cid1 == cid2 → true | S3 objects written: 1
Practical Implications
- Multiple database rows can reference the same CID without duplicating storage. E.g. if 500 users all download and re-upload the same regulatory form, only one copy is stored.
- Retry-safe — if an upload fails halfway and is retried, the second attempt will re-upload the same bytes (since the partial first upload was not committed), and the result is still correct.
- Import / migration safe — running an import job twice will not double your storage consumption.
-
Deduplication is scoped per tenant — with
PrependTenantID=true, Tenant 42 and Tenant 99 each get their own copy even if they store identical content. See Storage Key Strategy for the rationale.
Enable Tenant Isolation
For multi-tenant deployments, set PrependTenantID to true in
appsettings.json:
{
"FileStorage": {
"BucketName": "bizfirst-files",
"Endpoint": "http://localhost:9000",
"AccessKey": "admin",
"SecretKey": "admin123",
"Region": "us-east-1",
"ForcePathStyle": true,
"PrependTenantID": true, // ← changed
"WriteStopThresholdPercent": 90.0
}
}
No application code changes are required. When PrependTenantID=true,
S3ObjectStorageProvider automatically reads the current tenant ID from
IAiSessionContextAccessor.CurrentRequestSession.TenantID — the ambient
session context set by the BizFirst platform request pipeline — and prepends it to
every S3 key.
What Changes in the S3 Bucket
| PrependTenantID | Example S3 Key |
|---|---|
false |
3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4 |
true (TenantID = 42) |
tenant_42/3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4 |
true (TenantID = 99) |
tenant_99/3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4 |
How TenantID is Resolved
The TenantID is an integer resolved from the active request's AI session context:
// Inside S3ObjectStorageProvider — you never write this code yourself.
int tenantID = _sessionAccessor.CurrentRequestSession.TenantID;
string key = _options.PrependTenantID
? $"tenant_{tenantID}/{cid}"
: cid;
The session context is populated by the BizFirst authentication and session middleware, which
runs before any application code in the request pipeline. You do not need to pass a tenant ID
to StoreAsync or GetStreamAsync — the platform handles it.
PrependTenantID=true in a production environment, all future
writes will use the tenant-prefixed key. Existing objects stored without a prefix will no
longer be found by GetStreamAsync or ExistsAsync. If you are
migrating an existing deployment to multi-tenant mode, plan a key-migration job that copies
objects from their un-prefixed keys to their prefixed equivalents before switching the flag.
Next Steps
You now have a working InfraHub Storage integration. Explore the guides below to go deeper:
Setup & Configuration
Full configuration reference, environment-specific appsettings, secrets management, and AWS S3 vs MinIO differences.
Use Cases & Scenarios
Invoice PDFs, profile photos, payroll exports, CAD files, audit logs — real-world patterns for integrating file storage into domain models.
Storing Files
Advanced storage patterns: chunked uploads, StoreOptions overrides, metadata, content type inference, and error handling strategies.
Configuration Reference
Every configuration key, its type, default value, and effect — with environment variable equivalents for container deployments.