← Back to Tech Log

Zero to Production: Part 4 - Rethinking Object Storage: The Garage Pivot

·9 min read·Part 4 of the Zero To Production series·

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:

yaml
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: true

Deconstructing 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:

  1. The "Scratch" Image Healthcheck Trap: The garage-webui image is compiled FROM scratch for maximum security and minimal disk size. It contains no underlying operating system layers—meaning it completely lacks a /bin/sh or bash shell. A standard Coolify CMD-SHELL healthcheck 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 static curl binary that the developer explicitly baked into the root directory of the container image.
  2. Internal DNS Routing: Notice that the API_BASE_URL points explicitly to http://garage:3903, matching the exact YAML service key name, not http://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:

bash
sudo docker exec -it garage-storage /garage status

Look 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:

bash
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 (like 1G, 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:

bash
sudo docker exec -it garage-storage /garage layout apply --version 1

Step 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:

bash
sudo docker exec -it garage-storage /garage bucket create supabase-storage

Garage v2.1.0 completely streamlined its security interface. To generate the isolated, programmatic S3 credentials, use the unified key create command parameter:

bash
sudo docker exec -it garage-storage /garage key create supabase-key

Copy 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:

bash
sudo docker exec -it garage-storage /garage bucket allow --read --write --owner supabase-storage --key supabase-key

Phase 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:

yaml
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=true is 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:

code
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:

yaml
STORAGE_S3_REGION=garage

Save 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

More from the Zero To Production series