File upload server with Fastify and S3
@upupjs/server ships a Fastify adapter: a plugin factory you register on
your instance. It wraps the same createUpupHandler every other adapter
wraps, so the config object is identical across frameworks — see
Server mode setup for what each option
does.
1. Install
pnpm add @upupjs/serverThe Fastify types are structural — the adapter declares the small slice of
request/reply it uses, so no particular Fastify typings version is
required for it to typecheck.
2. Share one config
Author the config once and import it wherever you register:
// lib/upup-config.ts
import type { UpupServerConfig } from '@upupjs/server'
export const upupConfig: UpupServerConfig = {
storage: {
type: 'aws',
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
// Required, server-only: stable, high-entropy, min 16 chars, shared
// across every instance. createUpupHandler throws without it.
uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
getUserId: async req => {
const session = await getSessionFromCookie(req)
return session?.userId ?? null
},
}3. Register the plugin
import Fastify from 'fastify'
import { createUpupPlugin } from '@upupjs/server/fastify'
import { upupConfig } from './lib/upup-config'
const fastify = Fastify()
await fastify.register(createUpupPlugin(upupConfig, { path: '/api/upup/*' }))
await fastify.listen({ port: 3000 })Point the uploader at the same prefix (without the wildcard):
<UpupUploader mode="server" serverUrl="/api/upup" provider="aws" />The mount path
createUpupPlugin is the one adapter that registers its own route, because
fastify.all(path, handler) structurally requires a path at registration
time. The optional second argument overrides it; the default is
/upup/*.
createUpupPlugin(upupConfig) // registers fastify.all('/upup/*', …)
createUpupPlugin(upupConfig, { path: '/api/upup/*' })Keep the trailing /*. The plugin registers exactly the string you pass,
and the handler dispatches on the path suffix beneath it — a bare
/api/upup matches only that one path, so /api/upup/presign would 404 at
the Fastify router before the handler ever sees it.
Body parsing
Nothing to wire. Fastify's built-in application/json content-type parser
already populates request.body, and the adapter re-serializes that object
into the Web Request it hands the core handler. Every route the browser
POSTs to (/presign, /multipart/init, /multipart/sign-part,
/multipart/complete, /multipart/abort, /files/:provider/transfer)
sends JSON, so the default parser covers all of them.
There is no multipart/form-data step to configure: on the presigned paths the file bytes go straight from the browser to storage and never traverse your Fastify process.
If you removed Fastify's JSON parser or replaced it with one that leaves
request.body empty, the adapter forwards no body and the POST routes fail
with 400 Invalid JSON body (code: BAD_REQUEST).
Behind a proxy or load balancer
The adapter builds the request URL from request.protocol,
request.hostname, and request.url. That URL is load-bearing beyond
routing: the OAuth redirect_uri for cloud drives is derived from the
request's origin. Under a TLS-terminating proxy, construct Fastify with
proxy trust enabled so those two fields reflect the public origin:
const fastify = Fastify({ trustProxy: true })With that set, Fastify derives protocol/hostname from
X-Forwarded-Proto / X-Forwarded-Host and the callback URL resolves to
the public origin you registered in each provider's console. Leave it off
and OAuth callbacks resolve to an internal address the provider rejects.
CORS
Same-origin deployments need no CORS config. For cross-origin ones, set it
on the upup config rather than reaching for @fastify/cors — the handler
attaches the headers itself, on every response including the preflight
OPTIONS (204):
export const upupConfig: UpupServerConfig = {
// ...storage, uploadTokenSecret
cors: {
allowedOrigins: ['https://app.example.com'],
allowedMethods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAgeSeconds: 600,
},
}Credentialed CORS is granted only on a concrete origin match. A config whose
allowedOrigins is just ['*'] gets public, non-credentialed CORS — the
server-mode drive client sends credentials: 'include', so cloud drives
will fail against a wildcard-only allowlist. Enumerate your real app
origins.
Verify the mount
curl http://localhost:3000/api/upup/healthA JSON body with "status": "ok" and checks.config / checks.storage
means the plugin is registered and the storage credentials resolve. The
route is unauthenticated by design and always answers 200 — read the
status field, not the HTTP code.
Related
- Server mode setup — every
createUpupHandleroption, hooks, limits, and observability. - Server auth & trust model — why
uploadTokenSecretis mandatory and what forged requests get. - Storage providers — S3, R2, MinIO, B2, Spaces, Wasabi configs.