* feat(backend,frontend): recursive delete of key prefixes in bulk actions
Bulk object selection previously only supported deleting individually
listed object keys — folders ("prefixes") could not be selected or
deleted, leaving no way to remove a directory and all of its contents.
Backend:
- Add S3Service.DeleteObjectsByPrefix, which recursively lists every
object under a prefix and batch-deletes them, returning the count.
- Extend the delete-multiple endpoint to accept a "prefixes" array
alongside "keys"; keys are batch-deleted and each prefix is deleted
recursively. Response now reports the total objects removed.
Frontend:
- Enable folder checkboxes and add a per-folder "Delete folder" action.
- Select-all now covers both files and folders.
- Route all bulk/folder deletes through a confirmation dialog that
spells out the file/folder counts and warns that folders are removed
recursively (previously bulk delete fired with no confirmation).
- api/hook send "prefixes"; optimistic update drops objects under any
deleted prefix.
* fix(backend): validate delete prefixes and count actual removals
Addresses maintainer review on the recursive prefix-delete endpoint:
- Reject blank/whitespace-only prefixes with a 400 instead of a 500, and
normalize each prefix to have a trailing "/" so "photos/2024" can no
longer also delete siblings like "photos/2024-old/..." on this
irreversible public endpoint.
- DeleteMultipleObjects now returns the number of objects actually
removed (requested keys minus failures) rather than assuming every
requested key was deleted; the handler sums real counts across the
keys and prefix paths. Draining the full RemoveObjects error channel
also fixes a potential sender-goroutine leak on early return.
- Add tests: blank-prefix -> 400, prefix trailing-slash normalization,
and S3Service.DeleteObjectsByPrefix (list-then-delete, empty prefix,
no-match, and list-error propagation).
* fix(frontend): align select-all with the active search filter
The header "select all" checkbox derived its checked state from the
filtered (searched) rows, but handleSelectAll operated on the full,
unfiltered object list — so with a search active it selected hidden
items and the checkbox state disagreed with the selection.
ObjectsTable now passes the keys of the currently visible (filtered)
rows to onSelectAll, and its checked state reflects whether every
visible row is selected. handleSelectAll toggles only those visible
rows, leaving any off-screen selection intact.
* fix(frontend): scope select-all to the visible page
After merging upstream's client-side deep-search pagination, the rendered
rows are pageObjects (one page slice) while select-all still operated on
filteredObjects (every match across hidden pages). Scope the header
checkbox's state and its select-all action to pageObjects so one click
never selects off-screen rows for a destructive bulk delete. In
normal/prefix browsing pageObjects === filteredObjects, so behavior there
is unchanged.
---------
Authored-by: Camilo Hollanda <775409+prem-prakash@users.noreply.github.com>
Garage UI - Web Dashboard for Garage S3 Storage
A modern web interface to manage Garage object storage clusters. Browse buckets, manage access keys, monitor your cluster, all from your browser.
![]() |
![]() |
![]() |
![]() |
Features
- Bucket management - create, configure, and browse buckets with drag-and-drop file uploads
- Access key management - create keys, assign per-bucket permissions
- Cluster overview - monitor node status, layout configuration, and storage usage
- Flexible authentication - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.)
- Multi-user access control - optional OIDC-team-based permissions, see docs/access-control.md
- Easy deployment - single Docker image or Helm chart, configure with one YAML file
Quick Start
Prerequisites
- Docker & Docker Compose
- A running Garage cluster (v2.1.0+) - setup guide if you need one
1. Clone & Configure
git clone https://github.com/Noooste/garage-ui.git
cd garage-ui
cp config.example.yaml config.yaml
Edit config.yaml with your Garage endpoints and admin token (from garage.toml).
2. Start
docker compose up -d garage-ui
Access at http://localhost:8080
Deployment
Docker
docker run -d -p 8080:8080 \
-v $(pwd)/config.yaml:/app/config.yaml \
noooste/garage-ui:latest
Kubernetes
helm repo add garage-ui https://helm.noste.dev/
helm install garage-ui garage-ui/garage-ui \
--set garage.endpoint=http://garage:3900 \
--set garage.adminEndpoint=http://garage:3903 \
--set garage.adminToken=your-token
The chart creates a ClusterIP service on port 80. To try it out before setting up an ingress:
kubectl port-forward svc/garage-ui 8080:80
Then open http://localhost:8080
Reusing your garage.toml
If you already have a running Garage instance, you can point Garage UI straight at your garage.toml and skip config.yaml entirely:
./garage-ui --garage-toml /etc/garage.toml
Garage UI reads the S3 endpoint, admin endpoint, admin token, and S3 region straight from the TOML file. When no authentication method is explicitly configured, token auth auto-enables: the login page asks for the Garage admin token, giving you a login wall with zero extra config.
Bind address handling: Wildcard addresses like 0.0.0.0 or [::] are converted to 127.0.0.1 so the UI can reach Garage on localhost. Inside a container this won't work, so override the endpoints explicitly with environment variables or a config file.
Docker:
docker run -d -p 8080:8080 \
-v /etc/garage.toml:/etc/garage.toml:ro \
-e GARAGE_UI_GARAGE_TOML=/etc/garage.toml \
-e GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900 \
-e GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://garage:3903 \
noooste/garage-ui:latest
The endpoint overrides are needed because the container cannot reach 127.0.0.1 on the host.
Combining flags: Use --garage-toml for Garage connection values and --config for everything else (auth, CORS, logging, etc.):
./garage-ui --garage-toml /etc/garage.toml --config config.yaml
Precedence order (highest wins): built-in defaults < garage.toml < config.yaml < environment variables.
Configuration
Minimum required config:
server:
port: 8080
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
region: "garage"
Server bind host is configured by server.host (default: ::). IPv6 literals like :: and ::1 are supported.
server:
host: "::" # IPv6 wildcard (dual-stack-preferred)
port: 8080
If your environment needs explicit IPv4-only binding, set server.host: "0.0.0.0".
See config.example.yaml for all options including authentication, CORS, and logging.
Environment Variables
Override any config value with GARAGE_UI_ prefix:
GARAGE_UI_SERVER_PORT=8080
GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
Loading sensitive values from files (_FILE suffix)
For Docker and Kubernetes secrets, sensitive env vars can be read from files instead of plain values. Set {VAR}_FILE=/path/to/file and garage-ui uses the file's contents (trailing CR/LF trimmed) as the value. If both {VAR} and {VAR}_FILE are set, _FILE wins and a warning is logged. A missing or unreadable file stops startup.
Supported vars:
GARAGE_UI_GARAGE_ADMIN_TOKEN_FILEGARAGE_UI_AUTH_ADMIN_USERNAME_FILEGARAGE_UI_AUTH_ADMIN_PASSWORD_FILEGARAGE_UI_AUTH_JWT_PRIVATE_KEY_FILEGARAGE_UI_AUTH_OIDC_CLIENT_ID_FILEGARAGE_UI_AUTH_OIDC_CLIENT_SECRET_FILE
Example with Docker Compose secrets:
services:
garage-ui:
image: noooste/garage-ui:latest
environment:
GARAGE_UI_AUTH_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
secrets:
- admin_password
secrets:
admin_password:
file: ./admin_password.txt
This matches the convention used by the official Postgres and MySQL Docker images. Helm users don't need it; the chart already injects secrets via existingSecret references.
Garage Configuration
Garage UI requires these settings in your garage.toml:
# Admin API (required for Garage UI)
[admin]
api_bind_addr = "0.0.0.0:3903" # Default: 127.0.0.1:3903
admin_token = "your-admin-token" # Generate with: openssl rand -base64 32
# S3 API
[s3_api]
s3_region = "garage" # Default: "garage"
api_bind_addr = "[::]:3900" # Default: 127.0.0.1:3900
Important: The admin_token and s3_region in garage.toml must match your Garage UI config.yaml.
For complete Garage configuration, see the official documentation.
Development
Backend (Go 1.25+):
cd backend
go run main.go --config ../config.yaml
Frontend (Node.js 25+):
cd frontend
npm install
npm run dev
API docs: http://localhost:8080/api/v1/
Troubleshooting
Connection failed:
curl http://localhost:3903/status -H "Authorization: Bearer your-token"
Enable debug logs:
logging:
level: "debug"
format: "text" # or "json"
Roadmap
Roughly ordered by value. Open an issue to push something up the list.
- Fine-grained access control: OIDC teams with per-bucket-prefix permissions, see docs/access-control.md
- Object search: recursive substring search across a bucket
- Bucket quotas: size and object count limits from bucket settings
- Zero-config startup: run straight from
garage.toml, log in with the admin token - Broad compatibility: Garage v1 through latest, IPv6-only networks, secrets from files
- Inline object preview: images, video, PDF, and text without downloading (#60)
- Presigned share links: time-limited download links from the object browser
- Resumable uploads: multipart uploads that survive a dropped connection
- Visual layout editor: staged vs. applied diff before committing layout changes
- Admin audit log: who changed what, building on access control
- Table and detail polish: sortable columns, clearer node details (#36, #37)
License
MIT - see LICENSE
Links
Made with ❤️ in France 🇫🇷



