Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/content/docs/ai-development/claude-code.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Developing with Claude Code
description: A practical guide to building on fullstackhero with Claude Code - how it loads AGENTS.md, invokes skills, runs review workflows, and the build/test loop to follow.
lastUpdated: 2026-05-24
lastUpdated: 2026-09-25
sidebar:
label: Developing with Claude Code
order: 3
Expand Down Expand Up @@ -67,12 +67,12 @@ dotnet test src/FSH.Starter.slnx # unit + architecture + integration
```

<Callout type="warning" title="Integration tests need Docker">
`Integration.Tests` spins up real Postgres, Valkey, and MinIO via Testcontainers. If Docker isn't running
`Integration.Tests` spins up real Postgres, Valkey, and RustFS via Testcontainers. If Docker isn't running
you'll see `DockerUnavailableException` - that's environmental, not a code regression. Run the unit
projects (e.g. `dotnet test src/Tests/Catalog.Tests`) to validate logic without Docker.
</Callout>

To see it all running, the Aspire AppHost brings up the whole stack - Postgres, Valkey, MinIO, the migrator,
To see it all running, the Aspire AppHost brings up the whole stack - Postgres, Valkey, RustFS, the migrator,
the API, and both React apps - with one command:

```bash
Expand Down
20 changes: 10 additions & 10 deletions src/content/docs/building-blocks/storage.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Storage building block
lastUpdated: 2026-06-11
description: File storage abstraction - local filesystem or S3-compatible (MinIO / AWS S3) - with presigned URLs, tenant isolation, and optional quota metering.
lastUpdated: 2026-09-25
description: File storage abstraction - local filesystem or S3-compatible (AWS S3, RustFS, MinIO, R2...) - with presigned URLs, tenant isolation, and optional quota metering.
sidebar:
label: Storage
order: 10
Expand All @@ -12,10 +12,10 @@ seo:
keywords: '.net s3 storage, minio dotnet, presigned url .net, aws sdk dotnet storage, tenant scoped file paths'
---

The Storage block is the kit's blob-storage abstraction. One interface (`IStorageService`), two implementations - `LocalStorageService` (filesystem) and `S3StorageService` (AWS S3 + MinIO compatible) - with presigned URL support and an optional `QuotaMeteredStorageService` decorator that meters per-tenant usage against the Quota block.
The Storage block is the kit's blob-storage abstraction. One interface (`IStorageService`), two implementations - `LocalStorageService` (filesystem) and `S3StorageService` (AWS S3 and any S3-compatible store - the kit runs RustFS locally) - with presigned URL support and an optional `QuotaMeteredStorageService` decorator that meters per-tenant usage against the Quota block.

<Callout type="tip" title="Presigned URLs keep bytes out of your API">
Clients PUT directly to S3 / MinIO using a short-lived presigned URL minted by your API. Your API never proxies bytes. Same on the read side - `GenerateDownloadUrlAsync` mints a presigned GET URL the browser hits directly.
Clients PUT directly to S3 using a short-lived presigned URL minted by your API. Your API never proxies bytes. Same on the read side - `GenerateDownloadUrlAsync` mints a presigned GET URL the browser hits directly.
</Callout>

## What it ships
Expand Down Expand Up @@ -56,7 +56,7 @@ public interface IStorageService
### Implementations

- **`LocalStorageService`** - stores under `wwwroot/uploads/{owner-type}/{guid}_{sanitized-filename}` and validates extension + size against `FileTypeMetadata.GetRules(fileType)`. Presigning is a **dev-only token fallback**: `GenerateUploadUrlAsync` issues a `local://upload/{token}` URL backed by `LocalPresignTokenStore`; `GenerateDownloadUrlAsync` and `BuildPublicUrl` return server-relative `/uploads/...` paths (no signing needed).
- **`S3StorageService`** - uses `AWSSDK.S3` with a singleton `IAmazonS3` client. A custom `ServiceUrl` (MinIO etc.) switches to path-style addressing per `ForcePathStyle`; presigned PUT/GET URLs come from the SDK's request signer.
- **`S3StorageService`** - uses `AWSSDK.S3` with a singleton `IAmazonS3` client. A custom `ServiceUrl` (RustFS, MinIO, etc.) switches to path-style addressing per `ForcePathStyle`; presigned PUT/GET URLs come from the SDK's request signer.
- **`QuotaMeteredStorageService`** - decorator. `CheckAndRecordAsync(tenantId, QuotaResource.StorageBytes, bytes, ct)` on upload (throws 507 when exceeded, rolls back the charge if the write fails); refunds the object's size on `RemoveAsync`. Requests with no resolved tenant pass through unmetered.

