Skip to content
Feeblo Docs
Esc
navigateopen⌘Jpreview
On this page

Media

Where Feeblo stores images. S3-compatible buckets, Cloudflare R2 settings, and MinIO for local development.

Feeblo stores profile images, organization logos, and editor uploads in a single S3-compatible bucket. Any provider that speaks the S3 API works: MinIO, Cloudflare R2, AWS S3, Backblaze B2. The server talks to it through the AWS SDK for JavaScript, configured by six environment variables.

The variables

Variable Required Notes
MEDIA_UPLOAD_REGION yes Set to auto when using Cloudflare R2
MEDIA_UPLOAD_ENDPOINT yes S3 API endpoint, for example http://127.0.0.1:9002 (dev)
MEDIA_UPLOAD_ACCESS_KEY_ID no Static credentials; unset both to use the default credential chain
MEDIA_UPLOAD_SECRET_ACCESS_KEY no Paired with the key above
MEDIA_PUBLIC_BUCKET_NAME yes Bucket that holds profile-images/, organization-logos/, and editor-media/
MEDIA_PUBLIC_BASE_URL no Public URL prefix; defaults to ENDPOINT/BUCKET

The upload service in packages/domain/src/services/s3-config.ts reads these directly. Each object sits under a fixed key prefix: profile-images/, organization-logos/, editor-media/, and a temporary tmp/editor-media/ for in-progress editor uploads. Public URLs assemble as MEDIA_PUBLIC_BASE_URL when set, otherwise as MEDIA_UPLOAD_ENDPOINT/MEDIA_PUBLIC_BUCKET_NAME, with a trailing slash stripped.

One compose default to watch: docker-compose.yml ships MEDIA_UPLOAD_ENDPOINT=http://127.0.0.1:9002, which only works when the browser and the server can both reach that host. Point it at a reachable endpoint in production, and set MEDIA_PUBLIC_BASE_URL to whatever origin serves the bucket (see R2 below).

Cloudflare R2

R2 is S3-compatible, so only a few values differ:

MEDIA_UPLOAD_REGION=auto
MEDIA_UPLOAD_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
MEDIA_UPLOAD_ACCESS_KEY_ID=<R2_ACCESS_KEY_ID>
MEDIA_UPLOAD_SECRET_ACCESS_KEY=<R2_SECRET_ACCESS_KEY>
MEDIA_PUBLIC_BUCKET_NAME=<BUCKET_NAME>
MEDIA_PUBLIC_BASE_URL=https://media.example.com

MEDIA_UPLOAD_REGION=auto is the one non-obvious requirement; .env.example calls it out inline. The S3 API endpoint itself refuses anonymous reads, so R2 buckets need a public route: a custom domain connected to the bucket (https://media.example.com) or an r2.dev URL. Put that in MEDIA_PUBLIC_BASE_URL, or images render as access denied.

Editor uploads and the temporary bucket

Editor uploads land in tmp/editor-media/ first. Saving a post or changelog copies the referenced objects to editor-media/, rewrites the stored URL, and deletes the temporary copy after the database transaction commits. Drafts that never get saved leave orphans behind.

The repo ships a lifecycle rule for that: docs/r2-temporary-editor-media-lifecycle.json expires tmp/editor-media/ objects after 7 days and aborts incomplete multipart uploads after 1 day. Apply it with:

npx wrangler r2 bucket lifecycle set <BUCKET_NAME> \
  --file docs/r2-temporary-editor-media-lifecycle.json

Any S3 provider with bucket lifecycle policies can mirror the same rule. The two knobs that matter are the prefix filter (tmp/editor-media/) and the expiration window.

MinIO for local development

The dev stack in docker/docker-compose.dev.yml runs MinIO plus mc, the MinIO client, side by side with Postgres:

  • MinIO API on port 9002, console on 9001
  • root credentials feeblo / password
  • a bootstrap job (mc) creates the feeblo-media-public bucket and sets its access policy to download, so objects read publicly without signing

The .env.example defaults line up with exactly this stack:

MEDIA_UPLOAD_REGION="us-east-1"
MEDIA_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
MEDIA_UPLOAD_ACCESS_KEY_ID="feeblo"
MEDIA_UPLOAD_SECRET_ACCESS_KEY="password"
MEDIA_PUBLIC_BUCKET_NAME="feeblo-media-public"

Start it with docker compose -f docker/docker-compose.dev.yml up -d and uploads work without further configuration. The dev stack has no lifecycle rule, so tmp/editor-media/ objects accumulate until you clean the bucket.

Uploads from the dashboard go through POST /media/upload (authenticated, images only, 10 MB per file), and the response carries the final public URL.

Next steps

Was this page helpful?