> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goosybear.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Your own object store

> What a site bucket is, how your code writes and reads bytes through env.BUCKET, and how env.IMAGES transforms them.

A site on Goosy can keep files as well as rows. When your build says it needs an object
store, a bucket is created for it automatically and bound into your code — you do not
provision anything, there are no access keys to hold, and there is no URL to sign.

<Note>
  **What is live today:** a declared store is **created and bound**. `env.BUCKET` is real in
  your Worker, `env.IMAGES` is real when you ask for it, and both are removed with the site —
  **an empty bucket is**. A bucket that still holds objects is kept, and the deletion receipt
  says so — see "It goes when the site goes" below.
</Note>

## What you get, and what we never touch

**One bucket belongs to one site.** It is created the first time you publish a build that
declares it, and it is bound into that site's Worker. Your code is the only thing that ever
writes to it or reads from it — nothing on our side opens it, lists it, or hands its contents
to anyone.

**It is private.** There is no public URL for an object in your bucket. A visitor sees a file
because your own route read it and returned it, which means the rules about who may see what
are yours and are written in your code.

**It goes when the site goes — once it is empty.** Deleting the site removes the bucket.
A bucket that **still holds objects** is not removed: R2 refuses to delete a non-empty
bucket, and we deliberately do not enumerate and erase your bytes on our own initiative. Such
a bucket is **retained and reported in the deletion receipt** rather than silently dropped, so
you can see exactly what is left.

**Emptying it is your code's job today.** If you want the bytes gone with the site, delete the
objects through `env.BUCKET` before you delete the site. A platform-side drain — one that
empties the bucket for you as part of deletion — is not built yet; when it ships, this page
says so and the sentence above becomes unconditional.

## Declaring it

Two lines in `pages.deploy.json`:

```jsonc theme={null}
{
  "entry": "_worker.js",
  "routes": [{ "pattern": "/api/*" }, { "pattern": "/media/*" }],
  "bindings": {
    "storage": { "name": "BUCKET" },
    "images": true
  }
}
```

| Field                   | What it does                                                                      |
| ----------------------- | --------------------------------------------------------------------------------- |
| `bindings.storage`      | Declares the store. Present ⇒ a bucket is created on the first publish and bound. |
| `bindings.storage.name` | What **your module** calls it — `env.<name>`. Defaults to `BUCKET`.               |
| `bindings.images`       | `true` attaches the image-transform binding as `env.IMAGES`.                      |

`name` is the only part you choose. **Which bucket it points at is ours** and is not
settable: it is derived from your site's id, it is what our own teardown finds the bucket by,
and it is checked on every publish before your Worker goes up. That is what makes it
impossible for one site's binding to be pointed at another site's files.

**Five names are already taken** and are refused by name if you ask for one: `DB` (your
site's database), `ASSETS` (your own static files), `IMAGES` (the transform binding), and
`GOOSY_API_KEY` / `GOOSY_API_BASE` (the key your site calls our API with, and the API origin
it calls). They share one namespace with your binding, so a collision would not fail — it
would quietly give your code the wrong handle.

## Writing and reading — a complete Astro route

An upload endpoint and the route that serves the file back:

<Warning>
  **`Astro.locals.runtime.env` does not work.** It was removed in Astro 6 and
  **throws** on Astro 7, so a route reaching for it answers 500 on every request —
  a real error, not a warning you can ignore. `import { env } from
      "cloudflare:workers"` is the supported access for **every** binding on this page,
  and it is what the Cloudflare adapter's own documentation shows.
</Warning>

```ts theme={null}
// src/pages/api/upload.ts
import { env } from "cloudflare:workers";
import type { APIRoute } from "astro";

export const POST: APIRoute = async ({ request }) => {
  const form = await request.formData();
  const file = form.get("photo");
  if (!(file instanceof File)) {
    return new Response("photo is required", { status: 400 });
  }

  const key = `guests/${crypto.randomUUID()}`;
  await env.BUCKET.put(key, file.stream(), {
    httpMetadata: { contentType: file.type },
  });

  return Response.json({ key });
};
```

```ts theme={null}
// src/pages/media/[...key].ts
import { env } from "cloudflare:workers";
import type { APIRoute } from "astro";

export const GET: APIRoute = async ({ params }) => {
  const key = params.key;
  if (!key) return new Response("not found", { status: 404 });

  const object = await env.BUCKET.get(key);
  if (!object) return new Response("not found", { status: 404 });

  return new Response(object.body, {
    headers: {
      "content-type":
        object.httpMetadata?.contentType ?? "application/octet-stream",
      "cache-control": "public, max-age=3600",
      etag: object.httpEtag,
    },
  });
};
```

<Warning>
  **Decide who may read a file before you return it.** There is no public URL, so the route
  above IS the access rule — and as written it lets anyone who knows a key fetch the object.
  Check your session, your own tables, or whatever your product's rule is, the same way you
  would before returning a database row.
</Warning>

## Resizing on the way out — `env.IMAGES`

With `"images": true` your Worker can transform bytes it already holds, without them being
reachable at a URL first:

```ts theme={null}
// src/pages/media/[...key].ts — a thumbnail variant
export const GET: APIRoute = async ({ params, url }) => {
  const object = await env.BUCKET.get(params.key ?? "");
  if (!object) return new Response("not found", { status: 404 });

  const width = Number(url.searchParams.get("w") ?? "0");
  if (!width) return new Response(object.body);

  const transformed = await env.IMAGES.input(object.body)
    .transform({ width })
    .output({ format: "image/webp" });

  return transformed.response();
};
```

Now `/media/guests/abc?w=320` serves a 320-pixel WebP of the same object, and the original
stays where you put it.

## Limits and cost

|                                 |                                                                                                                                           |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Object size**                 | Cloudflare R2's own limits apply; a single `put` handles a normal upload, and very large files use the multipart API on the same binding. |
| **Egress**                      | Free. Serving a file to a visitor costs nothing per byte.                                                                                 |
| **Storage and operations**      | Metered on what your code actually stores and does. An empty bucket costs nothing.                                                        |
| **Requests to your own routes** | Count against your site's normal function limits (`limits.cpuMs`, `limits.subRequests`), not against a separate storage budget.           |

## Related

* [The deploy contract](/api/deploy-contract) — every field of `pages.deploy.json`.
* [Site and project databases](/api/site-database) — `env.DB`, for rows rather than bytes.
* [Errors and refusals](/api/errors-and-refusals) — the shared error envelope.
