Storage Configuration
Updated: 2026-09-14 — six buckets (includes
privatefor post/story); Dev=MinIO vs Prod=R2 env table on R2 Setup.
Bellamy Book uses object storage for all media files (avatars, post images, videos, blog files, backups). You can choose between two providers.
When self-hosting: With the default setup you must configure MinIO: set MINIO_ROOT_USER, MINIO_ROOT_PASSWORD, and Minio__PublicUrl in .env so the app can store and serve media. See Self-Host with Pre-Built Images (MinIO section) and the MinIO section below.
| Provider | Best For | Cost |
|---|---|---|
| MinIO (default) | Local development, self-hosted servers | Free (self-hosted) |
| Cloudflare R2 | Production, cloud deployment | Free tier (10 GB), zero egress fees |
Switch between them with a single environment variable — no code changes needed.
MinIO (Default)
MinIO is a self-hosted, S3-compatible object storage server. It runs as a Docker container alongside your other services.
When to Use
- Local development
- Self-hosted deployments where you control the server
- You want $0 cost and full control over your data
Configuration
appsettings.json:
{
"Storage": {
"Provider": "MinIO"
},
"Minio": {
"Endpoint": "localhost:9000",
"AccessKey": "minioadmin",
"SecretKey": "minioadmin123",
"PublicUrl": "http://localhost:9000"
}
}
Environment variables (Docker/production):
Storage__Provider=MinIO
Minio__Endpoint=minio.yourdomain.com
Minio__AccessKey=your_access_key
Minio__SecretKey=your_secret_key
Minio__PublicUrl=https://minio.yourdomain.com
Frontend:
VITE_STORAGE_PUBLIC_URL=https://minio.yourdomain.com
Docker Compose
MinIO is included in the default Docker Compose setup. It runs on:
- Port 9000 — S3 API (used by the backend)
- Port 9001 — Web Console (for browsing files)
Bucket Policies (security)
MinIO uses S3 bucket policies to control who can read objects. Without policies, buckets may be fully open or inaccessible; applying the right policies improves security.
| Bucket | Recommended policy | Purpose |
|---|---|---|
public | Referer-restricted (only your app domains can load) | Avatars, covers, comments, chat thumbs; optional exception for blogs/featured/* / blogs/content/* for SEO |
private | No anonymous read | Post/story processed media — API issues presigned URLs |
temp | Referer-restricted | Temporary uploads; avoid hotlinking |
support | Referer-restricted | Support paths; only your app can embed |
files | Public read (or referer if you prefer) | Blog attachments; often public so links work |
backups | No public access | Backend-only; never expose to the web |
How policies are applied: The backend can apply JSON policies when it creates buckets (see your repo’s Infrastructure/Services/Common/Policies/ or equivalent). Policy files are typically named like public-policy.json, temp-policy.json, etc., and use the S3 policy format with s3:GetObject and optional aws:Referer conditions.
Referer restriction example (only your domains can request objects):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::public/*",
"Condition": {
"StringLike": { "aws:Referer": ["https://app.yourdomain.com/*", "https://admin.yourdomain.com/*"] }
}
}]
}
Replace app.yourdomain.com and admin.yourdomain.com with your frontend and admin hostnames. After changing policies, update the bucket policy in the MinIO Console (port 9001) or via the backend’s bucket-creation logic.
Best practices when using MinIO:
- Use strong
MINIO_ROOT_USERandMINIO_ROOT_PASSWORDin.env; never leave defaults in production. - Set
Minio__PublicUrlto the URL users’ browsers use (e.g.https://cdn.yourdomain.com); do not expose internalminio:9000to the internet. - Restrict MinIO ports (9000, 9001) to your app servers and admins; put MinIO behind a reverse proxy with TLS if exposed.
- Keep
backupsandprivatebuckets without anonymous public read; only the backend (and short-lived presigns) should access private objects.
Cloudflare R2
Cloudflare R2 is a cloud object storage service with S3-compatible API and zero egress fees.
When to Use
- Production deployments
- You want a global CDN with zero bandwidth costs
- You don't want to manage storage infrastructure
- You need high availability and redundancy
Pricing
| Resource | Free Tier |
|---|---|
| Storage | 10 GB / month |
| Writes (Class A) | 1 million / month |
| Reads (Class B) | 10 million / month |
| Egress (bandwidth) | Always free |
Configuration
appsettings.json:
{
"Storage": {
"Provider": "R2"
},
"R2": {
"AccountId": "your_cloudflare_account_id",
"AccessKey": "your_r2_access_key",
"SecretKey": "your_r2_secret_key",
"PublicUrl": "https://r2.yourdomain.com"
}
}
Environment variables (Docker/production):
Storage__Provider=R2
R2__AccountId=your_cloudflare_account_id
R2__AccessKey=your_r2_access_key_id
R2__SecretKey=your_r2_secret_access_key
R2__PublicUrl=https://r2.yourdomain.com
Frontend:
VITE_STORAGE_PUBLIC_URL=https://r2.yourdomain.com
See the full Dev vs Prod env table on R2 Setup — Development vs Production.
Setup
R2 requires a few steps in the Cloudflare Dashboard:
- Create 6 buckets —
public,private,temp,files,support,backups - Create an R2 API Token — with Object Read & Write permissions (include
private) - Set up a Cloudflare Worker — routes buckets under one custom domain (e.g.,
r2.yourdomain.com); bindPRIVATE_BUCKET - Access control — via the Worker (referer restrictions); post/story served by API presigned URLs
For the full step-by-step guide with Worker code and security configuration, see the R2 Setup Guide.
Buckets
Both MinIO and R2 use the same 6 buckets:
| Bucket | Purpose | Public? |
|---|---|---|
public | Avatars, covers, comments, chat thumbs, blog images | Yes (referer-restricted / Worker rules) |
private | Post + story processed media | No — presigned GET from API |
temp | Temporary uploads awaiting processing | Yes (referer-restricted) |
files | Blog file downloads, attachments | Yes (fully public) |
support | Support / ticket-related paths | Yes (referer-restricted) |
backups | Database and system backups | No (backend-only) |
Buckets are auto-created by the backend on first use. For R2, create them manually first in the Dashboard so you can bind the Worker and disable r2.dev.
How It Works
┌─────────────────────────────────────────────┐
│ IMinioService │
│ (Application Interface) │
├──────────────────┬──────────────────────────┤
│ MinioService │ R2StorageService │
│ (MinIO client) │ (R2 via S3-compat API) │
├──────────────────┴──────────────────────────┤
│ Storage:Provider config │
│ "MinIO" or "R2" → picks one │
└─────────────────────────────────────────────┘
Both implementations use the same S3-compatible protocol. The Storage:Provider configuration value determines which one is instantiated at startup. All backend services (API, MediaProcessingWorker, ElasticsearchSyncWorker, BlogAutoGenerationWorker) read this setting.
Post/story read path: MediaProcessingWorker writes objects into private/…. Feed/story APIs rewrite those URLs to short-lived presigned links before returning DTOs to web/mobile clients.
Switching Providers
To switch from MinIO to R2 (or back):
- Change
Storage__Providerin your.envorappsettings.json - Fill in the credentials for the target provider (
R2__*orMinio__*) - Update
VITE_STORAGE_PUBLIC_URLin your frontend/admin build envs and rebuild those images - For R2: ensure the
privatebucket + Worker binding exist (R2 Setup) - Restart the services
Use MinIO for local development (runs in Docker, free, offline) and R2 for production (CDN, reliability, zero egress). Switch with one env var change — see Dev vs Prod env diff.
Security
Public Files
- Served via MinIO bucket policies or Cloudflare Worker with referer restrictions
- Only your domains (frontend, admin, API, docs) can load most media
- Featured/content blog images are open to everyone (SEO, social sharing)
- Avatar/cover paths may allow no-Referer loads (native mobile) when using the current Worker script
Private Files
private(post/story) andbackupshave no anonymous CDN read- Backend accesses via S3 API with authenticated credentials
- Feed/story clients receive presigned URLs (time-limited; R2 signed with AWS SDK SigV4, region
auto)
Presigned URLs
Both MinIO and R2 support S3 presigned URLs for secure, time-limited file access:
var url = await _minioService.GetPresignedUrlAsync("private", "posts/md/{id}", 3600);
// Returns a signed URL valid for 1 hour (R2 host: <accountId>.r2.cloudflarestorage.com)
Next Steps
- R2 Setup Guide — Full Cloudflare R2 setup with Worker and Dev vs Prod env
- Environment Configuration — All environment variables
- Backups — Backup configuration including storage
- Media — Upload / processing overview