# Storage (/docs/infrastructure/storage)

---
title: Storage
description: Where the file bytes go. Local disk with no configuration at all, S3 or R2 or forty other backends behind one config line, and a typed Files handle on app.storage that upload collections write through.
kind: guide
package: questpie
---

Every stored file is two records. An
[upload collection](/docs/schema/collections/uploads) owns the row, its
filename, MIME type and visibility. Storage owns the bytes, under a generated
UUID key. That split is what lets the backend move from a folder on your laptop
to a bucket without a collection changing.

## The default

Leave `storage` out of your config and QUESTPIE builds a Files SDK `fs()`
adapter rooted at `./uploads`, resolved against the process working directory.
Nothing to provision and nothing to install, because `files-sdk` is already a
QUESTPIE dependency. The one key worth setting on day one is `basePath`.

```ts title="src/questpie/server/questpie.config.ts"
import { runtimeConfig } from "questpie/app";

export default runtimeConfig({
	app: { url: process.env.APP_URL! },
	db: { url: process.env.DATABASE_URL! },
	storage: { basePath: "/api" },
});
```

`basePath` is a string QUESTPIE prefixes onto the URLs it builds, and nothing
more. It mounts no routes, so it has to match the path your framework handler
already answers on. The starters mount at `/api` and set it to `/api`. Unset, it
is `/`, and a row's `url` reads `{app.url}/{collection}/files/{key}`.

<Callout
	type="warn"
	title="Local disk survives neither a redeploy nor a second instance"
>
	`fs()` writes to the machine. On a container host that disk is wiped on deploy
	and never shared between replicas, so uploads vanish or go missing at random.
</Callout>

## The adapters

