Skip to main content

Storage Configuration

Updated: 2026-09-14 — six buckets (includes private for 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.

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",
"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.

BucketRecommended policyPurpose
publicReferer-restricted (only your app domains can load)Avatars, covers, comments, chat thumbs; optional exception for blogs/featured/* / blogs/content/* for SEO
privateNo anonymous readPost/story processed media — API issues presigned URLs
tempReferer-restrictedTemporary uploads; avoid hotlinking
supportReferer-restrictedSupport paths; 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 and private buckets 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​

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

See the full Dev vs Prod env table on R2 Setup — Development vs Production.

Setup​

R2 requires a few steps in the Cloudflare Dashboard:

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

BucketPurposePublic?
publicAvatars, covers, comments, chat thumbs, blog imagesYes (referer-restricted / Worker rules)
privatePost + story processed mediaNo — presigned GET from API
tempTemporary uploads awaiting processingYes (referer-restricted)
filesBlog file downloads, attachmentsYes (fully public)
supportSupport / ticket-related pathsYes (referer-restricted)
backupsDatabase and system backupsNo (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):

  1. Change Storage__Provider in your .env or appsettings.json
  2. Fill in the credentials for the target provider (R2__* or Minio__*)
  3. Update VITE_STORAGE_PUBLIC_URL in your frontend/admin build envs and rebuild those images
  4. For R2: ensure the private bucket + Worker binding exist (R2 Setup)
  5. 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 — 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) and backups have 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​