How We Replaced MinIO with Garage for Self-Hosted S3 Storage
When a user opened an issue asking whether we planned to replace MinIO, we had the same question ourselves. MinIO’s recent licensing changes made its future for self-hosted deployments uncertain. We needed an S3-compatible storage engine that was lightweight, open source, and built for exactly our use case: a single-server deployment serving video files.
We landed on Garage and completed the migration the same day.
Why Garage
Garage is an S3-compatible storage engine written in Rust, licensed under AGPL-3.0. It was designed from the ground up for self-hosted and edge deployments. Where MinIO consumed 500+ MB of RAM on our server, Garage uses around 50 MB. It’s a single binary with SQLite-backed metadata — no Java runtime, no complex cluster setup.
The feature set is exactly what we needed: standard S3 API for presigned uploads and downloads, bucket management, and CORS configuration. Nothing more, nothing less.
The tricky parts
The migration wasn’t just swapping one Docker image for another. Garage has a few quirks that required creative solutions.
No shell in the container
Garage ships a distroless Docker image — no /bin/sh, no package manager, just the binary. This means you can’t run init scripts inside the Garage container. We solved this with a multi-stage Dockerfile that copies the garage binary into Alpine:
# Dockerfile.garage-init
FROM dxflrs/garage:v2.2.0 AS garage
FROM alpine:3.21
COPY --from=garage /garage /usr/local/bin/garage
This init container shares the Garage network namespace (network_mode: "service:garage"), giving the CLI direct access to Garage’s RPC port. It runs once on startup to configure the storage node.
Auto-generated credentials
Unlike MinIO where you set MINIO_ACCESS_KEY to whatever you want, Garage generates its own key IDs with a GK prefix. You can’t import arbitrary keys. The init script creates a key, extracts the credentials, and writes them to a shared Docker volume:
KEY_INFO=$(garage key create sendrec-key 2>/dev/null || true)
if [ -z "${KEY_INFO}" ]; then
KEY_INFO=$(garage key info sendrec-key 2>/dev/null || true)
fi
KEY_ID=$(echo "${KEY_INFO}" | grep -oE 'GK[a-f0-9]{24}' | head -1)
SECRET=$(echo "${KEY_INFO}" | grep "Secret key" | sed 's/.*: *//')
printf 'S3_ACCESS_KEY=%s\nS3_SECRET_KEY=%s\n' "${KEY_ID}" "${SECRET}" > "${GARAGE_KEYS_FILE}"
The app container mounts this volume read-only and sources the credentials on startup through a simple entrypoint wrapper:
#!/bin/sh
GARAGE_KEYS_FILE="${GARAGE_KEYS_FILE:-/run/garage-keys/env}"
if [ -f "${GARAGE_KEYS_FILE}" ]; then
set -a
. "${GARAGE_KEYS_FILE}"
set +a
fi
exec "$@"
CORS configuration
MinIO supported CORS through an environment variable. Garage uses the S3 PutBucketCors API — but you can also configure it through Garage’s admin JSON API, which is what we use in the init script:
BUCKET_ID=$(garage json-api GetBucketInfo "{\"globalAlias\":\"${S3_BUCKET}\"}" \
| grep '"id"' | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
garage json-api UpdateBucket "{\"id\":\"${BUCKET_ID}\",\"body\":{\"corsConfig\":{\"set\":[{\"allowedOrigins\":[\"*\"],\"allowedMethods\":[\"GET\",\"PUT\",\"HEAD\"],\"allowedHeaders\":[\"*\"],\"exposeHeaders\":[\"ETag\"],\"maxAgeSeconds\":3600}]}}}"
This runs on every init, so CORS persists across container recreations.
Zero application code changes
Because SendRec’s storage layer (internal/storage/storage.go) uses the standard AWS S3 SDK, we didn’t change a single line of Go code. The SDK talks the same S3 protocol to Garage as it did to MinIO. The only application-level change was removing hardcoded default credentials from main.go, since credentials are now auto-generated and injected.
We did need to add two AWS SDK compatibility flags that Garage requires:
environment:
- AWS_REQUEST_CHECKSUM_CALCULATION=when_required
- AWS_RESPONSE_CHECKSUM_VALIDATION=when_required
The migration
Moving 122 files (348 MB of video recordings and thumbnails) took about 4 minutes of total downtime:
- Started Garage alongside MinIO on the production server
- Ran
rclone syncto copy all objects from MinIO to Garage - Stopped the app, ran a final incremental sync (no changes)
- Pointed the app at Garage, restarted
- Verified with
rclone check— 0 differences, 122 matching files
After 48 hours with no issues, we removed the MinIO container, volume, and credentials entirely.
The result
The Docker Compose setup is cleaner. Garage, its init container, and the app coordinate through a shared volume — no hardcoded credentials in environment files. RAM usage for storage dropped from 500+ MB to ~50 MB.
Most importantly, the storage engine is fully open source (AGPL-3.0) with no licensing ambiguity. For anyone self-hosting SendRec — or any project that needs S3-compatible storage — our self-hosting guide has the complete setup including the init script, Docker Compose configuration, and instructions for using alternative S3 providers like Cloudflare R2 or Backblaze B2.