Storage

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.

CloudSelf-hostedPackage
✅ Supported✅ Supported@vitnode/s3

Quick start

src/vitnode.api.config.ts
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/s3
pnpm i @vitnode/s3
npm i @vitnode/s3

It 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

  1. In the S3 console, create a bucket and note its name and region.
  2. In IAM, create a user (or role) with s3:PutObject, s3:DeleteObject and s3:GetObject on arn:aws:s3:::your-bucket/*, then create an access key.
  3. Copy the Access key ID and Secret access key. The secret is shown once.
  1. In the Cloudflare dashboard, open R2 and create a bucket.
  2. Open Manage R2 API Tokens and create a token with Object Read & Write for that bucket.
  3. Copy the Access Key ID, the Secret Access Key and the S3 API endpoint - the last one looks like https://<account_id>.r2.cloudflarestorage.com and 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:

VariableRequiredWhat it is for
S3_BUCKETYesBucket name. Missing it fails the first upload with Missing S3 configuration
S3_ACCESS_KEY_IDYesAccess key ID
S3_SECRET_ACCESS_KEYYesSecret access key
S3_REGIONAWS onlyBucket region, e.g. us-east-1. Defaults to auto, which is what R2 wants
S3_ENDPOINTR2 onlyThe account's S3 API endpoint. Setting it also switches on path-style addressing
S3_PUBLIC_URLNoBase URL public file URLs are built from - a CDN or custom domain
.env
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.com

Register the adapter

For AWS S3, the four values above are all it needs:

src/vitnode.api.config.ts
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:

src/vitnode.api.config.ts
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:

ConditionResulting URL
publicUrl is set{publicUrl}/{key}
endpoint is set{endpoint}/{bucket}/{key}
Neitherhttps://{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.

Next