---
title: 'File storage'
group: 'integrations'
---

## What is an object store?

An **object store** is a bucket you own — on Amazon S3 or Cloudflare R2 — that nuzur can write files to on your behalf. You register it once at the **team** level, give it an identifier, and from then on you refer to it by name: a `File` field points at it, and `nuzur-cli deploy` hands its credentials to a deployed app.

Registering the store in nuzur rather than pasting keys into a config file is the point. The bucket stays yours, the credentials stay encrypted, and every project in the team can use the same store without anyone copying a secret around.

---

## Registering a store

In the nuzur web app, open **team settings → Object Stores** and click **New object store**.

Every store has:

- **Identifier** — a short name you'll recognize later (e.g. `media-prod`, `uploads-eu`).
- **Type** — `S3` for Amazon S3, `R2` for Cloudflare R2.
- **Status** — `active` or `disabled`. Disabling a store leaves it registered but takes it out of circulation, so you can retire a bucket without deleting its configuration.

The rest of the form depends on the type.

---

## S3 and R2 side by side

| Field | **Amazon S3** | **Cloudflare R2** |
|---|---|---|
| Bucket | bucket name | bucket name |
| Region | e.g. `us-east-1` | not asked — R2 is always `auto` |
| Account id | — | your Cloudflare account id |
| Access key | access key id | access key id |
| Secret | secret access key | secret access key |
| Endpoint | derived from the region | derived from the account id, **overridable** |

### Amazon S3

Create (or reuse) an IAM user or role with read and write access to the bucket, then paste its **access key id**, **secret access key**, **region**, and **bucket name**.

### Cloudflare R2

R2 speaks the S3 API, so nuzur talks to it the same way — it just addresses it differently. Two values are specific to R2:

**Your account id.** Open the Cloudflare dashboard and go to **R2**. The account id is shown on the R2 overview page, next to the S3 API endpoint. It's the same 32-character hex id that appears in your dashboard URL. nuzur derives the endpoint from it:

```
https://<account_id>.r2.cloudflarestorage.com
```

**An R2 API token.** Still in **R2**, open **Manage R2 API Tokens → Create API token**. Give it the **Object Read & Write** permission and scope it to the bucket you're registering. Cloudflare then shows you an **Access Key ID** and a **Secret Access Key** — those are the two values nuzur wants. Copy them immediately; Cloudflare shows the secret once.

> **Region:** R2 has no regions in the S3 sense. Its region is always `auto`, and nuzur fills that in for you — there is no region field on an R2 store.

#### The endpoint override

The derived endpoint is right for a standard R2 bucket. If your bucket lives in one of R2's **jurisdictional** locations, its host is different — the EU jurisdiction, for example, is served from:

```
https://<account_id>.eu.r2.cloudflarestorage.com
```

That's what the optional **endpoint** field is for. Leave it empty and nuzur derives the standard host; fill it in and nuzur uses exactly what you typed. It also works for any other S3-compatible service that speaks the same API.

---

## Where your keys are stored

The **access key and secret never touch your project schema**, and they are never written into a project version's JSON. When you save a store, nuzur pushes the pair into **AWS Secrets Manager**, encrypted with KMS and keyed by team. What lives in the project — and in anything you export — is the store's UUID and its non-secret settings.

That means:

- Sharing a project, exporting a version, or handing a model to a teammate never leaks a credential.
- Rotating a key is a change in one place: edit the store, and every field and every future deploy that references it picks up the new value.
- A deployed app gets the credentials at deploy time (see [below](#getting-credentials-to-a-deployed-app)), not by reading them back out of nuzur at runtime.

---

## Pointing a field at a store

Four field types hold files: **File**, **Image**, **Audio**, and **Video**. Each one has a **storage type** in its type config:

| Storage type | Where the bytes go |
|---|---|
| `binary` | nuzur's own storage — nothing to configure |
| `object_store` | your bucket, via a registered object store |

Choose `object_store` and the field asks for two more things:

- **Object store** — pick one of the team's registered stores.
- **Path prefix** — the key prefix every file for this field is written under, e.g. `avatars/` or `invoices/2026/`. It keeps one field's files from colliding with another's inside a shared bucket.

That's the whole field config: a store UUID and a prefix. The credentials aren't copied into the field — nuzur resolves them from the team on each request, which is why rotating a key needs no change to the model.

See [Field types](/docs/field-types) for the full list of types and their validations.

---

## Generated `/upload` and `/sign` endpoints

The field-level setting above is about files nuzur stores for you. **Go Code Gen** has a separate, optional **file storage** switch that gives *your generated backend* its own upload path. Turn it on and the generated app exposes two endpoints:

### `POST /upload`

A `multipart/form-data` request with the file in a part named `file`. It returns the stored object's URL and key:

```json
{ "url": "https://…/uploads/9f2c….png", "key": "uploads/9f2c….png" }
```

### `POST /sign`

Takes a `key` or a `url` and returns a time-limited signed URL for it. Request:

```json
{ "key": "uploads/9f2c….png", "expiry_seconds": 900 }
```

Response:

```json
{ "url": "https://…/uploads/9f2c….png?X-Amz-Signature=…" }
```

`expiry_seconds` is optional.

### How your model uses them

The endpoints are **generic** — they are not bound to any entity. File fields in the generated app stay plain string/URL columns, so the flow is two steps:

1. `POST /upload` the bytes, take the `url` from the response.
2. Put that `url` into a normal create or update payload, like any other string field.

> **If the app's `aws:` config block is missing, `/upload` and `/sign` return `503`.** The app still boots and every other endpoint keeps working — a missing storage config degrades those two routes instead of taking the service down.

---

## Getting credentials to a deployed app

`nuzur-cli deploy` wires this up for you. The recommended path is to reference a registered store:

```bash
nuzur-cli deploy --host 203.0.113.10 --project my-project \
  --storage-enabled --storage 8f1c2b7e-....
```

`--storage` takes the **object store UUID**. Its credentials are resolved **server-side from your team** at deploy time — the secret is never typed on your command line and never stored in a deploy-config file.

If you'd rather pass a bucket by hand — including an R2 or any other S3-compatible bucket that isn't registered in nuzur — use the manual flags:

| Flag | Description |
|---|---|
| `--storage-enabled` | Generate the file storage layer (`/upload` and `/sign`) |
| `--storage` | UUID of a registered object store; credentials resolved server-side from your team |
| `--s3-bucket` | Bucket name |
| `--s3-region` | Bucket region (use `auto` for R2) |
| `--s3-access-key` | Access key id |
| `--s3-secret` | Secret access key — **CLI-only**, never written to a deploy-config file |
| `--s3-endpoint` | Endpoint override for R2 or any S3-compatible service |

For a manual R2 bucket, `--s3-endpoint https://<account_id>.r2.cloudflarestorage.com` with `--s3-region auto` is the equivalent of a registered R2 store.

Whichever way the credentials are resolved, deploy writes them into the project's `prod.yaml` on the server with `chmod 600` — readable only by the account that runs the app. See [Deploying a project](/docs/deploy) for the rest of the deploy flags.

---

## Next steps

- [Field types](/docs/field-types)
- [Go Code Gen](/docs/extensions-go-code-gen)
- [Deploying a project](/docs/deploy)
- [Creating a new team](/docs/create-team)
