Media storage
Feeblo uploads profile images, organization logos, and editor media to an S3-compatible bucket. Configure it with MEDIA_UPLOAD_* and optional MEDIA_PUBLIC_BASE_URL.
Uploaded media (profile images, organization logos, and rich-text editor images/videos) is stored in an S3-compatible bucket. This page covers the configuration, the supported content types, how public URLs are built, and the MinIO and Cloudflare R2 specifics.
This page is based on packages/domain/src/services/s3-config.ts, packages/domain/src/services/s3.ts, and packages/domain/src/media/api-live.ts in .reference/.
How it connects
Feeblo uses @effect-aws/client-s3 to talk to any S3-compatible API. The connection config is read from environment by S3Config (packages/domain/src/services/s3-config.ts):
const region = yield * Config.string("MEDIA_UPLOAD_REGION");
const endpoint = yield * Config.string("MEDIA_UPLOAD_ENDPOINT");
const accessKeyId = yield * Config.string("MEDIA_UPLOAD_ACCESS_KEY_ID").pipe(Config.option);
const secretAccessKey = yield * Config.string("MEDIA_UPLOAD_SECRET_ACCESS_KEY").pipe(Config.option);
const publicBucketName = yield * Config.string("MEDIA_PUBLIC_BUCKET_NAME");
const publicBaseUrl = yield * Config.string("MEDIA_PUBLIC_BASE_URL").pipe(Config.option);Credentials are optional — when MEDIA_UPLOAD_ACCESS_KEY_ID / MEDIA_UPLOAD_SECRET_ACCESS_KEY are unset, Feeblo falls back to the ambient credentials the runtime provides (e.g. IAM instance profiles when running on ECS/EC2, or R2's binding behaviour). When they are set, they're passed explicitly to the S3 client.
Environment variables
Prop
Type
MEDIA_PUBLIC_BASE_URL is the only media var not in .env.example. Set it when the public URL of a file differs from the S3 API endpoint — most common with a CDN or a custom domain in front of the bucket (e.g. R2 public buckets, or CloudFront). See "How public URLs are built" below.
Where files are uploaded
packages/domain/src/services/s3.ts writes files under three prefixes via the S3UploadService:
| Method | Key prefix | Example |
|---|---|---|
uploadProfileImage | profile-images/{userId}/ | profile-images/usr_abc/1719000000-uuid.png |
uploadOrganizationLogo | organization-logos/{orgId}/ | organization-logos/ws_abc/1719000000-uuid.png |
uploadEditorMedia | editor-media/{userId}/{kind}/ | editor-media/usr_abc/image/1719000000-uuid.png |
Each filename is {timestamp}-{randomUUID}.{extension}. The upload service uses @effect-aws/s3's S3FileSystem layer so files are written through Effect's FileSystem interface straight to the bucket.
How public URLs are built
resolvePublicUrl in s3.ts decides the base of every returned URL:
const baseUrl =
config.publicBaseUrl._tag === "Some"
? config.publicBaseUrl.value.replace(/\/$/, "")
: `${config.endpoint.replace(/\/$/, "")}/${bucket}`;
const encodedKey = fileKey.split("/").map(encodeURIComponent).join("/");
return { bucket, key: fileKey, url: `${baseUrl}/${encodedKey}` };So with the defaults (no MEDIA_PUBLIC_BASE_URL) a file's URL is https://{endpoint}/{bucket}/{key}, with each path segment URL-encoded. The /media/upload endpoint returns { bucket, key, kind, url } to the caller.
The bucket must be publicly readable
Feeblo returns direct object URLs — it does not sign them. The bucket's objects have to be
retrievable via a plain GET with no credentials. In the dev compose this is done by the mc
bootstrap container running mc anonymous set download myminio/feeblo-media-public. Mirror that
policy on whatever store you use.
What gets accepted
The upload endpoint (packages/domain/src/media/api-live.ts) validates content type and size before writing anything:
- Images (
kind: "image"):image/gif,image/jpeg,image/png,image/webp. Max 10 MB (MAX_IMAGE_BYTES). - Videos (
kind: "video"):video/mp4,video/quicktime,video/webm. Max 100 MB (MAX_VIDEO_BYTES).
Files must be between 1 byte and the kind's limit. The endpoint is POST /media/upload with a multipart file field, behind the auth middleware; the OpenAPI title is "Upload Editor Media".
MinIO (local / self-hosted)
The dev compose uses MinIO with the S3 API on port 9002 and the console on 9001:
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"The bucket is created and made publicly downloadable by the one-shot minio/mc bootstrap container (see the Docker setup).
Cloudflare R2
R2 is S3-compatible but its region handling differs. Two things to get right:
- Set
MEDIA_UPLOAD_REGIONto"auto"— anything else makes the signing path assume an AWS region. - Provide credentials — R2 access keys go in
MEDIA_UPLOAD_ACCESS_KEY_ID/MEDIA_UPLOAD_SECRET_ACCESS_KEY; there's no ambient credential path for R2 from a standalone server.
You'll typically also want a public R2 bucket domain or a custom domain in front of the bucket, set as MEDIA_PUBLIC_BASE_URL:
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>"
MEDIA_PUBLIC_BUCKET_NAME="feeblo-media"
MEDIA_PUBLIC_BASE_URL="https://media.feeblo.example.com"Files are then served from https://media.feeblo.example.com/{key} instead of the R2 API host.
AWS S3
For plain S3, point MEDIA_UPLOAD_ENDPOINT at the regional endpoint (or omit-style usage through ambient credentials on EC2/ECS) and let the region match:
MEDIA_UPLOAD_REGION="us-east-1"
MEDIA_UPLOAD_ENDPOINT="https://s3.us-east-1.amazonaws.com"
MEDIA_PUBLIC_BUCKET_NAME="feeblo-media"If the server runs on EC2 with an instance profile that can read/write the bucket, you can leave MEDIA_UPLOAD_ACCESS_KEY_ID / MEDIA_UPLOAD_SECRET_ACCESS_KEY unset.