Object Storage (Garage)
Garage is kilter's default S3-compatible object store. File handling splits into three layers:
| Layer | Tool | When it runs |
|---|---|---|
| Upload | Sharp | At upload time — normalize images (strip EXIF, auto-orient) |
| Storage | Garage | Permanent S3-compatible storage |
| Display | imgproxy | On request — resize, crop, format conversion |
rustfs is a drop-in alternative if you want a MinIO-style web console; it exposes the same env vars, so nothing below changes.
Connection
Garage publishes both GARAGE_* and generic S3 aliases. Prefer the generic aliases — every major S3 SDK (@aws-sdk/client-s3, boto3, minio-go) reads them natively:
S3_ENDPOINT=http://garage.<namespace>.svc:3900 # in-cluster; localhost port from `kilter env`
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=garage
AWS_S3_BUCKET=kilterGarage is headless — no web console. Inspect buckets with the CLI:
aws s3 ls s3://$AWS_S3_BUCKET/ --endpoint-url $S3_ENDPOINTS3 client setup
import { S3Client } from '@aws-sdk/client-s3';
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
region: process.env.AWS_REGION || 'garage',
forcePathStyle: true,
});AWS SDKs default to virtual-hosted-style URLs (bucket.host/key), which S3-compatible stores like Garage don't serve. Omit this flag and every request fails with confusing DNS or 404 errors.
Sharp: normalize at upload
Run uploads through Sharp once, before storing — auto-orient from EXIF, strip metadata, convert to a consistent format:
import sharp from 'sharp';
async function processUpload(buffer: Buffer): Promise<Buffer> {
return sharp(buffer).rotate().withMetadata().toBuffer();
}Sharp is upload-time only; never resize for display here. Two deployment gotchas: install sharp as a real dependency (not a devDependency), and use a base image with native-binding support (node:22-alpine works).
imgproxy: transform at display
imgproxy generates resized/converted images on the fly, reading directly from Garage (IMGPROXY_USE_S3=true, same AWS_* credentials). URL shape:
http://localhost:<port>/unsafe/rs:fit:{width}:{height}/plain/s3://<bucket>/{path}@webp
function imgproxyUrl(path: string, width: number, height: number): string {
const base = process.env.IMGPROXY_URL;
const bucket = process.env.AWS_S3_BUCKET;
return `${base}/unsafe/rs:fit:${width}:${height}/plain/s3://${bucket}/${path}@webp`;
}Render <img> tags against imgproxy URLs, never raw Garage URLs — you get right-sized WebP for free.
Troubleshooting
| Symptom | Fix |
|---|---|
| "Access Denied" from Garage | Credentials don't match — re-read AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY from kilter env |
| imgproxy returns 404 | Object missing (aws s3 ls it), or the path has a leading slash — use uploads/photo.jpg, not /uploads/photo.jpg; check kilter logs imgproxy for Garage reachability |
| Sharp fails in the pod | Native bindings missing — check the base image, and that sharp is a production dependency |