QUESTPIE
Infrastructure

Storage

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.

View markdown

Every stored file is two records. An upload collection 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.

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}.

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.

The adapters

There is no questpie/adapters/s3. The adapter slot takes any Files SDK adapter, and over forty ship with the package, one per import subpath. Three cover almost everyone.

AdapterImportNeedsPick it when
fs()built for younothingDevelopment, or one machine whose disk you own.
s3()files-sdk/s3A bucket, and the four @aws-sdk packagesAWS, or anything S3-compatible. MinIO, Spaces, Wasabi, Backblaze B2.
r2()files-sdk/r2A bucket, an account ID and two R2 keys, plus the same packagesCloudflare. 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.

KeyTypeDefaultWhat it does
locationstring"./uploads"Local only. A relative path resolves against the working directory.
adapterFiles SDK AdapternoneObject storage. Never alongside location.
basePathstring"/"Prefix on the URLs QUESTPIE builds.
signedUrlExpirationnumber3600Seconds 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

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.

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.

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.

VariablePurpose
QUESTPIE_STORAGE_ENDPOINTThe endpoint. Its presence is what triggers all this.
QUESTPIE_STORAGE_BUCKETBucket name.
QUESTPIE_STORAGE_ACCESS_KEYAccess key ID.
QUESTPIE_STORAGE_SECRET_KEYSecret access key.
QUESTPIE_STORAGE_REGIONRegion, "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().

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.

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.

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.

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.

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.

On this page