--- title: Storage description: S3-compatible object storage, with presigned URLs for browser uploads. type: reference --- # Storage `@kreogen/storage` is a thin wrapper around the AWS SDK's S3 client. It is S3-compatible rather than S3-specific, so the same code path serves MinIO in `docker-compose` locally, Cloudflare R2 or AWS S3 in production, and anything else speaking the same protocol. There is no vendor SDK to swap out when the project moves hosts — only `S3_ENDPOINT` changes. The package imports `server-only`. Credentials must never reach the browser, so there is no client entry point and no `@kreogen/storage/client`. Browsers upload through a presigned URL instead — see below. ## Configuration | Variable | Required | Purpose | | ---------------------- | -------- | ----------------------------------------------- | | `S3_BUCKET` | no | Bucket name | | `S3_ACCESS_KEY_ID` | no | Access key | | `S3_SECRET_ACCESS_KEY` | no | Secret key | | `S3_ENDPOINT` | no | Base URL of a non-AWS gateway, e.g. MinIO or R2 | | `S3_REGION` | no | Defaults to `us-east-1` | | `S3_FORCE_PATH_STYLE` | no | `"true"` to address buckets as a path segment | | `S3_PUBLIC_URL` | no | Public base URL for reads, e.g. a CDN in front | Like every other integration here, storage degrades rather than fails. The `storage` export is `undefined` until the bucket, key and secret are all present, and the helpers below throw a named error rather than a stack trace from deep inside the SDK: ``` Storage is not configured. Set S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY. ``` `S3_FORCE_PATH_STYLE` defaults to `true` whenever `S3_ENDPOINT` is set, which is the behaviour MinIO and most self-hosted gateways need — they serve buckets as `endpoint/bucket/key` rather than as a subdomain. It is wrong on real S3, which is why it is derived rather than hardcoded. ## Locally MinIO ships in `docker-compose.yml` and needs no account: ```sh bun run docker:up ``` The console is at [localhost:9001](http://localhost:9001), the API at `localhost:9000`, and the root credentials are in `docker-compose.yml`. Create a bucket in the console, then point the app at it: ``` S3_ENDPOINT="http://localhost:9000" S3_BUCKET="kreogen" S3_ACCESS_KEY_ID="kreogen" S3_SECRET_ACCESS_KEY="kreogen-secret" ``` ## Server-side uploads `put` takes a key, a body and optional metadata. The key is the full path inside the bucket — the package does no namespacing of its own, which matters in a multi-tenant application: ```ts import { put } from '@kreogen/storage'; const { key, url } = await put( `${orgId}/avatars/${user.id}.png`, buffer, { contentType: 'image/png', cacheControl: 'public, max-age=31536000' } ); ``` Prefixing with the organization id is worth doing even though nothing enforces it. Object keys are the only structure a bucket has, and a flat namespace makes "delete everything belonging to this tenant" impossible to express. The rest of the surface: | Function | Returns | | -------------------------- | --------------------------------------------------- | | `put(key, body, options?)` | `{ key, url }` | | `del(key)` | `void` | | `head(key)` | Object metadata, or throws if absent | | `list(prefix?)` | Array of objects under the prefix | | `getPublicUrl(key)` | A URL, derived from `S3_PUBLIC_URL` or the endpoint | `getPublicUrl` is string construction, not a signature. It tells you where an object would be served from; whether that URL actually resolves depends on the bucket's own policy. For anything private, use `getDownloadUrl` instead. ## Browser uploads Routing an upload through a Next.js route handler means the whole file is buffered by your server before it reaches the bucket, which turns a large upload into memory pressure and a request timeout on the one process serving every other user. Presigned URLs avoid that entirely. The server authorises the upload; the bytes go straight from the browser to the bucket. ```ts title="apps/app/app/api/uploads/route.ts" import { requireOrganization } from '@kreogen/auth/session'; import { getUploadUrl } from '@kreogen/storage'; import { parseRequestBody } from '@kreogen/validation'; import { z } from 'zod'; const schema = z.object({ filename: z.string().max(200) }); export const POST = async (request: Request) => { const { orgId } = await requireOrganization(); const parsed = await parseRequestBody(request, schema); if (!parsed.ok) { return parsed.response; } const key = `${orgId}/uploads/${crypto.randomUUID()}-${parsed.data.filename}`; return Response.json({ key, url: await getUploadUrl(key) }); }; ``` The route decides the key. Accepting one from the client instead lets a caller write to `../another-org/`, and a presigned URL grants exactly the request it was signed for — including the path. From the browser: ```tsx const { key, url } = await fetch('/api/uploads', { method: 'POST', body: JSON.stringify({ filename: file.name }), }).then((response) => response.json()); await fetch(url, { method: 'PUT', body: file }); ``` `getDownloadUrl` is the mirror image, for reading a private object: ```ts const url = await getDownloadUrl(key, 60); ``` Both default to five minutes. Signed URLs are bearer credentials — anyone holding one can use it until it expires — so keep the window as short as the flow allows, and never put one in a page that gets cached. ## Cleaning up Nothing deletes objects for you. Deleting a row that references an object leaves the object behind, silently accruing cost, so pair the two: ```ts await del(page.imageKey); await db.page.delete({ where: { id: page.id } }); ``` Order matters less than doing both. If the delete fails after the row is gone, you have an orphan; if the row survives a failed object delete, the next attempt can retry. A lifecycle rule on the bucket is the usual backstop for whatever slips through. --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt)