···6969- For local/self-hosted setups, MinIO is the recommended option when you want uploads.
7070- The Kaneo API creates presigned upload URLs and the browser uploads images directly to the configured storage backend.
7171- If you do not configure object storage, leave the `S3_*` variables unset.
7272+- For a complete MinIO example and backend-specific setup notes, see the [storage backends guide](/core/installation/storage-backends).
72737374### Optional MinIO service
7475
···15151616This means Kaneo can work with multiple backends as long as they expose a compatible S3-style API.
17171818+If you want backend-specific setup examples, see the [storage backends guide](/core/installation/storage-backends).
1919+1820## Recommended local setup: MinIO
19212022For local or self-hosted deployments, MinIO is the recommended storage backend.
···42444345Create the bucket before using uploads.
44464747+## Important: internal Docker URLs are not enough in production
4848+4949+When Kaneo generates a presigned upload URL, the browser uploads directly to your storage backend.
5050+5151+That means `S3_ENDPOINT` must be reachable by the browser, not just by Docker containers.
5252+5353+For example:
5454+5555+- `http://minio:9000` works only inside Docker
5656+- `https://files.example.com` works from the browser
5757+5858+If your Kaneo deployment is public, do not leave `S3_ENDPOINT` set to `http://minio:9000`.
5959+6060+Use a public MinIO hostname instead, for example:
6161+6262+```env
6363+S3_ENDPOINT=https://files.cloud.kaneo.app
6464+S3_BUCKET=kaneo-uploads
6565+S3_ACCESS_KEY_ID=minioadmin
6666+S3_SECRET_ACCESS_KEY=minioadmin
6767+S3_REGION=us-east-1
6868+S3_FORCE_PATH_STYLE=true
6969+S3_PUBLIC_BASE_URL=https://files.cloud.kaneo.app/kaneo-uploads
7070+```
7171+7272+## Recommended public setup
7373+7474+The simplest production setup is:
7575+7676+- `cloud.kaneo.app` for Kaneo
7777+- `files.cloud.kaneo.app` for MinIO
7878+7979+Expose MinIO on its own public hostname through your reverse proxy.
8080+8181+Why this is needed:
8282+8383+- Kaneo signs uploads against `S3_ENDPOINT`
8484+- the browser uses that signed URL directly
8585+- Docker-internal names like `minio` are not resolvable from a user's browser
8686+8787+Using a dedicated subdomain is recommended over proxying MinIO under a path.
8888+4589## Other S3-compatible backends
46904791Kaneo is not tied to MinIO. Any deployment can point Kaneo at another S3-compatible backend by changing the `S3_*` environment variables.
···107151108152- confirm the bucket exists
109153- confirm the credentials can write to the bucket
110110-- confirm the storage endpoint is reachable from the Kaneo API
154154+- confirm the storage endpoint is reachable from the browser
155155+- confirm `S3_ENDPOINT` is a public URL, not an internal Docker hostname
156156+- confirm `S3_PUBLIC_BASE_URL` matches the public asset host if you are using one
111157- if using direct browser uploads, confirm CORS is configured correctly for your storage endpoint
+232
apps/docs/core/installation/storage-backends.mdx
···11+---
22+title: Storage backends
33+description: Configure image uploads with MinIO, AWS S3, or Cloudflare R2.
44+---
55+66+## Overview
77+88+Kaneo uses S3-compatible object storage for image uploads in:
99+1010+- task descriptions
1111+- task comments
1212+1313+The browser uploads directly to the configured storage backend using presigned URLs.
1414+1515+That means one rule matters for every backend:
1616+1717+- `S3_ENDPOINT` must be reachable by the browser
1818+1919+Do not use a Docker-internal hostname such as `http://minio:9000` for a public deployment unless the browser can actually reach it.
2020+2121+## Required Kaneo variables
2222+2323+```env
2424+S3_ENDPOINT=
2525+S3_BUCKET=
2626+S3_ACCESS_KEY_ID=
2727+S3_SECRET_ACCESS_KEY=
2828+S3_REGION=us-east-1
2929+S3_FORCE_PATH_STYLE=false
3030+S3_PUBLIC_BASE_URL=
3131+```
3232+3333+Notes:
3434+3535+- `S3_PUBLIC_BASE_URL` is recommended when your asset URL should be different from the raw S3 endpoint.
3636+- `S3_FORCE_PATH_STYLE=true` is usually needed for MinIO.
3737+- `S3_FORCE_PATH_STYLE=false` is usually correct for AWS S3 and R2.
3838+3939+## MinIO
4040+4141+MinIO is the recommended self-hosted option.
4242+4343+### Local Docker setup
4444+4545+For local development, this is fine:
4646+4747+```env
4848+MINIO_ROOT_USER=minioadmin
4949+MINIO_ROOT_PASSWORD=minioadmin
5050+5151+S3_ENDPOINT=http://minio:9000
5252+S3_BUCKET=kaneo-uploads
5353+S3_ACCESS_KEY_ID=minioadmin
5454+S3_SECRET_ACCESS_KEY=minioadmin
5555+S3_REGION=us-east-1
5656+S3_FORCE_PATH_STYLE=true
5757+S3_PUBLIC_BASE_URL=http://localhost:9000/kaneo-uploads
5858+```
5959+6060+This works when Kaneo and MinIO are on the same Docker network and your browser reaches MinIO through `localhost`.
6161+6262+### Public deployment
6363+6464+For a public deployment, expose MinIO on its own hostname through your reverse proxy.
6565+6666+Example:
6767+6868+- Kaneo: `https://cloud.kaneo.app`
6969+- MinIO: `https://files.cloud.kaneo.app`
7070+7171+This can be done with Caddy, Nginx, Traefik, or any other reverse proxy.
7272+7373+Then use:
7474+7575+```env
7676+S3_ENDPOINT=https://files.cloud.kaneo.app
7777+S3_BUCKET=kaneo-uploads
7878+S3_ACCESS_KEY_ID=minioadmin
7979+S3_SECRET_ACCESS_KEY=minioadmin
8080+S3_REGION=us-east-1
8181+S3_FORCE_PATH_STYLE=true
8282+S3_PUBLIC_BASE_URL=https://files.cloud.kaneo.app/kaneo-uploads
8383+```
8484+8585+You also need:
8686+8787+- a created bucket
8888+- MinIO CORS allowing your Kaneo origin
8989+9090+## AWS S3
9191+9292+AWS S3 is the simplest managed option.
9393+9494+Use a bucket and an IAM user with access to that bucket.
9595+9696+Example:
9797+9898+```env
9999+S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
100100+S3_BUCKET=kaneo-uploads
101101+S3_ACCESS_KEY_ID=AKIA...
102102+S3_SECRET_ACCESS_KEY=...
103103+S3_REGION=us-east-1
104104+S3_FORCE_PATH_STYLE=false
105105+S3_PUBLIC_BASE_URL=https://kaneo-uploads.s3.us-east-1.amazonaws.com
106106+```
107107+108108+For another AWS region, adjust both:
109109+110110+- `S3_ENDPOINT`
111111+- `S3_PUBLIC_BASE_URL`
112112+113113+Recommended S3 CORS policy:
114114+115115+```json
116116+[
117117+ {
118118+ "AllowedHeaders": ["*"],
119119+ "AllowedMethods": ["GET", "PUT", "HEAD"],
120120+ "AllowedOrigins": ["https://cloud.kaneo.app"],
121121+ "ExposeHeaders": ["ETag"]
122122+ }
123123+]
124124+```
125125+126126+## Cloudflare R2
127127+128128+R2 works well because it exposes an S3-compatible API.
129129+130130+Use your account endpoint, bucket, and R2 access keys.
131131+132132+Example:
133133+134134+```env
135135+S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
136136+S3_BUCKET=kaneo-uploads
137137+S3_ACCESS_KEY_ID=...
138138+S3_SECRET_ACCESS_KEY=...
139139+S3_REGION=auto
140140+S3_FORCE_PATH_STYLE=false
141141+S3_PUBLIC_BASE_URL=https://pub-<hash>.r2.dev
142142+```
143143+144144+Notes:
145145+146146+- `S3_REGION=auto` is typical for R2
147147+- `S3_PUBLIC_BASE_URL` should usually point to your public bucket domain or custom domain
148148+- if your bucket is not public, image rendering will not work from stored asset URLs
149149+150150+## One copy-paste self-hosted example
151151+152152+This example gives you Kaneo + MinIO in one Compose file.
153153+154154+```yml
155155+services:
156156+ postgres:
157157+ image: postgres:16-alpine
158158+ env_file:
159159+ - .env
160160+ volumes:
161161+ - postgres_data:/var/lib/postgresql/data
162162+ restart: unless-stopped
163163+ healthcheck:
164164+ test: ["CMD-SHELL", "pg_isready -U kaneo -d kaneo"]
165165+ interval: 10s
166166+ timeout: 5s
167167+ retries: 5
168168+169169+ api:
170170+ image: ghcr.io/usekaneo/api:latest
171171+ env_file:
172172+ - .env
173173+ depends_on:
174174+ postgres:
175175+ condition: service_healthy
176176+ restart: unless-stopped
177177+178178+ web:
179179+ image: ghcr.io/usekaneo/web:latest
180180+ env_file:
181181+ - .env
182182+ depends_on:
183183+ - api
184184+ restart: unless-stopped
185185+186186+ minio:
187187+ image: minio/minio:latest
188188+ command: server /data --console-address ":9001"
189189+ env_file:
190190+ - .env
191191+ volumes:
192192+ - minio_data:/data
193193+ restart: unless-stopped
194194+195195+volumes:
196196+ postgres_data:
197197+ minio_data:
198198+```
199199+200200+Matching `.env`:
201201+202202+```env
203203+KANEO_CLIENT_URL=https://cloud.kaneo.app
204204+KANEO_API_URL=https://cloud.kaneo.app/api
205205+206206+MINIO_ROOT_USER=minioadmin
207207+MINIO_ROOT_PASSWORD=change-me
208208+209209+S3_ENDPOINT=https://files.cloud.kaneo.app
210210+S3_BUCKET=kaneo-uploads
211211+S3_ACCESS_KEY_ID=minioadmin
212212+S3_SECRET_ACCESS_KEY=change-me
213213+S3_REGION=us-east-1
214214+S3_FORCE_PATH_STYLE=true
215215+S3_PUBLIC_BASE_URL=https://files.cloud.kaneo.app/kaneo-uploads
216216+```
217217+218218+Before testing uploads:
219219+220220+1. Create the `kaneo-uploads` bucket.
221221+2. Configure MinIO CORS for your Kaneo origin.
222222+3. Make sure `files.cloud.kaneo.app` resolves publicly.
223223+224224+## Troubleshooting
225225+226226+If uploads fail:
227227+228228+- check that the bucket exists
229229+- check that `S3_ENDPOINT` is public and browser-reachable
230230+- check that `S3_PUBLIC_BASE_URL` points to the correct public asset host
231231+- check CORS on your storage backend
232232+- check that the access key can `PutObject` and `GetObject`