Skip to main content

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.

ProviderBest ForCost
MinIO (default)Local development, self-hosted serversFree (self-hosted)
Cloudflare R2Production, cloud deploymentFree 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.

BucketRecommended policyPurpose
publicReferer-restricted (only your app domains can load)Avatars, post images; optional exception for blogs/featured/* for SEO.
tempReferer-restrictedTemporary uploads; avoid hotlinking.
supportReferer-restrictedSupport ticket attachments; only your app can embed.
filesPublic read (or referer if you prefer)Blog attachments; often public so links work.
backupsNo public accessBackend-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_USER and MINIO_ROOT_PASSWORD in .env; never leave defaults in production.
  • Set Minio__PublicUrl to the URL users’ browsers use (e.g. https://cdn.yourdomain.com); do not expose internal minio:9000 to the internet.
  • Restrict MinIO ports (9000, 9001) to your app servers and admins; put MinIO behind a reverse proxy with TLS if exposed.
  • Keep backups bucket 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

ResourceFree Tier
Storage10 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:

  1. Create 5 bucketspublic, temp, files, support, backups
  2. Create an R2 API Token — with Object Read & Write permissions
  3. Set up a Cloudflare Worker — routes all buckets under one custom domain (e.g., r2.yourdomain.com)
  4. 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:

BucketPurposePublic?
publicAvatars, post images/videos, processed mediaYes (referer-restricted)
tempTemporary uploads awaiting processingYes (referer-restricted)
filesBlog file downloads, attachmentsYes (fully public)
supportSupport ticket attachmentsYes (referer-restricted)
backupsDatabase and system backupsNo (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):

  1. Change Storage__Provider in your .env or appsettings.json
  2. Fill in the credentials for the target provider
  3. Update VITE_STORAGE_PUBLIC_URL in your frontend/admin .env files
  4. Restart the services
Recommended Setup

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

  • backups bucket 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