I recently migrated my .NET side project’s object storage from Cloudflare R2 to Garage (self-hosted S3-compatible storage). The main driver was cost predictability – I wanted fixed infrastructure costs instead of variable charges.
Here’s how I configured it.
Garage Bucket-to-Website
Garage doesn’t currently support anonymous S3 access, so I used the bucket-to-website feature for public file access. This works by:
1. Setting the bucket name to match my public domain (e.g., cdn.myapp.com)
2. Enabling website mode via the Garage web UI
3. Pointing my DNS to Garage’s web endpoint
4. Uploading files via S3 API
5. Accessing files directly via public HTTP URLs on my domain
This provides persistent public access without pre-signed URLs – perfect for truly public assets like logos.
Environment Variables
Before (Cloudflare R2):
CLOUDFLARE_R2_ENDPOINT
CLOUDFLARE_R2_ACCESS_KEY_ID
CLOUDFLARE_R2_SECRET_ACCESS_KEY
CLOUDFLARE_R2_BUCKET_NAME
After (Garage):
GARAGE_S3_ENDPOINT=https://cdn.myapp.com # Public web endpoint (matches bucket name)
GARAGE_S3_API_ENDPOINT=https://s3-api.myapp.com # S3 API endpoint (optional)
GARAGE_S3_ACCESS_KEY_ID=your_key_id
GARAGE_S3_SECRET_ACCESS_KEY=your_secret
GARAGE_S3_BUCKET_NAME=cdn.myapp.com # Must match public domain for website mode
Important: The bucket name must match your public domain for bucket-to-website to work.
S3 Client Configuration
The critical configuration for Garage compatibility:
var config = new AmazonS3Config
{
ServiceURL = apiServiceUrl, // Garage S3 API endpoint
ForcePathStyle = true, // Required for Garage
AuthenticationRegion = "garage"
};
var s3Client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
Key points:
– ForcePathStyle = true is required for Garage
– AuthenticationRegion = “garage” This is particularly important for non-AWS S3-compatible services like Garage that use custom region naming
– Separate API endpoint for uploads vs. public website endpoint for downloads
Upload Implementation
public async Task<string> UploadImageAsync(Stream fileStream, string fileName)
{
var uniqueFileName = $"logos/{Guid.NewGuid()}{Path.GetExtension(fileName)}";
var putRequest = new PutObjectRequest
{
BucketName = _bucketName,
Key = uniqueFileName,
InputStream = fileStream,
ContentType = contentType,
UseChunkEncoding = false // Important for compatibility
};
await _s3Client.PutObjectAsync(putRequest);
// Return public website URL
return $"{_publicEndpoint}/{uniqueFileName}";
}
The returned URL (https://cdn.myapp.com/logos/abc123.png) is directly accessible – no auth needed.
Migrating Existing Assets
I used a simple script to transfer existing files from R2 to Garage. The script downloaded files from R2 and re-uploaded them to Garage using the S3 API, preserving the same file paths for zero downtime.
Key Takeaways
Critical configuration:
– ForcePathStyle = true and AuthenticationRegion = “garage” are essential
– UseChunkEncoding = false prevents upload issues
– Bucket name must match your public domain for website mode
– DNS must point to Garage’s web endpoint
Why bucket-to-website:
– Garage doesn’t support anonymous S3 access yet
– Provides persistent public access without pre-signed URLs
– Perfect for truly public assets (logos, images, etc.)
Why Garage:
– Fixed costs instead of variable API/bandwidth charges
– Full control over storage infrastructure
– S3 is compatible with minimal code changes
For side projects where cost predictability matters, Garage is excellent. Self-hosting means I know exactly what I’m paying each month.
—
Resources: