KBkilterKB
dev

Object Storage (Garage)

Garage is kilter's default S3-compatible object store. File handling splits into three layers:

LayerToolWhen it runs
UploadSharpAt upload time — normalize images (strip EXIF, auto-orient)
StorageGaragePermanent S3-compatible storage
DisplayimgproxyOn 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=kilter

Garage is headless — no web console. Inspect buckets with the CLI:

aws s3 ls s3://$AWS_S3_BUCKET/ --endpoint-url $S3_ENDPOINT

S3 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,
});
forcePathStyle: true is mandatory

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

SymptomFix
"Access Denied" from GarageCredentials don't match — re-read AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY from kilter env
imgproxy returns 404Object 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 podNative bindings missing — check the base image, and that sharp is a production dependency