Skip to main content
General
Storage

Setup

Configure the shipped S3-compatible image storage integration.

Open MarkdownFull AI corpusFeedback

The storage implementation uses S3Client from the AWS SDK with a configurable endpoint and forcePathStyle: true. Cloudflare R2 is the documented default. Other S3-compatible providers can work, but you must verify their endpoint, path-style support and CORS behavior.

Environment variables

The repositories read these exact names. S3_REGION is optional and defaults to auto; the other values are required for the included image flow.

.env
S3_ACCESS_KEY_ID="your-access-key"
S3_SECRET_ACCESS_KEY="your-secret-key"
S3_ENDPOINT="https://your-s3-endpoint"
S3_REGION="auto"
NEXT_PUBLIC_IMAGES_BUCKET_NAME="your-images-bucket"

There is no S3_BUCKET environment variable in the shipped configuration. NEXT_PUBLIC_IMAGES_BUCKET_NAME supplies the only configured bucket name.

Cloudflare R2

1. Create a bucket

  1. Open the Cloudflare dashboard and select R2 Object Storage.
  2. Create a bucket for avatars and organization logos.
  3. Keep direct public bucket access disabled.

The application still exposes a public image redirect route. A private bucket stops direct anonymous requests to R2, but it does not add user or organization checks to /storage/[...path].

2. Create credentials

Create an R2 API token with object read and write permission scoped to this bucket. The current code signs PutObject and GetObject operations. It does not need account-wide administration permission.

Copy the access key ID and secret access key when the token is created.

3. Configure the endpoint

.env
S3_ACCESS_KEY_ID="your-r2-access-key-id"
S3_SECRET_ACCESS_KEY="your-r2-secret-access-key"
S3_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
S3_REGION="auto"
NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-app-images"

4. Configure CORS

Direct browser uploads are part of the shipped avatar and logo flow, so the bucket must allow PUT from every application origin you use.

R2 CORS policy
[
  {
    "AllowedOrigins": ["http://localhost:3000", "https://yourdomain.com"],
    "AllowedMethods": ["GET", "PUT"],
    "AllowedHeaders": ["Content-Type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Replace the example production origin. Do not use * for production origins unless your application intentionally accepts uploads from every website.

AWS S3

Create a private bucket and credentials that are limited to the required object operations. A minimal starting policy for the shipped flow is:

IAM policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::YOUR_BUCKET/*"
    }
  ]
}

Configure the same CORS origins and methods on the bucket, then set:

.env
S3_ACCESS_KEY_ID="your-aws-access-key-id"
S3_SECRET_ACCESS_KEY="your-aws-secret-access-key"
S3_ENDPOINT="https://s3.us-east-1.amazonaws.com"
S3_REGION="us-east-1"
NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-app-images"

Use your bucket region in both the endpoint and S3_REGION.

Other providers

DigitalOcean Spaces, MinIO and providers with an S3 gateway require the same five variables. Provider compatibility is not abstracted behind separate adapters. The single client in lib/storage/s3.ts always sets forcePathStyle: true, so change that option if your provider requires virtual-hosted bucket URLs.

For Supabase's S3 gateway, follow the Supabase setup guide and keep the public application route limitation in mind.

Storage configuration

The bucket is exposed through config/storage.config.ts:

config/storage.config.ts
import { env } from '@/lib/env';

export const storageConfig = {
  bucketNames: {
    images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? ''
  }
} satisfies StorageConfig;

The S3 client and signing functions are in lib/storage/s3.ts. There is no lib/storage/service.ts or storageService object.

Verify the complete flow

Generating a presigned URL happens locally and does not prove that the credentials or bucket are valid. Test the full shipped flow:

  1. Start the application with the storage variables set.
  2. Sign in and upload a user avatar or organization logo.
  3. Confirm that the direct PUT request returns a successful status.
  4. Confirm that the object key is saved to the user or organization record.
  5. Request /storage/{bucket}/{key} and confirm that it redirects and displays the image.

Keep {key} to one URL segment with the shipped route. Although the signer validates nested S3 keys, /storage/[...path] currently reads only the first segment after the bucket. Extend that route before introducing folder-style keys.

If the upload returns a signature error, verify the endpoint, region, clock and request content type. The current signer uses image/jpeg, while the included crop components send image/png. Some providers require those values to match.