AWS S3 / Cloudflare R2
Store VitNode uploads in an S3-compatible bucket with @vitnode/s3 - one adapter for AWS S3, Cloudflare R2 and anything that speaks the S3 API.
@vitnode/s3 puts your uploads in an object store instead of on the API's disk,
which is what you want the moment there is more than one server - or no server
you own at all. R2 is S3-compatible, so the same adapter covers both: you add an
endpoint and you are done.
| Cloud | Self-hosted | Package |
|---|---|---|
| ✅ Supported | ✅ Supported | @vitnode/s3 |
Quick start
import { S3StorageAdapter } from '@vitnode/s3'
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
storage: {
adapter: S3StorageAdapter({
bucket: process.env.S3_BUCKET,
region: process.env.S3_REGION,
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
}),
},
})Set it up
Install the adapter
bun i @vitnode/s3pnpm i @vitnode/s3npm i @vitnode/s3It is a runtime dependency of the app that serves your API, not a dev
dependency - the API imports it on every boot. @aws-sdk/client-s3 comes with
it, so there is nothing else to add.
Create the bucket and an access key
- In the S3 console, create a bucket and note its name and region.
- In IAM, create a user (or role) with
s3:PutObject,s3:DeleteObjectands3:GetObjectonarn:aws:s3:::your-bucket/*, then create an access key. - Copy the Access key ID and Secret access key. The secret is shown once.
- In the Cloudflare dashboard, open R2 and create a bucket.
- Open Manage R2 API Tokens and create a token with Object Read & Write for that bucket.
- Copy the Access Key ID, the Secret Access Key and the
S3 API endpoint - the last one looks like
https://<account_id>.r2.cloudflarestorage.comand is what makes R2 work through an S3 client.
Put the credentials in the environment
The adapter takes plain arguments, so the names are yours to choose. These are
the ones VitNode's own .env.example uses, and the ones the snippets here read:
| Variable | Required | What it is for |
|---|---|---|
S3_BUCKET | Yes | Bucket name. Missing it fails the first upload with Missing S3 configuration |
S3_ACCESS_KEY_ID | Yes | Access key ID |
S3_SECRET_ACCESS_KEY | Yes | Secret access key |
S3_REGION | AWS only | Bucket region, e.g. us-east-1. Defaults to auto, which is what R2 wants |
S3_ENDPOINT | R2 only | The account's S3 API endpoint. Setting it also switches on path-style addressing |
S3_PUBLIC_URL | No | Base URL public file URLs are built from - a CDN or custom domain |
S3_BUCKET=your-bucket
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your_access_key_id
S3_SECRET_ACCESS_KEY=your_secret_access_key
# Cloudflare R2 only
# S3_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
# Optional CDN or custom domain in front of the bucket
# S3_PUBLIC_URL=https://cdn.example.comRegister the adapter
For AWS S3, the four values above are all it needs:
import { S3StorageAdapter } from '@vitnode/s3'
import { buildApiConfig } from '@vitnode/core/vitnode.config'
export const vitNodeApiConfig = buildApiConfig({
storage: {
adapter: S3StorageAdapter({
bucket: process.env.S3_BUCKET,
region: process.env.S3_REGION,
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
}),
},
})For Cloudflare R2, keep the same adapter and add the endpoint - the region stays
at its auto default:
adapter: S3StorageAdapter({
bucket: process.env.S3_BUCKET,
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
endpoint: process.env.S3_ENDPOINT,
publicUrl: process.env.S3_PUBLIC_URL,
}),Upload a test file
Open AdminCP → Core → System → Integrations (/admin/core/system/integrations).
The Storage card now reads active; click Test storage and upload an image.
On success the dialog says the image is stored in your adapter, and the object
appears in the bucket under month_{month}_{year}/admin-storage-test/…. On
failure, check the API logs: the SDK's own error names the cause, and the two
usual suspects are a bucket in a different region than S3_REGION and a token
without write permission.
The file also shows up in AdminCP → Core → System → Files with its size and
pixel dimensions - proof that both the object and its core_files row were
written.
How the public URL is built
getUrl never asks the provider - it builds a string, and the first rule that
applies wins:
| Condition | Resulting URL |
|---|---|
publicUrl is set | {publicUrl}/{key} |
endpoint is set | {endpoint}/{bucket}/{key} |
| Neither | https://{bucket}.s3.{region}.amazonaws.com/{key} |
For R2 that middle row matters: the S3 API endpoint is not a public read
domain, so a bucket without a public r2.dev or custom domain gives URLs that
only your credentials can read. Enable public access on the bucket and put that
hostname in publicUrl.
Options
Prop
Type
Gotchas
Misconfiguration surfaces on the first upload, not at boot
The S3 client is created lazily, so a missing bucket or key throws Missing S3 configuration the first time somebody uploads - not when the server starts.
The Integrations card only knows an adapter is registered. Run Test
storage after any credential change.
A private bucket breaks previews and downloads
VitNode serves files by URL, and the built-in download routes fetch that URL
server-side before re-streaming it. If neither the bucket nor publicUrl is
publicly readable, thumbnails never load and both download routes turn the
refused fetch into a 404. Front the bucket with CloudFront, an R2 custom
domain, or make it public.
A provider error reaches the browser as a 500
The adapter lets the SDK's own error propagate, and it is not an
HTTPException - so a denied PutObject or a bucket in the wrong region
answers 500, with the useful sentence in the API log rather than in the
uploader. Catch it in your route if the person uploading deserves better than
"something went wrong".
Objects are overwritten, never appended
Keys contain a UUID, so a collision is not a practical concern - but a
PutObject to an existing key replaces it. If you enable versioning on the
bucket, remember that deleteFile removes the current version only; lifecycle
rules are how old versions actually go away.