
# Attachment Fields & Storage

An [attachment field](/en/docs/attachment-fields) does not store a file. It stores a _reference_ to one, and the bytes live in a [bucket](/en/docs/buckets-overview) like any other upload. Understanding that indirection explains most of the behaviour on this page.

```yaml
tables:
  - id: 1
    name: contracts
    fields:
      - id: 1
        name: document
        type: single-attachment
        bucket: documents
        allowedFileTypes: [application/pdf]
        maxFileSize: 10485760
```

## Which Bucket

`bucket` names an entry in your `buckets[]` array. Omit it and the field writes to the implicit `default` bucket, whose visibility follows whether `app.auth` is configured — see [Buckets Overview](/en/docs/buckets-overview).

Two layers of limits now apply to the same upload, and both must pass:

| Limit               | On the field       | On the bucket                               |
| ------------------- | ------------------ | ------------------------------------------- |
| Accepted MIME types | `allowedFileTypes` | `allowedMimeTypes`                          |
| Size ceiling        | `maxFileSize`      | `maxFileSize`, then `STORAGE_MAX_FILE_SIZE` |

Declaring the tighter of the two on the field keeps the intent next to the column it constrains; the bucket limit remains the backstop for every field that points at it.

## What the Column Holds

The file reaches storage through the ordinary [upload endpoint](/en/docs/file-operations), which returns a key of the form `<uuid>-<filename>`. That key — not the filename, not a URL — is what the column holds.

With `storeMetadata: true` the column instead holds an object carrying the key plus the metadata captured at upload (dimensions, duration). Either shape resolves to the same stored object; the difference is only how much you can render without a second request.

## What the Record API Returns

Read a record and its attachment values come back decorated with a way to actually fetch the file. Which decoration you get depends on `STORAGE_DEFAULT_ACCESS`:

```json
{
  "id": 1,
  "fields": {
    "document": {
      "key": "9f3c1e2a-7b44-4d10-9e21-8a6f0c1d2e3b-contract.pdf",
      "signedUrl": "https://app.example.com/api/buckets/default/signed?path=…&op=download&expires=…&token=…",
      "signedUrlExpiresAt": "2026-04-05T11:00:00Z"
    }
  }
}
```

| Storage access mode             | Added to the value                 | Lifetime |
| ------------------------------- | ---------------------------------- | -------- |
| `private` (the default)         | `signedUrl` + `signedUrlExpiresAt` | 1 hour   |
| `STORAGE_DEFAULT_ACCESS=public` | `url` — no token, no expiry        | Forever  |

The two are mutually exclusive by design: in public mode `signedUrl` is deliberately absent, so a client can rely on `url` never containing a token and on `signedUrl` always meaning "this expires".

Because the URL is minted per read, a record fetched an hour ago carries a link that has since died. Re-read the record rather than caching the URL — the key is the durable handle, the URL is not.

:::callout
**Auto-signed URLs point at the `default` bucket.** The URL the record API attaches is built against `/api/buckets/default/signed` regardless of which bucket the field declares. A field with `bucket: documents` therefore returns a link that will not resolve. Until that is fixed, mint the URL yourself against the right bucket with [`POST /api/buckets/{bucket}/sign`](/en/docs/signed-urls-download) — or keep attachment fields on the `default` bucket, where the returned link is correct.
:::

## Images on an Attachment

An image attachment's signed URL takes [transform parameters](/en/docs/image-transforms) appended to it, so one stored original serves every size a page needs:

```text
<signedUrl>&width=150&height=150&fit=cover
```

The token covers the path and the expiry, not the transform query, so appending parameters never invalidates it.

## Deleting

Record deletion drives file deletion — see [Lifecycle & Quotas](/en/docs/file-lifecycle) for the full table. In short: a soft delete keeps the bytes, `?purge=true` removes them, `?permanent=true` does not, and replacing or clearing a `single-attachment` value deletes the file it displaced.

## Related Pages

- [Attachment Fields](/en/docs/attachment-fields) — the field properties themselves.
- [Buckets Overview](/en/docs/buckets-overview) — the bucket an attachment writes to.
- [File Operations](/en/docs/file-operations) — the endpoint that produces the key.
- [Download URLs](/en/docs/signed-urls-download) — minting a link yourself.
- [Lifecycle & Quotas](/en/docs/file-lifecycle) — when the bytes go away.
- [Form File Uploads](/en/docs/form-file-uploads) — filling an attachment from a form.
