Tanstack Start
Self-hosting

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:

MethodKey prefixExample
uploadProfileImageprofile-images/{userId}/profile-images/usr_abc/1719000000-uuid.png
uploadOrganizationLogoorganization-logos/{orgId}/organization-logos/ws_abc/1719000000-uuid.png
uploadEditorMediaeditor-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:

  1. Set MEDIA_UPLOAD_REGION to "auto" — anything else makes the signing path assume an AWS region.
  2. 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.

On this page