There is no `questpie/adapters/s3`. The `adapter` slot takes any
[Files SDK](https://www.npmjs.com/package/files-sdk) adapter, and over forty
ship with the package, one per import subpath. Three cover almost everyone.

| Adapter | Import         | Needs                                                           | Pick it when                                                                   |
| ------- | -------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `fs()`  | built for you  | nothing                                                         | Development, or one machine whose disk you own.                                |
| `s3()`  | `files-sdk/s3` | A bucket, and the four `@aws-sdk` packages                      | AWS, or anything S3-compatible. MinIO, Spaces, Wasabi, Backblaze B2.           |
| `r2()`  | `files-sdk/r2` | A bucket, an account ID and two R2 keys, plus the same packages | Cloudflare. The `binding` form runs inside a Worker and needs no AWS packages. |

`files-sdk/gcs`, `files-sdk/azure`, `files-sdk/supabase` and the rest behave
identically. Pick the subpath, call it with its options, hand the result to
`adapter`. Every provider SDK is an optional peer, so you install only yours.

### What a swap does not change

QUESTPIE serves an upload collection's bytes itself, from
`GET {basePath}/:collection/files/:key`, whatever the adapter is. It streams
them out of the backend, so the row's `url`, the `serve` rule, byte-range
requests and the response headers read the same on S3 as on local disk. What
moves is where the bytes sit, and whether a client can skip your server.

## Configuring

`storage` is a union of two mutually exclusive shapes. `location` is the local
one, `adapter` is everything else, and setting both throws at boot, as does any
key outside these five.

| Key                   | Type                    | Default       | What it does                                                                              |
| --------------------- | ----------------------- | ------------- | ----------------------------------------------------------------------------------------- |
| `location`            | `string`                | `"./uploads"` | Local only. A relative path resolves against the working directory.                       |
| `adapter`             | Files SDK `Adapter`     | none          | Object storage. Never alongside `location`.                                               |
| `basePath`            | `string`                | `"/"`         | Prefix on the URLs QUESTPIE builds.                                                       |
| `signedUrlExpiration` | `number`                | `3600`        | Seconds a private file's token stays valid.                                               |
| `defaultVisibility`   | `"public" \| "private"` | `"public"`    | Inert in practice. `visibility` is `NOT NULL DEFAULT 'public'`, so set it per collection. |

### Swapping to S3

```ts title="src/questpie/server/questpie.config.ts"
import { runtimeConfig } from "questpie/app";
import { s3 } from "files-sdk/s3";

export default runtimeConfig({
	app: { url: process.env.APP_URL! },
	db: { url: process.env.DATABASE_URL! },
	storage: {
		basePath: "/api",
		adapter: s3({
			bucket: process.env.S3_BUCKET!,
			region: "eu-central-1", // omit it and s3() reads AWS_REGION
			// endpoint + forcePathStyle: true for MinIO or LocalStack
			// credentials omitted, so the AWS credential chain applies
			publicBaseUrl: "https://cdn.example.com", // optional, see below
		}),
	},
});
```

That is the entire swap. No collection moves, no route moves, no code holding
`app.storage` moves. R2 is the same call with `r2()` from `files-sdk/r2`, taking
`bucket` plus `accountId`, `accessKeyId` and `secretAccessKey`, each falling
back to `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`.

<Callout
	type="warn"
	title="Without `publicBaseUrl`, `url()` hands back an expiring link"
>
	`s3()` and `r2()` sign a GetObject URL when no `publicBaseUrl` is set, good
	for `defaultUrlExpiresIn` seconds, 3600 by default. Point it at a CDN origin
	for permanent links. This governs `app.storage.url()` only, never the row's
	`url`.
</Callout>

## From environment variables

Set `QUESTPIE_STORAGE_ENDPOINT` and leave `storage` out of the config entirely,
and QUESTPIE wires an S3-compatible adapter from the environment with
`forcePathStyle: true`. This is the path QUESTPIE Cloud takes at deploy time.

| Variable                      | Purpose                                               |
| ----------------------------- | ----------------------------------------------------- |
| `QUESTPIE_STORAGE_ENDPOINT`   | The endpoint. Its presence is what triggers all this. |
| `QUESTPIE_STORAGE_BUCKET`     | Bucket name.                                          |
| `QUESTPIE_STORAGE_ACCESS_KEY` | Access key ID.                                        |
| `QUESTPIE_STORAGE_SECRET_KEY` | Secret access key.                                    |
| `QUESTPIE_STORAGE_REGION`     | Region, `"auto"` when unset.                          |

The adapter is lazy, so a missing `@aws-sdk` peer (`client-s3`, `lib-storage`,
`s3-presigned-post`, `s3-request-presigner`) surfaces on the first storage call
and not at boot. An endpoint with no bucket or key warns and reverts to `fs()`.

<Callout type="warn" title="Any `storage` block at all switches this off">
	The environment path runs only when `storage` is absent from `runtimeConfig`.
	A block setting nothing but `basePath` still counts, so a config carrying the
	starter's `storage: { basePath: "/api" }` ignores every `QUESTPIE_STORAGE_*`
	variable. Configure the adapter explicitly instead.
</Callout>

## The handle

`app.storage`, and `storage` on every context QUESTPIE spreads into hooks,
routes and jobs, is a `Files` instance typed against your configured adapter.

```ts
await app.storage.upload("reports/q3.pdf", file.stream(), {
	contentType: "application/pdf",
}); // → { key, size, contentType, etag?, lastModified? }

const url = await app.storage.url("reports/q3.pdf"); // provider URL, see below
const stored = await app.storage.download("reports/q3.pdf");
const { items } = await app.storage.list({ prefix: "reports/" });
await app.storage.delete("reports/q3.pdf");
```

`head`, `exists`, `copy`, `move`, `search` and `listAll` are there too. `url()`
asks the provider, so on the default `fs()` adapter it hands back a path
QUESTPIE does not serve. `collections.media.upload(file, ctx)` generates the
UUID key, writes through this handle, then creates the row and deletes the
object again if that write throws. Reach for the handle outside a collection.

## Private bytes

A collection declared `.upload({ visibility: "private" })` gets a signed `url`
on read: an HMAC-SHA256 token over the key, the expiry and the collection name,
appended as `?token=` and verified by the serve route before a byte moves.
Signing needs `app.config.secret`, resolved from `QUESTPIE_SECRET` or
`BETTER_AUTH_SECRET`. Without one the hook leaves `url` as `undefined` rather
than emit an unsigned link.

`questpie/storage` exports the primitives directly:
`generateSignedUrlToken(key, secret, expirationSeconds, collection?)`,
`verifySignedUrlToken(token, secret, expectedCollection?)` and
`buildStorageFileUrl(baseUrl, basePath, collection, key, token?)`. A token
answers how bytes are served. Who may fetch them is the `serve` rule, in
[Access control](/docs/schema/access-control).

## Writing your own

There is no QUESTPIE storage contract to implement. `adapter` accepts a Files
SDK `Adapter`, so a backend nobody has covered is one object away.

```ts
import type { Adapter } from "questpie/storage";

// Required: name, raw, upload, download, head, exists, delete, copy, list,
// url, signedUploadUrl. Optional: deleteMany, move, resumableUpload.
const myAdapter = { name: "my-backend" /* … */ } as Adapter;

export default runtimeConfig({ storage: { adapter: myAdapter } });
```

`questpie/storage` re-exports `Adapter`, `Files`, `UploadResult`,
`SignedUpload`, `SignUploadOptions` and the `Storage*` config types from the one
`files-sdk` instance the framework holds, so typing an implementation needs no
direct import of the package.

## Related

- [Direct-to-storage uploads](/docs/infrastructure/storage/direct-uploads), presigned uploads that route the bytes past your server.
- [Upload collections](/docs/schema/collections/uploads), `.upload()`, its options and the deletion lifecycle.
- [Access control](/docs/schema/access-control), the `serve` rule that decides who may fetch bytes.
- [Configuration](/docs/ship/configuration), where `runtimeConfig` puts `storage`.