### Request / response
Expand All @@ -69,7 +69,7 @@ public interface IStorageService

### Options

- **`S3StorageOptions`** - `Bucket`, `Region`, `Prefix`, `PublicRead` (default true), `PublicBaseUrl` (for non-expiring public URLs), `ServiceUrl` (custom endpoint for MinIO etc.), `AccessKey` / `SecretKey` (leave empty to use the AWS SDK credential chain), `ForcePathStyle` (only applies when `ServiceUrl` is set).
- **`S3StorageOptions`** - `Bucket`, `Region`, `Prefix`, `PublicRead` (default true), `PublicBaseUrl` (for non-expiring public URLs), `ServiceUrl` (custom endpoint for RustFS, MinIO, etc.), `AccessKey` / `SecretKey` (leave empty to use the AWS SDK credential chain), `ForcePathStyle` (only applies when `ServiceUrl` is set).

## How modules consume Storage

Expand Down Expand Up @@ -108,10 +108,10 @@ The generic `T` names the owning type - local storage uses it as the folder segm
"Provider": "s3", // or "local"
"S3": {
"Bucket": "fsh-uploads",
"ServiceUrl": "http://minio:9000", // omit for AWS S3 default
"ServiceUrl": "http://rustfs:9000", // omit for AWS S3 default
"Region": "us-east-1",
"ForcePathStyle": true, // required for MinIO
"AccessKey": "minioadmin",
"ForcePathStyle": true, // required for RustFS / MinIO
"AccessKey": "rustfsadmin",
"SecretKey": "set-via-secrets",
"PublicBaseUrl": "https://cdn.example.com" // for non-expiring public URLs
}
Expand Down Expand Up @@ -147,7 +147,7 @@ The Files module ships an `IFileScanner` hook. If you're using `IStorageService`

## Gotchas

- **MinIO needs `ForcePathStyle = true`.** Virtual-hosted-style addressing puts the bucket name in the subdomain, which MinIO can't service without DNS gymnastics. The option defaults to `false` and only takes effect when `ServiceUrl` is set - set it explicitly for MinIO and other self-hosted S3-compatible services.
- **Self-hosted S3 stores (RustFS, MinIO) need `ForcePathStyle = true`.** Virtual-hosted-style addressing puts the bucket name in the subdomain, which they can't service without DNS gymnastics. The option defaults to `false` and only takes effect when `ServiceUrl` is set - set it explicitly for RustFS, MinIO, and other self-hosted S3-compatible services.
- **Presigned URLs have a TTL.** Once it expires, the URL is dead. Use `BuildPublicUrl` for non-expiring public URLs (and a bucket policy that grants public-read on that prefix).
- **`QuotaMeteredStorageService` is scoped.** It depends on `IQuotaService` which is scoped per request. Don't resolve it from a singleton or a hosted service without creating a scope.
- **Local storage paths are not tenant-segmented.** Files land under `wwwroot/uploads/{owner-type}/`, and anything `BuildPublicUrl` points at is served statically without policy enforcement. Don't use `LocalStorageService` in multi-tenant production - it exists for dev and tests.
Expand Down
9 changes: 8 additions & 1 deletion src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Overview
lastUpdated: 2026-08-07
lastUpdated: 2026-09-25
description: Release notes and version history for fullstackhero.
sidebar:
order: 1
Expand All @@ -11,6 +11,13 @@ seo:

Notable changes to the kit, newest first.

## 2026-09-25

