Skip to content

Blob Storage

Some WDCC projects need somewhere to put user-uploaded files - profile pictures, event photos, PDFs.

Our two options
- Cloudflare R2      - the default for all new projects
- AWS S3             - legacy, plus a few specific cases

Both speak the same API (R2 is S3-compatible), so the code is nearly identical either way. The difference is cost, and who can touch what.

Currently, we should default to using R2 since it is cheaper and easier to use. Some legacy projects are still using S3 which is why we still include it here as an option - Always default to R2 where possible.

Key Links

I want to... Go to
Use the bucket I've been given For project leads
Move a project off S3 Migrating
Serve files on a wdcc.co.nz domain Custom domains

Convention

Each WDCC project should get one bucket. This is due to R2 API token being scoped to a bucket and we want each secret key to only control one project so that we can hand it out to the leads in isolation.

Bucket naming
wdcc-<project>              - production   (e.g. wdcc-ayo)
wdcc-<project>-staging      - staging      (e.g. wdcc-ayo-staging)

Setting up a new R2 Bucket

1. Create a new Bucket

Login to Cloudflare > R2 Object Storage > Create Bucket > Name the bucket using the naming convention above > Location: Automatic and Storage Class: Standard

2. Custom domain

Skip this if the files are private (i.e. always served through the app via presigned URLs). Only do it for files meant to be publicly fetchable, like avatars in an <img> tag.

Most projects will require a custom domain.

Bucket > Settings > Custom Domains > Add

Domain convention
<project>-files.wdcc.co.nz              - production
<project>-files-dev.wdcc.co.nz          - dev

The DNS record should be created for you automatically, since we own the wdcc.co.nz domain.

Never serve production files from the *.r2.dev URL.

It's rate-limited and meant for poking at a bucket during development. Use a custom domain.

3. Generate a Scoped Token

R2 Object Storage > Manage API Tokens (bottom right) > Create Account API Token

Token settings

  1. Name: <project>-prod (or dev)
  2. Permissions: Object Read & Write
  3. Specify buckets: select only this project's bucket. Do not leave it on "all buckets"
  4. TTL: Forever

After creating the token, you will get an Access Key ID, Secret Access Key and the Endpoint. These secrets are shown once so make sure you save it (it should be stored in: TECH DOCUMENT)

4. Hand it over

For projects, they would need the following four values in their env:

What the lead needs
S3_ENDPOINT             <from step 3>
S3_REGION               auto
S3_ACCESS_KEY_ID        <from step 3>
S3_SECRET_ACCESS_KEY    <from step 3>

Using your bucket (project leads)

You've been given four values. They go in .env locally and in your host's secrets (fly secrets set ...) for deployments - never in the repo.

From code

R2 is S3-compatible, so use the normal AWS SDK. Nothing Cloudflare-specific to install.

lib/s3.ts
import { S3Client } from "@aws-sdk/client-s3";

export const s3 = new S3Client({
  region: "auto",
  endpoint: process.env.S3_ENDPOINT,
  forcePathStyle: true,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
  },
});

Two gotchas (according to claude)

  • forcePathStyle: true is required. Without it the SDK builds a URL R2 doesn't answer on
  • If uploads fail with a confusing 400 or 501, it's the SDK's default checksums. Add requestChecksumCalculation: "WHEN_REQUIRED" to the client config

Migrating S3 to R2

[TODO]


Notes:

Don't LIST the bucket in a request path.

Listing counts as a Class A op - the expensive tier, and the one with the smallest allowance. An app that lists the bucket to render a gallery on every page load will burn the shared monthly allowance for every other project without uploading a single file.

Store the object keys in your database next to whatever record owns them, and read from there.


AWS (legacy)

We keep the AWS account for the projects still on S3 and for the occasional one-off.

  • The account stays open even when empty - old client handover docs reference the account ID
  • There's a $5 budget alarm on it. If that fires and nobody deliberately started something, treat it as a possible leaked key and rotate immediately
  • New IAM users get one policy scoped to one bucket, and no console access. Access keys go in the Project Tracker with the date issued

A leaked AWS key is a much worse day than a leaked R2 token - AWS keys can start compute in regions nobody checks, R2 tokens can only touch files. That asymmetry is most of why we moved.