Portal Community

Steps on This Page

  1. Start MinIO Locally
  2. Register the Service
  3. Store a File
  4. Retrieve a File
  5. Check Existence
  6. Verify Deduplication
  7. Enable Tenant Isolation
  8. Next Steps
Prerequisites
1

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:

FlagPurpose
-p 9000:9000S3 API port — this is the endpoint your .NET application connects to.
-p 9001:9001MinIO Console (web UI) port.
MINIO_ROOT_USERThe admin username (equivalent to AWS Access Key ID).
MINIO_ROOT_PASSWORDThe 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.
Create the bucket via the MinIO Console. Open http://localhost:9001 in your browser, log in with 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.
Development Credentials Only The credentials shown above (admin / admin123) are for local development only. Never use these in a shared or production environment. See Setup & Configuration for production credential management.
2

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:

SettingValue AboveWhy
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.
3

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"
    }
}
What to Store in Your Database Save the CID string (64 hex characters) in the column that represents the file relationship — e.g. 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 TypeContent Type String
PDFapplication/pdf
JPEG imageimage/jpeg
PNG imageimage/png
CSVtext/csv
Excel (.xlsx)application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
JSONapplication/json
ZIP archiveapplication/zip
Unknown / binaryapplication/octet-stream
4

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
}
Stream Disposal When you call 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.
5

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

SituationRecommendation
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.
Performance An S3 HEAD request typically completes in 5–20 ms on a LAN (MinIO) and 15–50 ms on AWS S3 (same region). It transfers no body bytes. Using ExistsAsync before GetStreamAsync adds only one round-trip, which is usually negligible compared to the stream transfer itself.
6

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);
Result cid1 = "3a7f2e9b1c4d8f06a2e5b7d9c1f3e8a04b6c2d7e9f1a3b5c8d0e2f4a6b8c0d2e4"
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.
7

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

PrependTenantIDExample 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.

Consistency Requirement Once you enable 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:

Configuration

Setup & Configuration

Full configuration reference, environment-specific appsettings, secrets management, and AWS S3 vs MinIO differences.

Storage

Use Cases & Scenarios

Invoice PDFs, profile photos, payroll exports, CAD files, audit logs — real-world patterns for integrating file storage into domain models.

Storage

Storing Files

Advanced storage patterns: chunked uploads, StoreOptions overrides, metadata, content type inference, and error handling strategies.

Reference

Configuration Reference

Every configuration key, its type, default value, and effect — with environment variable equivalents for container deployments.

Previous: Architecture Overview  ·  Next: Setup & Configuration