- **Object storage: MinIO replaced with RustFS for local dev, Docker Compose, and integration tests (breaking for docker-compose).** The `minio/minio` and `minio/mc` images were removed from Docker Hub and `quay.io/minio` now refuses anonymous pulls, so fresh clones could no longer bring up the stack. The kit now ships [RustFS](https://rustfs.com) (`rustfs/rustfs:1.0.0`, S3-compatible, Apache-2.0) on the same ports - **9000** (S3 API) and **9001** (web console) - with bucket bootstrap done by a pinned `amazon/aws-cli:2.37.3` init container. Nothing changes in application code: the API still talks to it through the `s3` storage provider with `ForcePathStyle`, and production can keep pointing at AWS S3 or any other S3-compatible store. See PR [#1390](https://github.com/fullstackhero/dotnet-starter-kit/pull/1390).
- **Aspire:** the `minio` / `minio-init` resources are now `rustfs` / `rustfs-init`, and the AppHost parameters are renamed `minio-user` / `minio-password` → `rustfs-user` / `rustfs-password` (default `rustfsadmin`). If you set the old parameters in user secrets or config, rename them. The data volume is now `{appPrefix}-rustfs-data`, so local uploads start empty; delete the old `*-minio-data` volume when you no longer need it.
- **Breaking (docker-compose):** in `deploy/docker/.env`, rename `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` → `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` (compose refuses to start without them). The services are now `rustfs` / `rustfs-init` and the volume `minio_data` → `rustfs_data`, so existing objects are **not** carried over: before upgrading, copy them out of the old MinIO bucket and into the new `fsh` bucket with `aws s3 sync` (against each `--endpoint-url`), or have users re-upload. `fsh new` now generates `RUSTFS_ACCESS_KEY` / `RUSTFS_SECRET_KEY` for new projects.
- **Integration tests:** `FshWebApplicationFactory` runs RustFS through the generic Testcontainers `ContainerBuilder` (the `Testcontainers.Minio` package is gone; the base `Testcontainers` package is added), waiting on `/health`. Its public members are renamed `Minio*` → `S3AccessKey` / `S3SecretKey` / `S3Bucket` / `S3ServiceUrl`; update any custom tests that used `MinioServiceUrl`.

## 2026-08-07

The transactional outbox was rebuilt so that any module can publish, every tenant's events actually get dispatched, and the kit is safe to scale past one API instance.
Expand Down
4 changes: 2 additions & 2 deletions src/content/docs/compare/fsh-vs-blazorplate.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: fullstackhero vs BlazorPlate
lastUpdated: 2026-05-19
lastUpdated: 2026-09-25
description: How fullstackhero compares to BlazorPlate - the difference between a free, open-source .NET 10 starter kit and a paid closed-source SaaS template.
sidebar:
label: vs BlazorPlate
Expand Down Expand Up @@ -32,7 +32,7 @@ BlazorPlate is a paid commercial multi-tenant SaaS starter for .NET, sold as a o
| Background jobs | Hangfire 1.8 | Hangfire |
| Observability | Serilog 4 + OpenTelemetry 1.15 (OTLP) | Serilog |
| Realtime | SignalR (Valkey backplane) + Server-Sent Events | SignalR |
| File storage | Tenant-scoped MinIO / S3 abstraction | File storage primitives |
| File storage | Tenant-scoped S3 abstraction (RustFS locally) | File storage primitives |
| Email | MailKit / SendGrid | Email service |
| Webhooks | Tenant-scoped subscriptions + HMAC-signed payloads | Not included |
| API browser | Scalar (OpenAPI 3.1) | Swagger |
Expand Down
4 changes: 2 additions & 2 deletions src/content/docs/cross-cutting-concerns/health-checks.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Health checks
lastUpdated: 2026-06-11
lastUpdated: 2026-09-25
description: Liveness + readiness endpoints - per-module database checks, Valkey, Hangfire, tenant migrations - for Kubernetes / Docker Compose / load balancer probes.
sidebar:
label: Health checks
Expand Down Expand Up @@ -28,7 +28,7 @@ Liveness asks "is the process alive?" - restart if it fails. Readiness asks "is
| `hangfire` | Kit's `HangfireHealthCheck` | When `EnableJobs = true` |
| `db:tenants-migrations` | Kit's `TenantMigrationsHealthCheck` (Multitenancy module) | Always (with the Multitenancy module) |

There is no storage/MinIO health check - blob storage failures surface through the Files module's own error handling, not readiness.
There is no storage/S3 health check - blob storage failures surface through the Files module's own error handling, not readiness.

## How to wire probes

Expand Down
32 changes: 16 additions & 16 deletions src/content/docs/deployment/aspire.mdx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
title: Local Orchestration with .NET Aspire
lastUpdated: 2026-06-11
description: What spins up when you run the AppHost, and the decisions behind the local topology - Postgres, Valkey, MinIO, the migrator, the API, and both React apps.
lastUpdated: 2026-09-25
description: What spins up when you run the AppHost, and the decisions behind the local topology - Postgres, Valkey, RustFS, the migrator, the API, and both React apps.
sidebar:
label: Local Orchestration (Aspire)
order: 2
pageType: concept
seo:
title: '.NET Aspire local orchestration - services, ports, and'
description: 'How fullstackhero uses .NET Aspire to run the full stack locally with one command: Postgres + pgAdmin, Redis, MinIO, a one-shot DB migrator, the API, and…'
keywords: '.NET Aspire AppHost, Aspire Postgres Redis MinIO, dotnet aspire orchestration, fullstackhero local development'
description: 'How fullstackhero uses .NET Aspire to run the full stack locally with one command: Postgres + pgAdmin, Redis, RustFS, a one-shot DB migrator, the API, and…'
keywords: '.NET Aspire AppHost, Aspire Postgres Redis RustFS, dotnet aspire orchestration, fullstackhero local development'
---

One command brings up the entire stack - databases, cache, object storage, the
Expand All @@ -31,8 +31,8 @@ documents what it starts and **why** it's wired the way it is.
| pgAdmin | *(sidecar)* | Web UI on **:5050**, auto-discovers every database on the server | Persistent |
| Valkey | `redis` | Distributed cache (HybridCache L2) + SignalR backplane + Data Protection key ring (**Valkey** - a Redis-compatible, BSD-licensed Redis fork; resource name stays `redis`) | Persistent (data volume) |
| RedisInsight | `redis-insight` | Key browser on **:5540**, pre-connected to the Valkey instance for inspecting cache keys in dev | Persistent |
| MinIO | `minio` | S3-compatible object storage, **:9000** (API) / **:9001** (console) | Persistent (data volume) |
| MinIO init | `minio-init` | One-shot: creates the `fsh-uploads` bucket + download policy, then exits | Run-once |
| RustFS | `rustfs` | S3-compatible object storage (`rustfs/rustfs:1.0.0`), **:9000** (S3 API) / **:9001** (web console) | Persistent (data volume) |
| RustFS init | `rustfs-init` | One-shot (`amazon/aws-cli`): creates the `fsh-uploads` bucket + a public-read `GetObject` policy, then exits | Run-once |
| DB migrator | `fsh-starter-db-migrator` | One-shot: applies migrations across the tenant catalog + every tenant's module DBs (`apply --seed`, so the root admin exists), then exits | Run-once |
| Demo seeder | `fsh-starter-demo-seeder` | One-shot, **dev-only**: runs `seed-demo` after the migrator to provision the `acme`/`globex` demo tenants + users, then exits | Run-once |
| API | `fsh-starter-api` | The ASP.NET Core API (`net10.0`) | Long-running |
Expand All @@ -44,15 +44,15 @@ The `fsh-starter-*` resource names - and the Docker volume names - are derived
from the AppHost's assembly name. A CLI-scaffolded app (say `Acme.Store`) gets
`acme-store-api`, `acme-store-admin`, and so on, so two FSH-based apps on one
machine never collide on container or volume names. The third-party infra
(`postgres`, `redis`, `minio`) and the `fsh-db` database keep stable names.
(`postgres`, `redis`, `rustfs`) and the `fsh-db` database keep stable names.
</Callout>

The Aspire dashboard opens automatically and shows every resource's state,
logs, traces, and endpoints. The API's Scalar UI is at `/scalar`.

<Callout type="note" title="Startup is ordered, not racy">
The API waits for Postgres and Valkey to be healthy **and** for the one-shot
jobs (`minio-init`, `fsh-starter-db-migrator`, `fsh-starter-demo-seeder`) to finish before it starts. So the API
jobs (`rustfs-init`, `fsh-starter-db-migrator`, `fsh-starter-demo-seeder`) to finish before it starts. So the API
never boots against an unmigrated database or a missing bucket - no retry loops,
no first-request 500s.
</Callout>
Expand All @@ -61,19 +61,19 @@ no first-request 500s.

### Persistent infra, run-once jobs

Postgres, Valkey, and MinIO use `ContainerLifetime.Persistent` with named data
Postgres, Valkey, and RustFS use `ContainerLifetime.Persistent` with named data
volumes. Your data, pgAdmin layout, and uploaded files survive `dotnet run`
restarts, so the inner loop stays fast. The migrator and MinIO bootstrap are
restarts, so the inner loop stays fast. The migrator and RustFS bootstrap are
**run-once** - they do their job and exit rather than linger as "unhealthy."

### MinIO is S3, so dev matches prod
### RustFS is S3, so dev matches prod

Object storage uses the same `Storage__Provider = "s3"` code path locally as in
production - only `ServiceUrl` and `ForcePathStyle` differ. The Files module's
presigned-URL upload flow (browser → storage directly, bytes never proxied
through the API) is exercised end-to-end in dev. MinIO is configured to accept
through the API) is exercised end-to-end in dev. RustFS is configured to accept
browser PUTs from the admin (`:5173`) and dashboard (`:5174`) origins via
`MINIO_API_CORS_ALLOW_ORIGIN`.
`RUSTFS_CORS_ALLOWED_ORIGINS`. The root credentials come from the `rustfs-user` / `rustfs-password` AppHost parameters (default `rustfsadmin`).

