Storage Provider: Self-Hosted S3-Compatible Object Storage Container Image:
dxflrs/garage:v1.0.1S3 API Endpoint:http://localhost:3100(Host) /http://garage:3100(Docker Network) Admin REST API:http://localhost:3101(Host) Internal RPC:3901(Container Network) Default Bucket:rexoneDefault Region:garage
Garage is a lightweight, open-source distributed object storage service written in Rust by Deuxfleurs. It implements the standard Amazon S3-compatible API.
- 100% Self-Hosted & Free: No monthly storage fees, billing surprises, credit card requirements, or bandwidth limits.
- Zero Vendor Lock-In: Uses the official
aws-sdk-s3gem. Switching to AWS S3, MinIO, or Cloudflare R2 only requires updating endpoint environment variables. - Complete Privacy: Media assets, user avatars, voice recordings, and video files stay inside your local or private deployment volumes (
garage-dataandgarage-meta). - Local & Offline Dev: You can develop, upload, stream, and run underground media compression workers completely offline without an internet connection.
- No macOS Port Conflicts: Runs on ports
3100(S3) and3101(Admin), cleanly avoiding macOS ControlCenter / AirPlay Receiver conflicts on port 5000.
Run the dedicated dev script from the repository root:
./scripts/dev_garage.shWhat this script does automatically in the background:
- Starts the
dev-rexone-core-garagecontainer on ports3100and3101. - Polls until the Garage daemon is healthy and responsive.
- Assigns cluster layout for single-node development (
/garage layout assign -z dc1 -c 1G <NODE_ID>andlayout apply --version 1). - Creates default bucket
rexoneif it does not already exist. - Generates or imports API key
rexone-keywith full read, write, and owner permissions on the bucket. - Automatically syncs the generated credentials into your local
.envfile if keys are not yet configured.
If you prefer to start Garage manually via docker compose:
docker compose -f docker-compose.dev.yaml up garage -ddocker exec -it dev-rexone-core-garage /garage statusYou will see output showing your node with status NO ROLE ASSIGNED (e.g. e0f76906a44bfbb9).
# Replace <NODE_ID> with the ID from the status command
docker exec -it dev-rexone-core-garage /garage layout assign -z dc1 -c 1G <NODE_ID>
docker exec -it dev-rexone-core-garage /garage layout apply --version 1docker exec -it dev-rexone-core-garage /garage bucket create rexonedocker exec -it dev-rexone-core-garage /garage key create rexone-keyGarage will output:
Key ID: GKxxxxxxxxxxxxxxxxxxxxxxxx
Secret key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
docker exec -it dev-rexone-core-garage /garage bucket allow --read --write --owner rexone --key rexone-keyAdd the generated Key ID and Secret key to your .env file:
STORAGE_PROVIDER=garage
S3_ENDPOINT=http://garage:3100
S3_PUBLIC_ENDPOINT=http://localhost:3100
S3_BUCKET=rexone
S3_REGION=garage
S3_ACCESS_KEY=GKxxxxxxxxxxxxxxxxxxxxxxxx
S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
S3_ADMIN_ENDPOINT=http://garage:3101
S3_ADMIN_TOKEN=rexone_garage_admin_token_secret_key_xxxxxGarage implements Amazon S3 credential structures with standard cryptographic sizing:
- Access Key ID (
S3_ACCESS_KEY): Prefixed withGKfollowed by 24 hexadecimal characters ([0-9a-f]{24}). Example format:GKdf638173bb772fdd2a5dbb09. - Secret Access Key (
S3_SECRET_KEY): A 64-character hexadecimal string ([0-9a-f]{64}). - Key Storage: Keys are cryptographically stored and managed within Garage's metadata engine (
garage-meta).
Garage generates unique, cryptographically random keys on-demand without requiring hardcoded secrets:
- Creation:
docker exec dev-rexone-core-garage /garage key create <key-name> - Inspection:
docker exec dev-rexone-core-garage /garage key info --show-secret <key-name> - Authorization:
docker exec dev-rexone-core-garage /garage bucket allow --read --write --owner <bucket> --key <key-name> - Revocation:
docker exec dev-rexone-core-garage /garage key delete <key-name> --yes
For browsing object storage, viewing thumbnails, and inspecting bucket hierarchies:
- URL: http://localhost:4000/admin/assets (Web) or http://localhost:3000/admin/assets (API).
- Features: Visual asset gallery, media player (audio/video), dimension and MIME metadata, underground compression metrics, and soft/hard delete lifecycle.
Cyberduck is a free, native graphical file browser for S3 on macOS.
We have included a pre-configured profile at docs/Garage_Local.cyberduckprofile:
- Double-click
docs/Garage_Local.cyberduckprofilein Finder. - Cyberduck will open with Server (
localhost), Port (3100), and Path (/rexone) automatically configured over HTTP. - Paste your credentials:
- Access Key ID: Your
S3_ACCESS_KEY(e.g.GKdf638173bb772fdd2a5dbb09) - Secret Access Key: Your
S3_SECRET_KEY
- Access Key ID: Your
- Click Connect (or Save Bookmark).
- Open Cyberduck and click Open Connection (
Cmd + O). - Protocol dropdown: Select S3 (HTTP). (Note: If only "Amazon S3" appears, it forces port 443/TLS. Use the profile file from Method A or download the official Cyberduck S3 (HTTP) Profile).
- Set Server:
localhost| Port:3100. - Enter Access Key ID (
S3_ACCESS_KEY) and Secret Access Key (S3_SECRET_KEY). - Expand More Options, set Region:
garageand Path:/rexone. - Click Connect. You can now view all files, download, drag-and-drop upload, and organize buckets visually.
- Use the Amazon S3 protocol pointing to
http://localhost:3100with path-style requests enabled.
graph TD
Browser["Client Browser / Mobile"] -->|View file: http://localhost:3100/rexone/...| S3_PORT["Garage Port 3100 (S3 API)"]
API["API Container (Puma)"] -->|Upload / Delete via http://garage:3100| S3_PORT
MEDIA["Media Worker (SolidQueue)"] -->|Fetch & Re-upload compressed files| S3_PORT
Script["scripts/dev_garage.sh"] -->|Bootstrap layout & bucket| ADMIN_PORT["Garage Port 3101 (Admin API)"]
ExtGUI["Cyberduck / S3 GUI"] -->|Browse bucket via http://localhost:3100| S3_PORT
| Port | Service | Scope | Default Host Binding |
|---|---|---|---|
| 3100 | S3 API | Upload, download, delete, presigned URLs | http://localhost:3100 |
| 3101 | Admin API | Node clustering, layout, key & bucket management | http://localhost:3101 |
| 3901 | Cluster RPC | Internal cluster node synchronization | Container internal ([::]:3901) |
All commands can be executed via docker exec:
| Task | Command |
|---|---|
| Check node status & health | docker exec dev-rexone-core-garage /garage status |
| List all buckets | docker exec dev-rexone-core-garage /garage bucket list |
Inspect rexone bucket |
docker exec dev-rexone-core-garage /garage bucket info rexone |
| List API keys | docker exec dev-rexone-core-garage /garage key list |
| Inspect API key details | docker exec dev-rexone-core-garage /garage key info rexone-key |
| Create a new bucket | docker exec dev-rexone-core-garage /garage bucket create <bucket-name> |
| Grant key bucket access | docker exec dev-rexone-core-garage /garage bucket allow --read --write --owner <bucket> --key <key> |
Here are the real-world issues you may encounter when setting up and running Garage, along with their root causes and resolutions.
- Symptom: Opening a direct asset URL in the browser (e.g.
http://localhost:3100/rexone/my-image.png) or loading an<img>element returns an XML error with<Code>AccessDenied</Code>and<Message>Forbidden: Garage does not support anonymous access yet</Message>. - Root Cause: Garage's S3 API (
[s3_api]on port 3100) is private by default. It requires AWS SigV4 authentication on every request and intentionally rejects unauthenticated/anonymous HTTPGETrequests. - Solution:
- Never use plain, unauthenticated S3 URLs in the frontend.
- Rexone Core automatically issues AWS S3 Presigned URLs via
Aws::S3::Presigneron port 3100 with a 7-day expiration (expires_in: 7.days.to_i). AssetSerializerandAsset#storage_urlgenerate fully signed URLs (?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...) that web browsers, mobile apps, and video players can access seamlessly.
- Symptom: Background jobs (
Media::CompressImageJoborMedia::CompressVideoJob) fail withFailed to open TCP connection to localhost:3100 (Connection refused - connect(2) for "localhost" port 3100). - Root Cause: Inside a Docker container (
mediaorwaka),localhost:3100resolves to the worker container itself where no Garage daemon is running. - Solution:
- The backend uses internal Docker network routing (
S3_ENDPOINT=http://garage:3100), whereas external clients useS3_PUBLIC_ENDPOINT=http://localhost:3100. - Always use
StorageService::Client.download(asset.storage_key, local_path)instead of HTTP loopback calls (URI.open(asset.url)).StorageService::Garage#downloadstreams objects directly from@client.get_objectacross the Docker network without relying on host port forwarding.
- The backend uses internal Docker network routing (
- Symptom: When testing a presigned URL using
curl -I "http://localhost:3100/rexone/...", Garage returns403 Forbidden, but pasting the exact same URL into a web browser tab returns200 OKand loads the image. - Root Cause: Presigned URLs are cryptographically bound to the HTTP method they were generated for (
:get_objectsignsGET). Thecurl -Icommand sends aHEADrequest, which produces a signature mismatch in AWS SigV4 validation. - Solution:
- Test presigned URLs using
GETwith curl:curl -s -D - -o /dev/null "<PRESIGNED_URL>" - This outputs the HTTP headers (
HTTP/1.1 200 OK) and discards the binary body, verifying theGETsignature accurately.
- Test presigned URLs using
- Symptom: Running
docker compose up garageexits immediately with status code 1 and logsError: IO error: No such file or directory (os error 2). - Root Cause: Garage cannot locate its configuration file at
/etc/garage.tomlor the meta/data volume directories. - Solution:
- Verify that
config/garage.tomlexists in the repository root. - Check that
docker-compose.dev.yamlincludes the volume bind mount:volumes: - ./config/garage.toml:/etc/garage.toml:ro - garage-meta:/var/lib/garage/meta - garage-data:/var/lib/garage/data
- Use
./scripts/dev_garage.sh, which verifies these prerequisites before launching the container.
- Verify that
- Symptom: Starting Garage on port 5000 fails because the port is already in use by a macOS system process (
ControlCenter). - Root Cause: macOS Monterey and newer bind port 5000 to the system AirPlay Receiver.
- Solution:
- Rexone Core intentionally configures Garage on ports
3100(S3 API) and3101(Admin API), avoiding any macOS AirPlay conflicts without requiring changes to macOS system settings.
- Rexone Core intentionally configures Garage on ports
🔴 Problem 6: Cyberduck Error: Failed to parse XML document with handler class org.jets3t.service.impl.rest.XmlResponsesSaxParser$ListBucketHandler
- Symptom: Connecting to Garage via Cyberduck displays the error dialog:
Failed to parse XML document with handler class org.jets3t.service.impl.rest.XmlResponsesSaxParser$ListBucketHandler. Please contact your web hosting service provider for assistance. - Root Cause:
- Region Mismatch: Cyberduck defaults to region
us-east-1for S3 connections. Garage strictly validates the AWS SigV4 scope againsts3_region = "garage"(configured inconfig/garage.toml). When Cyberduck signs withus-east-1, Garage returns400 Bad Request: unexpected scope: 20260904/us-east-1/s3/aws4_request. Cyberduck receives this HTTP 400 error body instead of the expected bucket XML, causing its Java XML parser (ListBucketHandler) to crash. - Virtual-Host Addressing: Cyberduck defaults to virtual-hosted addressing (
http://rexone.localhost:3100) rather than path-style addressing (http://localhost:3100/rexone).
- Region Mismatch: Cyberduck defaults to region
- Solution:
- Method 1 (Recommended): Double-click
docs/Garage_Local.cyberduckprofile. It sets the region togarage, disables virtual-host addressing (s3.bucket.virtualhost.disable=true), and configures port 3100. - Method 2 (Manual Cyberduck Setting): In the Open Connection dialog, click More Options, change Region to
garage, and set Path to/rexone. - Method 3 (macOS Terminal): Run
defaults write ch.sudo.cyberduck s3.bucket.virtualhost.disable trueto enforce path-style requests globally across Cyberduck.
- Method 1 (Recommended): Double-click
No. The silent media compression pipeline operates strictly in-place:
- Exact Storage Key Re-Use:
When
Media::CompressImageJoborMedia::CompressVideoJobprocesses an asset, it downloads the original file to a temporary worker scratchpad, compresses it, and calls:StorageService::Client.upload( compressed_path, storage_key: @asset.storage_key, overwrite: true )
- Atomic S3 Replacement:
Because
storage_keyis identical to the existing record, Garage's S3 API (put_object) atomically replaces the existing object in therexonebucket. No new files, random hashes, or duplicate versions are created. - Zero Waste on Optimal Files:
If compression does not yield a smaller file (
compressed_bytes >= original_bytes), the pipeline aborts the re-upload completely:- The original file in Garage remains untouched.
- The asset is marked
optimal(mark_optimal!) immediately. - No unnecessary S3 write operations or network transfers occur.
- Temporary File Scrubbing:
The worker scratchpad directory (
/tmp/media_compress*) is cleanly wiped in anensureblock viaFileUtils.rm_rfimmediately after the job finishes.
How should object storage paths (storage_key) be structured across the ecosystem?
rexone/
├── admin/
│ ├── logo_company_1788533943.png
│ ├── banner_summer_sale_1788534000.webp
│ └── product_tshirt_black_1788534200.jpg
└── user/
├── 550e8400-e29b-41d4-a716-446655440000/
│ ├── avatar_profile_1788533943.png
│ ├── audio_voice_note_1788534120.mp3
│ └── document_contract_1788534500.pdf
└── 7c9e6679-7425-40de-944b-e07fc1f90ae7/
└── avatar_profile_1788536000.jpg
- Avoid redundant suffixes like
_uploads(e.g.admin_uploads/,user_uploads/). In object storage, all stored objects are uploaded assets. admin/: Holds platform-wide assets, system logos, marketing hero banners, static email templates, and admin catalog files.user/: Scoped container for all end-user content.
Partitioning user assets by user/{user_id}/ provides critical production advantages:
- GDPR / Account Deletion ("Right to be Forgotten"): When a user deletes their account, purging all their files is a single atomic S3 prefix deletion (
delete_objectswith prefixuser/{user_id}/). - Quota Tracking & Billing: Calculating a user's total storage consumption requires only a single S3 query: sum the
sizeof all objects with prefixuser/{user_id}/. - Zero Collision Risk: Multiple users can upload
photo.jpgsimultaneously without name collision. - Intuitive GUI Browsing: In Cyberduck or S3 browsers, you see organized per-user folders rather than tens of thousands of loose files in a single flat directory.
Why avoid user/{user_id}/{type}/ subfolders?
- Types are Mutable: An asset's
typeoften evolves over time (e.g., fromgeneraltoavatar, or fromattachmenttodocument). - Moving Files in S3 is Costly: Object storage does not have a native "rename" or "move" operation. Moving a file requires
CopyObject(copying the entire byte stream) +DeleteObject(deleting the old key). This invalidates cached presigned URLs, introduces race conditions, and requires updating database references. - Folder Proliferation: Most users only upload 1–3 files (e.g. 1 avatar). Creating separate subfolders (
/avatar/,/audio/,/document/,/general/) for every user creates excessive empty folders and navigation friction in S3 GUIs.
Recommended Filename Pattern:
user/{user_id}/{type}_{sanitized_basename}_{timestamp}.{ext}
and for admins:
admin/{type}_{sanitized_basename}_{timestamp}.{ext}
- Database Column (
Asset#type): Single source of truth for queries, filtering, authorization, and UI grouping. - S3 Storage Key In-Place Rename: When an admin reclassifies an asset's
typevia the Admin Portal, the backend renames the object in-place (StorageService::Client.move(old_key, new_key)) using Garage's metadata copy + delete, updating the key and URL without leaving orphan files. - File Extension (
.png,.mp4): Always retain the true file extension in the key so Cyberduck, browsers, and CDNs immediately recognize the MIME type and render native thumbnail previews.
Yes. All uploaded media, video streams, user avatars, and metadata chunks are physically stored inside Docker named volumes on your VPS disk:
rexone-core_garage-data: Content-addressed immutable data blocks.rexone-core_garage-meta: SQLite database files, indexes, and write-ahead logs.
docker exec dev-rexone-core-garage /garage statusOutput:
==== HEALTHY NODES ====
ID Hostname Address Tags Zone Capacity DataAvail
87134ce57f61018a 6f32e77d686f 172.19.0.6:3901 [] dc1 1000.0 MB 53.9 GB (85.9%)
DataAvail: The actual free disk space remaining on your VPS host drive (e.g.53.9 GB).Capacity: Logical maximum assigned to this node.
docker exec dev-rexone-core-garage /garage bucket info rexoneOutput:
Size: 168.9 kiB (173.0 KB)
Objects: 3
docker system df -v | grep garagedf -h /When df -h / shows
In rexone-web at /admin/assets, administrators can monitor storage metrics directly from the browser without logging into SSH:
- Garage Occupied Storage: Live bucket bytes and object count.
- VPS Host Disk Space: Live available disk space, total capacity, and used percentage progress bar.
- Low Disk Alert: Automatic warning banner when host free disk space drops below 15%.
- Active Database Records: Active asset record count and tracked bytes.
- Live Refresh: Instant re-fetch querying
GET /v1/admin/assets/storage_stats.
Important
Rebooting or restarting servers DOES NOT lose any data.
Docker named volumes (postgres, garage-meta, garage-data) are permanently decoupled from container lifecycles. Running docker compose down, docker restart, or rebooting the physical VPS leaves all database rows and Garage files 100% intact.
For catastrophic disaster recovery (VPS hardware failure, disk corruption, accidental server deletion), use the built-in backup scripts in scripts/:
| Script | Function | Target |
|---|---|---|
./scripts/backup_all.sh |
Runs full unified backup | Both DB & Garage |
./scripts/backup_db.sh |
Dumps PostgreSQL (pg_dump) |
backups/db/rexone_core_*.sql.gz |
./scripts/backup_garage.sh |
Takes live meta snapshot & tarballs volumes | backups/garage/garage_backup_*.tar.gz |
- Automatic Pruning: Retains backups for 7 days by default (
RETENTION_DAYS=7). - Zero Lock-In: Backups are written to the local
./backups/directory (git-ignored).
Add this single cron line to your VPS (crontab -e) to back up everything automatically every night at 3:00 AM:
0 3 * * * cd /path/to/rexone-core && ./scripts/backup_all.sh >> /var/log/rexone_backup.log 2>&1