Zero to Production: Part 4 - Rethinking Object Storage: The Garage Pivot
When you spin up self-hosted Supabase via the standard template from Coolify, it provisions MinIO by default to handle your S3-compatible object storage layer. For the first few days of development, it works fine. But as the data grows and you watch the server metrics on a resource-constrained VPS, the architectural cracks start to show.
MinIO is a heavy enterprise ecosystem that consumes a substantial chunk of idle RAM. Combined with strict licensing shifts in recent years and a lack of highly optimized, lightweight Docker images tailored for independent developers, it quickly becomes an unnecessary resource sink for a single-server deployment.
My quest to replace MinIO turned into a fascinating architectural journey. I initially planned to roll with the native filesystem for storage in Supabase, but I wanted a completely decoupled, global storage hub that could scale out across future standalone applications on my server. Then I evaluated RustFS, an incredibly fast S3-compatible engine. However, RustFS is engineered for enterprise-scale distributed tracing and massive AI data workloads. Its default configuration bundles an incredibly heavy observability stack (including Tempo, Jaeger, Loki, and Grafana) and forces you to simulate multiple virtual drives to satisfy its erasure-coding algorithms even when running on a single physical disk. It was massive overkill.
Then I found Garage.
Garage is a lightweight, single-binary, self-hoster-friendly S3-compatible storage engine written in Rust that utilizes SQLite for its internal metadata replication tracking. It idles at a mere fraction of MinIO’s memory footprint, completely strips out enterprise logging bloat, and is designed specifically to eke out maximum performance on low-power hardware, home labs, and single-VPS instances.
This is how I deployed Garage alongside a visual dashboard inside Coolify, navigated its unique single-node clustering layout, and connect it cleanly to the Supabase backend.
Phase 1: Designing the Standalone Storage Hub
Instead of bundling your storage engine directly inside the Supabase project stack, the optimal DevOps move is to deploy Garage as its own standalone project within Coolify. This completely decouples the storage layer from any single database, allowing future independent applications on the VPS to leverage the same high-performance S3 backend.
To make management painless, we can deploy the core Garage engine (v2.1.0) and the open-source garage-webui dashboard inside the same Docker Compose resource file. This synchronizes their container lifecycles and allows the UI to communicate directly with Garage’s sensitive Admin API over an isolated network without ever exposing administration ports to the public internet.
Create a new Docker Compose resource in Coolify and paste this production-ready configuration:
services:
garage:
image: 'dxflrs/garage:v2.1.0'
container_name: garage-storage
security_opt:
- "no-new-privileges:true" # Hardens the container against privilege escalation attacks
environment:
- GARAGE_S3_API_URL=$GARAGE_S3_API_URL
- GARAGE_WEB_URL=$GARAGE_WEB_URL
- GARAGE_ADMIN_URL=$GARAGE_ADMIN_URL
- 'GARAGE_RPC_SECRET=${SERVICE_HEX_64_RPCSECRET}'
- GARAGE_ADMIN_TOKEN=$SERVICE_PASSWORD_GARAGE
- GARAGE_METRICS_TOKEN=$SERVICE_PASSWORD_GARAGEMETRICS
- GARAGE_ALLOW_WORLD_READABLE_SECRETS=true
- 'RUST_LOG=${RUST_LOG:-garage=info}'
volumes:
- 'garage-meta:/var/lib/garage/meta'
- 'garage-data:/var/lib/garage/data'
- type: bind
source: ./garage.toml
target: /etc/garage.toml
healthcheck:
test: ["CMD", "/garage", "stats", "-a"]
interval: 10s
timeout: 5s
retries: 5
networks:
- coolify
- supabase_private
webui:
image: 'khairul169/garage-webui:latest'
container_name: garage-webui
depends_on:
- garage
environment:
- PORT=3909
- API_BASE_URL=http://garage:3903 # Internal Docker routing to Garage's Admin API
- S3_ENDPOINT_URL=http://garage:3900 # Internal programmatic S3 API routing
- API_ADMIN_KEY=$SERVICE_PASSWORD_GARAGE
- S3_REGION=us-east-1
volumes:
- type: bind
source: ./garage.toml
target: /etc/garage.toml # Requires explicit access to map cluster topology dynamically
healthcheck:
# Technical fix for "FROM scratch" images containing no underlying OS shell
test: ["CMD", "/bin/curl", "-f", "http://127.0.0.1:3909/"]
interval: 15s
timeout: 5s
retries: 3
networks:
- coolify
- supabase_private
volumes:
garage-meta:
garage-data:
networks:
coolify:
external: true
supabase_private:
name: <YOUR_SUPABASE_NETWORK_ID> # Replace with your exact Supabase internal private network ID
external: trueDeconstructing the Container Quirks
If you attempt to deploy this stack blindly, you will run into two distinct containerization anomalies that will hang or crash your deployments:
- The "Scratch" Image Healthcheck Trap: The
garage-webuiimage is compiledFROM scratchfor maximum security and minimal disk size. It contains no underlying operating system layers—meaning it completely lacks a/bin/shor bash shell. A standard CoolifyCMD-SHELLhealthcheck will instantly fail because Docker cannot spin up a shell interpreter inside the container. To bypass this, the healthcheck block uses a direct execution array (["CMD", ...]) invoking a custom, compiled staticcurlbinary that the developer explicitly baked into the root directory of the container image. - Internal DNS Routing: Notice that the
API_BASE_URLpoints explicitly tohttp://garage:3903, matching the exact YAML service key name, nothttp://garage-storage. Docker's internal embedded DNS server within Coolify handles internal service-to-service discovery based on the core service declaration block identifier to prevent accidental container name collisions on the host machine.
Phase 2: Navigating the Cluster Layout Gauntlet
Once your containers turn a healthy green, if you try to immediately run an application migration or programmatically execute an S3 bucket creation, you will be rejected by the Garage engine with a flat error: InternalError (500): Layout not ready.
Because Garage is a natively distributed, peer-to-peer system designed to replicate data seamlessly across multiple global geographic points, it absolutely refuses to handle data writes until you explicitly declare its cluster topology. Even when running a single, isolated node on a single VPS, you must explicitly step through its clustering layout wizard so the data engine can build its internal consistent hashing rings.
To initialize the single-node cluster topology, SSH into the VPS and execute the following sequential sequence:
Step 1: Query Node Identity
First, ping the running container to extract the node's unique public hash signature:
sudo docker exec -it garage-storage /garage statusLook for the alphanumeric hash representing your local node (e.g., ndv5tb3qpu5k69a0) and copy it.
Step 2: Assign Node Capacity Weight & Fault Domains
Next, assign the node to a physical availability zone and declare its relative capacity weight:
sudo docker exec -it garage-storage /garage layout assign [YOUR_NODE_ID] --zone dc1 --capacity 1G- The
1G/ "1000.0 MB" Mathematical Trap: In version 2.1.0, Garage strictly enforces that capacity weights must be declared using explicit storage units (like1G,10G) or be an absolute integer value of at least 1024. This ensures data shards can be cleanly split without rounding issues. When you execute this command, Garage will print out a confirmation stating it has allocated exactly "1000.0 MB of usable capacity". Do not worry. This is not a hard disk storage quota or a partition limit. This is simply a relative mathematical scaling weight used to map the engine's 256 internal virtual data tokens across your hardware cluster. Garage will happily use hundreds of gigabytes of actual server disk space if data is pushed to it. You can check the real, underlying disk consumption separately at any time by running/garage stats.
Step 3: Commit and Apply the Changes
Finalize the layout topology and commit it to the live storage cluster:
sudo docker exec -it garage-storage /garage layout apply --version 1Step 4: Provision the S3 Bucket & Generate Access Credentials
Now that the storage ring is fully active, we need to provision a dedicated Supabase storage bucket via the CLI:
sudo docker exec -it garage-storage /garage bucket create supabase-storageGarage v2.1.0 completely streamlined its security interface. To generate the isolated, programmatic S3 credentials, use the unified key create command parameter:
sudo docker exec -it garage-storage /garage key create supabase-keyCopy the returned Access Key ID and Secret Access Key from the terminal.
Finally, bind that credential key block to the new bucket with full data permissions:
sudo docker exec -it garage-storage /garage bucket allow --read --write --owner supabase-storage --key supabase-keyPhase 3: Hooking up Supabase & Conquering the Final Boss
With the Garage S3 cluster running optimally, jump back into the Supabase service project dashboard in Coolify to swap out the default MinIO connection strings.
While the Coolify UI often displays generic GLOBAL_S3_... template variables, self-hosted Supabase microservices (specifically the Storage and GoTrue auth containers) strictly look for variables prefixed explicitly with STORAGE_.
Navigate to the Supabase Environment Variables tab and map the configuration as follows:
STORAGE_BACKEND=s3
STORAGE_S3_BUCKET=supabase-storage
STORAGE_S3_ENDPOINT=http://garage:3900
AWS_ACCESS_KEY_ID=YOUR_GENERATED_GARAGE_ACCESS_KEY
AWS_SECRET_ACCESS_KEY=YOUR_GENERATED_GARAGE_SECRET_KEY
STORAGE_S3_FORCE_PATH_STYLE=true- Why Path Style is Mandatory: Setting
STORAGE_S3_FORCE_PATH_STYLE=trueis an absolute requirement when running storage engines containerized locally alongside apps. If you omit this, the S3 client libraries inside Supabase will assume standard cloud routing and attempt subdomain-style resolution (e.g.,http://supabase-storage.garage:3900), which cannot resolve across internal private Docker network routers, throwing immediate connection timeouts.
Conquering the Final Boss: AWS Signature V4 Mismatch
After applying the variables and restarting the Supabase stack, I went to my front-end interface to run an image upload test. The interface immediately spun out and threw an opaque error inside the logs:
Authorization header malformed, unexpected scope: 20260621/us-east-1/s3/aws4_request. Expected region: "garage".When Supabase handles uploads, it securely signs every single payload packet using the strict AWS Signature V4 authentication protocol. This protocol constructs a secure cryptographic signature based on timestamps, user keys, and the designated region string.
Because standard template blueprints default to STORAGE_S3_REGION=us-east-1, Supabase cryptographically generated the signature token scoped to us-east-1. Garage, however, strictly maps its internal cryptographic region boundary to the literal string garage. Because the security scope strings did not align, Garage flagged the upload packet as a potential spoofing attempt or data anomaly and rejected the connection with a hard 403 Forbidden block.
The Resolution
To fix this, go back to the Supabase Environment Variables configuration panel in Coolify and update the region key:
STORAGE_S3_REGION=garageSave the changes and click Restart.
With the region scope aligned, file uploads and deletions will process instantly across the private Docker bridge, and the VPS server disk will write binaries natively, running on a highly performant engine that idles at a mere fraction of the CPU and hardware costs of MinIO.
The storage layer is fully optimized, decoupled, and securely bound. Next up, we build the monitoring stack to ensure this multi-service architecture never silently runs out of server resources.
Next Post: Part 5 — Eyes on the Glass: The Observability Stack