### The migrator is the production deploy step too

Expand Down Expand Up @@ -123,7 +123,7 @@ not http. The API uses `UseHttpsRedirection()`, so a call to the http endpoint
cross-origin redirects (a different scheme/port is cross-origin per the Fetch
spec). Hitting https directly preserves the bearer token on every request. The
Vite apps run un-proxied on fixed ports (5173 / 5174) so HMR works and the
origins line up with the MinIO CORS allow-list.
origins line up with the RustFS CORS allow-list.

### The React apps are optional

Expand All @@ -142,10 +142,10 @@ and the AppHost omits both React apps entirely.
| pgAdmin | 5050 |
| Valkey | 6379 (container) |
| RedisInsight | 5540 |
| MinIO | 9000 (API) · 9001 (console) |
| RustFS | 9000 (S3 API) · 9001 (console) |

## From local to cloud

The local topology maps almost one-to-one onto AWS: Postgres → RDS, Valkey →
ElastiCache, MinIO → S3, the API container → ECS Fargate, and the two React apps
ElastiCache, RustFS → S3, the API container → ECS Fargate, and the two React apps
→ S3 + CloudFront. See [Deploy to AWS with Terraform](/docs/deployment/aws-terraform/).
4 changes: 2 additions & 2 deletions src/content/docs/frontend/dashboard.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Tenant dashboard
lastUpdated: 2026-07-13
lastUpdated: 2026-09-25
description: The end-user-facing React + Vite app at clients/dashboard - catalog, chat, files, tickets, invoices, identity admin, plus real-time SignalR + SSE feeds.
sidebar:
label: Tenant dashboard
Expand Down Expand Up @@ -125,7 +125,7 @@ A product management surface over the Catalog module: paginated product list wit
`clients/dashboard/src/pages/files/` (with the orchestration in `src/hooks/use-file-upload.ts`) implements the kit's three-step presigned upload:

1. Browser calls `POST /api/v1/files/upload-url` with metadata (file name, size, content type, category) - the server mints a presigned PUT URL and reserves a `FileAsset` row.
2. Browser uploads bytes directly to MinIO / S3 via the presigned URL - no proxy through the API - with real upload progress.
2. Browser uploads bytes directly to S3 (RustFS locally) via the presigned URL - no proxy through the API - with real upload progress.
3. Browser calls `POST /api/v1/files/{id}/finalize` - the server verifies the object and flips the file from `PendingUpload` to `Available`.

<Screenshot src="/screenshots/dashboard/files-upload.png"
Expand Down
Loading