Storage Configuration
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"
}
}
Environment variables (Docker/production):
Storage__Provider=MinIO
Minio__Endpoint=minio.yourdomain.com
Minio__AccessKey=your_access_key
Minio__SecretKey=your_secret_key
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, post images; optional exception for blogs/featured/* for SEO. |
temp | Referer-restricted | Temporary uploads; avoid hotlinking. |
support | Referer-restricted | Support ticket attachments; 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
backupsbucket private (no public policy); only the backend should access it with credentials.
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
Setup
R2 requires a few steps in the Cloudflare Dashboard:
- Create 5 buckets —
public,temp,files,support,backups - Create an R2 API Token — with Object Read & Write permissions
- Set up a Cloudflare Worker — routes all buckets under one custom domain (e.g.,
r2.yourdomain.com) - Configure bucket policies — via the Worker code (referer restrictions, public/private access)
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 5 buckets:
| Bucket | Purpose | Public? |
|---|---|---|
public | Avatars, post images/videos, processed media | Yes (referer-restricted) |
temp | Temporary uploads awaiting processing | Yes (referer-restricted) |
files | Blog file downloads, attachments | Yes (fully public) |
support | Support ticket attachments | Yes (referer-restricted) |
backups | Database and system backups | No (backend-only) |
Buckets are auto-created by the backend on first use. For R2, it's recommended to create them manually first in the Dashboard so you can configure public access settings.
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.
Switching Providers
To switch from MinIO to R2 (or back):
- Change
Storage__Providerin your.envorappsettings.json - Fill in the credentials for the target provider
- Update
VITE_STORAGE_PUBLIC_URLin your frontend/admin.envfiles - 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.
Security
Public Files
- Served via MinIO bucket policies or Cloudflare Worker with referer restrictions
- Only your domains (frontend, admin, API, docs) can load media
- Featured blog images are open to everyone (SEO, social sharing)
Private Files
backupsbucket has no public access- Backend accesses via S3 API with authenticated credentials
- Sensitive file downloads use presigned URLs (time-limited, signed by backend)
Presigned URLs
Both MinIO and R2 support S3 presigned URLs for secure, time-limited file access:
var url = await _minioService.GetPresignedUrlAsync("support", "ticket/doc.pdf", 3600);
// Returns a signed URL valid for 1 hour
Next Steps
- R2 Setup Guide — Full Cloudflare R2 setup with Worker and policies
- Environment Configuration — All environment variables
- Backups — Backup configuration including storage