Setup
Configure the shipped S3-compatible image storage integration.
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.
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
- Open the Cloudflare dashboard and select R2 Object Storage.
- Create a bucket for avatars and organization logos.
- 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
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.
[
{
"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:
{
"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:
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:
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:
- Start the application with the storage variables set.
- Sign in and upload a user avatar or organization logo.
- Confirm that the direct
PUTrequest returns a successful status. - Confirm that the object key is saved to the user or organization record.
- 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.