---
title: Media
description: "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:

```bash
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:

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

:::warning[The lifecycle file replaces the whole configuration]
The command above sets the bucket's lifecycle from the file, so merge this rule with any
existing rules before applying it.
:::

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:

```bash
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

<CardGroup cols={2}>
  <Card title="Environment variables" href="/self-hosting/environment">
    Every variable Feeblo reads, grouped and marked required or optional.
  </Card>
  <Card title="Deploy with Docker" href="/self-hosting/docker">
    The compose walkthrough, first boot, and upgrade path.
  </Card>
  <Card title="Email" href="/self-hosting/email">
    SMTP transports, provider webhooks, outbox controls.
  </Card>
  <Card title="Database" href="/self-hosting/database">
    Postgres specifics and the migration lifecycle.
  </Card>
</CardGroup>