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 on9001 - root credentials
feeblo/password - a bootstrap job (
mc) creates thefeeblo-media-publicbucket and sets its access policy todownload, 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.