User's actual question: Backblaze B2's web console lets them browse a bucket as folders/files; Garage has no equivalent by default, so after switching an additional backup mirror from Backblaze-only to also target local Garage, they had no way to visually confirm data landed there the way they could on Backblaze. "S3 storage is opaque, you can't browse it" was true of Garage's *own* CLI, but wrong as a blanket statement — Backblaze's browsability comes from a client (its web console) layered on top of the same kind of object storage, and Garage has an actively-maintained equivalent (khairul169/garage-webui, 1.1k stars, "integrated objects/bucket browser") that gives the same experience against Garage's S3 API. services/garage-webui.sh (new): standard service-template Docker service. Requires an existing services/garage.sh install (checks for $DOCKER_DIR/garage/.env, errors with instructions if missing — this is a browser for an existing instance, not a replacement). Reaches Garage over host.docker.internal (both containers' ports are already published to the host — simpler and more robust than trying to join garage's own Compose-project-scoped default network by name). Has its own login (AUTH_USER_PASS, bcrypt via a throwaway `docker run --rm httpd:alpine htpasswd` — same $ -> $$ escaping services/wg-easy.sh already uses for its own bcrypt PASSWORD_HASH, verified here against a real docker compose config run: unescaped, Compose tries to interpolate $2y$05... as variable references and silently corrupts the value with a "not set" warning; escaped, it passes through intact with no warning), so it doesn't need Authelia gating by default. Prerequisite fix in services/garage.sh: its admin API (bucket/key management, object listing — the thing garage-webui talks to) has been running with zero authentication since this service was first built, because admin_token was never set in garage.toml. Nothing in this repo called that API before now, so it went unnoticed; adding a real consumer is what surfaced it. Fixed: generate admin_token (openssl rand -base64 32) alongside the existing rpc_secret, persist GARAGE_ADMIN_TOKEN/GARAGE_ADMIN_PORT to .env for garage-webui to read locally (never sent over SSH, unlike the S3 credentials backup.sh reads remotely). Update mode backfills admin_token into an existing garage.toml (+ restarts just the garage container to apply it) for anyone who installed before this change, same backfill-not-break approach as the GARAGE_S3_API_PORT fix from the previous commit. Verified: bash -n on both files; docker compose config against real Docker Compose for both the primary garage.toml/.env generation (with the new admin_token/GARAGE_ADMIN_PORT fields) and the new garage-webui docker-compose.yml; the bcrypt-escaping behavior specifically (proved via a minimal repro that unescaped $ corrupts the value with a warning, escaped does not); the admin_token/ GARAGE_ADMIN_PORT Update-mode backfill logic against old- and new-style .env/garage.toml fixtures, including idempotency (running it twice adds nothing a second time); and the credential-parsing regexes in garage-webui.sh against both a complete .env fixture and an old one missing the new fields (confirms the "run garage's Update first" error path actually triggers rather than proceeding with blanks). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
500 lines
23 KiB
Bash
500 lines
23 KiB
Bash
#!/bin/bash
|
|
# services/garage.sh — Garage: lightweight self-hosted S3-compatible object
|
|
# storage, single-node.
|
|
# Part of the modular post-install system (sourced by setup.sh).
|
|
#
|
|
# Can also be run standalone on any machine:
|
|
# sudo bash garage.sh
|
|
# (Docker must already be installed when run standalone)
|
|
#
|
|
# Why Garage and not MinIO: MinIO's open-source community edition is dead —
|
|
# console GUI stripped May 2025, Docker images stopped publishing October
|
|
# 2025, repo formally archived April 2026, with MinIO redirecting everyone
|
|
# to their paid AIStor product. Garage (Deuxfleurs) is the small-scale
|
|
# self-hoster's actively-maintained replacement: single Rust binary, built
|
|
# specifically for this "one lightweight node, S3-compatible" use case
|
|
# rather than large-scale clusters (that's SeaweedFS's niche instead).
|
|
#
|
|
# Typical use in this repo: a local S3-compatible target for services/
|
|
# backup.sh's offsite/additional-mirror Kopia sync-to s3, so a local mirror
|
|
# can reuse the exact same, already-proven sync-to s3 code path the
|
|
# Backblaze B2 mirror uses — instead of Kopia's separate, less-exercised
|
|
# SFTP backend.
|
|
|
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
|
# Detected when the script is executed directly rather than sourced by setup.sh.
|
|
# Sets up helpers and globals, then defers execution until after the function
|
|
# definition at the bottom of this file.
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
|
|
|
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
_COMMON="$_SELF_DIR/../lib/common.sh"
|
|
|
|
if [[ -f "$_COMMON" ]]; then
|
|
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
|
# shellcheck source=../lib/common.sh
|
|
source "$_COMMON"
|
|
else
|
|
# One-off copy — inline minimal stubs so the script works without the repo
|
|
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
|
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
|
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
|
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
|
|
|
require_docker() {
|
|
command -v docker &>/dev/null || {
|
|
log_error "Docker not found. Install it first:"
|
|
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
|
return 1
|
|
}
|
|
docker compose version &>/dev/null || {
|
|
log_error "Docker Compose plugin missing:"
|
|
log_error " sudo apt-get install -y docker-compose-plugin"
|
|
return 1
|
|
}
|
|
}
|
|
|
|
ensure_docker_dir_ownership() {
|
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
|
}
|
|
|
|
port_in_use() {
|
|
local _port="$1" _proto="${2:-tcp}"
|
|
local _flag="-tlnH"
|
|
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
|
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
|
}
|
|
|
|
find_free_port() {
|
|
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
|
while port_in_use "$_port" "$_proto"; do
|
|
_port=$((_port + 1))
|
|
done
|
|
eval "$_varname='$_port'"
|
|
}
|
|
|
|
prompt_text() {
|
|
local _q="$1" _def="$2" _var="$3" _r
|
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
|
read -r -p " $_q " _r
|
|
eval "$_var='${_r:-$_def}'"
|
|
}
|
|
|
|
prompt_yn() {
|
|
local _q="$1" _def="$2" _var="$3" _r
|
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
|
read -r -p " $_q " _r
|
|
eval "$_var='${_r:-$_def}'"
|
|
}
|
|
|
|
prompt_reinstall_mode() {
|
|
local _var="$1" _r
|
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='cancel'"; return; }
|
|
echo ""
|
|
echo " 1) Update — refresh the image only, leave config/data as-is"
|
|
echo " 2) Full reinstall — wipe and reconfigure from scratch"
|
|
echo " 3) Cancel — leave the existing install untouched"
|
|
read -r -p " Choice [3]: " _r
|
|
case "$_r" in
|
|
1) eval "$_var='update'" ;;
|
|
2) eval "$_var='fresh'" ;;
|
|
*) eval "$_var='cancel'" ;;
|
|
esac
|
|
}
|
|
|
|
write_readme() {
|
|
local _dir="$1"; shift
|
|
mkdir -p "$_dir"
|
|
cat > "$_dir/README.md"
|
|
}
|
|
fi
|
|
|
|
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
|
# ($HOME under sudo is /root, not the real user's home)
|
|
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
|
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
|
DRY_RUN="${DRY_RUN:-false}"
|
|
UNATTENDED="${UNATTENDED:-false}"
|
|
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
|
|
|
register_service() { :; } # no-op — no wizard to register into
|
|
_RUN_STANDALONE=1
|
|
fi
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
register_service garage utilities "Self-hosted S3-compatible object storage, single node (Garage — MinIO CE's actively-maintained replacement)" 3900
|
|
|
|
install_garage() {
|
|
require_docker || return 1
|
|
|
|
local DIR="$DOCKER_DIR/garage"
|
|
|
|
if [ "$DRY_RUN" = true ]; then
|
|
echo "[DRY-RUN] Would create $DIR with garage.toml + docker-compose.yml + .env"
|
|
echo "[DRY-RUN] Would auto-scan for free S3 API / RPC / admin ports"
|
|
echo "[DRY-RUN] Would generate an RPC secret and an admin API token, persisted (never regenerated on update)"
|
|
echo "[DRY-RUN] Would run the one-time cluster init: layout assign/apply, bucket create, key create"
|
|
echo "[DRY-RUN] Would print the endpoint/bucket/access-key/secret for Kopia's sync-to s3"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
|
|
local MODE=""
|
|
prompt_reinstall_mode MODE
|
|
case "$MODE" in
|
|
update)
|
|
log_info "Refreshing the Garage image only — existing data, config, and keys are left as-is."
|
|
local _GARAGE_NEEDS_RESTART_FOR_ADMIN_TOKEN=0
|
|
# .env fields added to this script after someone's initial
|
|
# install (GARAGE_S3_API_PORT, added so services/backup.sh
|
|
# can read it remotely) never get backfilled by Update on
|
|
# their own — Update deliberately never touches .env, and
|
|
# the only other path (fresh reinstall) is destructive to
|
|
# this service's actual bucket/key. Backfill missing keys
|
|
# here instead, so a schema addition never forces that
|
|
# tradeoff. Read the real port from docker-compose.yml
|
|
# itself (the heredoc that wrote it bakes the scanned port
|
|
# in as a literal) rather than re-scanning or re-prompting.
|
|
if [[ -f "$DIR/.env" ]] && ! grep -q '^GARAGE_S3_API_PORT=' "$DIR/.env"; then
|
|
local _existing_port
|
|
_existing_port="$(grep -oE '"[0-9]+:[0-9]+"' "$DIR/docker-compose.yml" 2>/dev/null | head -1 | tr -d '"' | cut -d: -f1)"
|
|
if [ -n "$_existing_port" ]; then
|
|
echo "GARAGE_S3_API_PORT=${_existing_port}" >> "$DIR/.env"
|
|
log_info "Backfilled GARAGE_S3_API_PORT=${_existing_port} into .env (added in a newer version of this script; services/backup.sh needs it to read this instance remotely)."
|
|
fi
|
|
fi
|
|
if [[ -f "$DIR/.env" ]] && ! grep -q '^GARAGE_ADMIN_PORT=' "$DIR/.env"; then
|
|
local _existing_admin_port
|
|
_existing_admin_port="$(grep -oE '"[0-9]+:[0-9]+"' "$DIR/docker-compose.yml" 2>/dev/null | sed -n 3p | tr -d '"' | cut -d: -f1)"
|
|
if [ -n "$_existing_admin_port" ]; then
|
|
echo "GARAGE_ADMIN_PORT=${_existing_admin_port}" >> "$DIR/.env"
|
|
log_info "Backfilled GARAGE_ADMIN_PORT=${_existing_admin_port} into .env."
|
|
fi
|
|
fi
|
|
# Older installs' garage.toml predates admin_token, meaning
|
|
# this instance's admin API (bucket/key management, object
|
|
# listing — published to the host, not just the internal
|
|
# Docker network) has been running with no authentication
|
|
# at all. Add one now rather than leaving it open — nothing
|
|
# in this repo talked to that API before services/
|
|
# garage-webui.sh, so there's no existing authenticated
|
|
# caller this could break.
|
|
if [[ -f "$DIR/garage.toml" ]] && ! grep -q '^admin_token' "$DIR/garage.toml"; then
|
|
local _new_admin_token
|
|
_new_admin_token="$(openssl rand -base64 32)"
|
|
printf 'admin_token = "%s"\n' "$_new_admin_token" >> "$DIR/garage.toml"
|
|
if grep -q '^GARAGE_ADMIN_TOKEN=' "$DIR/.env"; then
|
|
sed -i "s#^GARAGE_ADMIN_TOKEN=.*#GARAGE_ADMIN_TOKEN='${_new_admin_token}'#" "$DIR/.env"
|
|
else
|
|
echo "GARAGE_ADMIN_TOKEN='${_new_admin_token}'" >> "$DIR/.env"
|
|
fi
|
|
log_info "Garage's admin API had no auth token — added one and will restart to apply it."
|
|
_GARAGE_NEEDS_RESTART_FOR_ADMIN_TOKEN=1
|
|
fi
|
|
( cd "$DIR" && docker compose pull && docker compose up -d ) \
|
|
&& log_success "Garage image refreshed" \
|
|
|| log_warning "Refresh failed — check: docker compose -f $DIR/docker-compose.yml logs"
|
|
if [ "${_GARAGE_NEEDS_RESTART_FOR_ADMIN_TOKEN:-0}" = 1 ]; then
|
|
( cd "$DIR" && docker compose restart garage ) \
|
|
&& log_success "Admin API now requires GARAGE_ADMIN_TOKEN from .env." \
|
|
|| log_warning "Restart failed — apply the new admin_token manually: docker compose -f $DIR/docker-compose.yml restart garage"
|
|
fi
|
|
return 0
|
|
;;
|
|
cancel)
|
|
log_info "Leaving the existing install as-is."
|
|
return 0
|
|
;;
|
|
fresh) ;; # fall through to the full install flow below
|
|
esac
|
|
fi
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo " GARAGE — self-hosted S3-compatible object storage"
|
|
echo "═══════════════════════════════════════════════════════"
|
|
echo ""
|
|
echo " Single-node setup — good fit for a local Kopia backup mirror target"
|
|
echo " (services/backup.sh) so a local mirror can use Kopia's S3 backend"
|
|
echo " instead of its less-exercised SFTP backend."
|
|
echo ""
|
|
|
|
local S3_API_PORT="3900" RPC_PORT="3901" ADMIN_PORT="3903"
|
|
find_free_port S3_API_PORT "$S3_API_PORT"
|
|
find_free_port RPC_PORT "$RPC_PORT"
|
|
find_free_port ADMIN_PORT "$ADMIN_PORT"
|
|
|
|
# Suggested defaults are generated fresh at runtime, not fixed strings
|
|
# baked into this script — same reasoning as not hardcoding what a
|
|
# remote reader (services/backup.sh) should expect the name to be:
|
|
# this is the operator's name to pick, not this repo's.
|
|
local BUCKET_NAME="" KEY_NAME=""
|
|
local _default_bucket="kopia-$(date +%s)" _default_key="key-$(date +%s)"
|
|
prompt_text " Bucket name:" "$_default_bucket" BUCKET_NAME
|
|
BUCKET_NAME="${BUCKET_NAME:-$_default_bucket}"
|
|
prompt_text " Access key name:" "$_default_key" KEY_NAME
|
|
KEY_NAME="${KEY_NAME:-$_default_key}"
|
|
|
|
mkdir -p "$DIR"/{data,meta}
|
|
ensure_docker_dir_ownership "$DIR"
|
|
cd "$DIR" || return 1
|
|
|
|
# 32-byte hex secret used for inter-node RPC auth — a single-node
|
|
# cluster still requires one, since Garage's admin/layout CLI talks to
|
|
# the node over this same RPC channel.
|
|
local RPC_SECRET
|
|
RPC_SECRET="$(openssl rand -hex 32)"
|
|
|
|
# Without admin_token, Garage's admin API (bucket/key management,
|
|
# metrics, object listing) is open to anyone who can reach ADMIN_PORT
|
|
# — and that port is published to the host, not just the internal
|
|
# Docker network. Nothing in this repo talked to that API before, so
|
|
# this went unnoticed; services/garage-webui.sh is the first consumer,
|
|
# so it's the point this needed locking down.
|
|
local ADMIN_TOKEN
|
|
ADMIN_TOKEN="$(openssl rand -base64 32)"
|
|
|
|
cat > garage.toml << TOML
|
|
metadata_dir = "/meta"
|
|
data_dir = "/data"
|
|
db_engine = "lmdb"
|
|
|
|
replication_factor = 1
|
|
|
|
rpc_bind_addr = "[::]:${RPC_PORT}"
|
|
rpc_public_addr = "127.0.0.1:${RPC_PORT}"
|
|
rpc_secret = "${RPC_SECRET}"
|
|
|
|
[s3_api]
|
|
s3_region = "garage"
|
|
api_bind_addr = "[::]:${S3_API_PORT}"
|
|
root_domain = ".s3.garage.localhost"
|
|
|
|
[admin]
|
|
api_bind_addr = "[::]:${ADMIN_PORT}"
|
|
admin_token = "${ADMIN_TOKEN}"
|
|
TOML
|
|
|
|
cat > docker-compose.yml << COMPOSE
|
|
name: garage
|
|
|
|
services:
|
|
garage:
|
|
image: dxflrs/garage:v2.3.0
|
|
container_name: garage
|
|
hostname: garage
|
|
restart: unless-stopped
|
|
env_file: .env
|
|
volumes:
|
|
- ./garage.toml:/etc/garage.toml:ro
|
|
- ./data:/data
|
|
- ./meta:/meta
|
|
ports:
|
|
- "${S3_API_PORT}:${S3_API_PORT}"
|
|
- "${RPC_PORT}:${RPC_PORT}"
|
|
- "${ADMIN_PORT}:${ADMIN_PORT}"
|
|
COMPOSE
|
|
|
|
cat > .env << ENV
|
|
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
|
|
|
|
# Read directly (over SSH) by another box's services/backup.sh when adding
|
|
# this instance as a Kopia sync-to s3 mirror target — keep this key name
|
|
# stable, other scripts depend on it.
|
|
GARAGE_S3_API_PORT=${S3_API_PORT}
|
|
|
|
# Read by services/garage-webui.sh (same host only — never sent over SSH)
|
|
# to reach this instance's admin API for its bucket/object browser.
|
|
GARAGE_ADMIN_PORT=${ADMIN_PORT}
|
|
GARAGE_ADMIN_TOKEN='${ADMIN_TOKEN}'
|
|
ENV
|
|
chmod 600 .env
|
|
|
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
|
|
|
|
log_info "Starting Garage..."
|
|
docker compose up -d || { log_error "Failed to start Garage — check: docker compose logs"; return 1; }
|
|
|
|
# ── One-time cluster init ────────────────────────────────────────────────
|
|
# A brand-new Garage node has no layout yet — S3 API calls fail until
|
|
# one is assigned and applied, even for a single node. Only ever run
|
|
# once: re-running layout assign/apply against an already-initialized
|
|
# cluster is unnecessary and risks fighting the layout version counter.
|
|
log_info "Waiting for Garage to become ready..."
|
|
local _tries=0
|
|
until docker exec garage /garage status >/dev/null 2>&1 || [ "$_tries" -ge 30 ]; do
|
|
sleep 1; _tries=$((_tries + 1))
|
|
done
|
|
|
|
if ! docker exec garage /garage status >/dev/null 2>&1; then
|
|
log_error "Garage didn't come up in time — check: docker compose logs"
|
|
return 1
|
|
fi
|
|
|
|
local NODE_ID _status_out
|
|
# `garage status` output is a title line, then a column-header line
|
|
# ("ID Hostname Address ..."), then the actual node row — NR==3, not
|
|
# NR==2 (which would just grab the literal string "ID" off the header).
|
|
_status_out="$(docker exec garage /garage status 2>/dev/null)"
|
|
NODE_ID="$(echo "$_status_out" | awk 'NR==3{print $1}')"
|
|
if [ -z "$NODE_ID" ]; then
|
|
log_error "Couldn't read this node's ID from 'garage status' — check: docker exec garage /garage status"
|
|
return 1
|
|
fi
|
|
|
|
# A "Full reinstall" of this service deliberately does NOT wipe ./data
|
|
# or ./meta — that's real backup-mirror data (Kopia's sync-to s3
|
|
# target), and losing it silently on a reinstall would be far worse
|
|
# than the alternative. That means the on-disk metadata from an
|
|
# earlier install can already have a layout committed, and Garage
|
|
# requires each `layout apply --version N` to be exactly
|
|
# previous_version + 1 — reapplying a hardcoded "1" against a node
|
|
# that already has one fails with "Invalid new layout version".
|
|
# `garage status` marks a node with no committed role as
|
|
# "NO ROLE ASSIGNED"; only run the one-time layout assign/apply when
|
|
# that's actually the case; otherwise this node is already part of an
|
|
# applied layout (e.g. a fresh reinstall reusing prior ./meta) and
|
|
# touching layout state again is both unnecessary and unsafe.
|
|
if echo "$_status_out" | grep -q "NO ROLE ASSIGNED"; then
|
|
log_info "Assigning single-node cluster layout..."
|
|
docker exec garage /garage layout assign -z dc1 -c 1G "$NODE_ID" >/dev/null \
|
|
&& docker exec garage /garage layout apply --version 1 >/dev/null \
|
|
|| { log_error "Cluster layout init failed — check: docker exec garage /garage layout show"; return 1; }
|
|
else
|
|
log_info "Cluster layout already applied (existing ./meta from an earlier install) — skipping layout init."
|
|
|
|
# Same reasoning as above: existing ./meta can also mean an
|
|
# existing bucket + key from that earlier install are still
|
|
# sitting in Garage's storage, holding real data. .env was
|
|
# already overwritten by this same reinstall (the docker-compose.yml/
|
|
# .env heredocs above run unconditionally on the fresh path), so
|
|
# nothing on disk still points at them — creating a new
|
|
# bucket/key below would leave that old data orphaned rather than
|
|
# lost outright, but silently. Surface it and let the operator
|
|
# choose instead of doing that automatically.
|
|
local _existing_buckets
|
|
_existing_buckets="$(docker exec garage /garage bucket list 2>/dev/null | tail -n +2)"
|
|
if [ -n "$(echo "$_existing_buckets" | tr -d '[:space:]')" ]; then
|
|
echo ""
|
|
log_warning " Found existing bucket(s) already in this Garage instance's storage"
|
|
log_warning " (left over from before this reinstall — never auto-deleted):"
|
|
echo "$_existing_buckets" | sed 's/^/ /'
|
|
echo ""
|
|
log_warning " Continuing will create a NEW bucket '$BUCKET_NAME' / key '$KEY_NAME' and"
|
|
log_warning " point .env at those instead. Anything already in a bucket above stays in"
|
|
log_warning " Garage's storage but this install will no longer have a key that can read"
|
|
log_warning " it. Ctrl-C now if you meant to keep using one of the buckets above."
|
|
local _cont_new_bucket=""
|
|
prompt_yn " Create a new bucket/key anyway? (y/n):" "n" _cont_new_bucket
|
|
if [[ ! "$_cont_new_bucket" =~ ^[Yy]$ ]]; then
|
|
log_info "Stopping here — no new bucket/key created, existing ones left untouched."
|
|
log_info "(To reconnect this install to an existing bucket instead: docker exec garage"
|
|
log_info " /garage key create <name>, then /garage bucket allow --read --write --owner"
|
|
log_info " <bucket> --key <name>, then add GARAGE_BUCKET/GARAGE_ACCESS_KEY_ID/"
|
|
log_info " GARAGE_ACCESS_KEY_SECRET to $DIR/.env by hand.)"
|
|
return 1
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
log_info "Creating bucket and access key..."
|
|
docker exec garage /garage bucket create "$BUCKET_NAME" >/dev/null 2>&1
|
|
|
|
local _key_out
|
|
_key_out="$(docker exec garage /garage key create "$KEY_NAME" 2>&1)"
|
|
local ACCESS_KEY_ID ACCESS_KEY_SECRET
|
|
# Garage's real CLI output pads labels with extra spaces for column
|
|
# alignment (e.g. "Key ID: GKxxxx", not just "Key ID: GKxxxx")
|
|
# — a fixed ": " separator leaves that padding stuck to the value.
|
|
# ':[[:space:]]+' as a regex field separator consumes ALL of it,
|
|
# however many spaces there actually are. Confirmed live: the fixed
|
|
# single-space version left leading spaces baked into .env, which
|
|
# would have broken S3 auth (access keys have to match exactly).
|
|
ACCESS_KEY_ID="$(echo "$_key_out" | awk -F':[[:space:]]+' '/^Key ID:/{print $2}')"
|
|
ACCESS_KEY_SECRET="$(echo "$_key_out" | awk -F':[[:space:]]+' '/^Secret key:/{print $2}')"
|
|
|
|
if [ -z "$ACCESS_KEY_ID" ] || [ -z "$ACCESS_KEY_SECRET" ]; then
|
|
log_error "Couldn't parse the access key from 'garage key create' output:"
|
|
echo "$_key_out"
|
|
return 1
|
|
fi
|
|
|
|
docker exec garage /garage bucket allow --read --write --owner "$BUCKET_NAME" --key "$KEY_NAME" >/dev/null
|
|
|
|
# Persisted so a later "Update" run never re-runs any of the above —
|
|
# this presence check is exactly what gates that.
|
|
{
|
|
echo ""
|
|
echo "# Written once, at first install — never touched again by an Update run."
|
|
echo "GARAGE_BUCKET='$BUCKET_NAME'"
|
|
echo "GARAGE_ACCESS_KEY_ID='$ACCESS_KEY_ID'"
|
|
echo "GARAGE_ACCESS_KEY_SECRET='$ACCESS_KEY_SECRET'"
|
|
} >> .env
|
|
chmod 600 .env
|
|
|
|
log_success "Garage ready — bucket '$BUCKET_NAME', key '$KEY_NAME'"
|
|
|
|
local PUBLIC_IP
|
|
PUBLIC_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
|
[ -z "$PUBLIC_IP" ] && PUBLIC_IP="<this-box's-IP>"
|
|
|
|
if command -v ufw &>/dev/null; then
|
|
ufw allow "${S3_API_PORT}/tcp" comment "Garage S3 API"
|
|
fi
|
|
|
|
write_readme "$DIR" << MD
|
|
# Garage
|
|
|
|
Self-hosted, S3-compatible object storage — single node. MinIO's open-source
|
|
community edition is dead (archived April 2026); Garage is the actively
|
|
maintained small-scale replacement.
|
|
|
|
## Credentials (see \`.env\` for the actual values)
|
|
- Bucket: \`$BUCKET_NAME\`
|
|
- Access Key ID: \`GARAGE_ACCESS_KEY_ID\` in \`.env\`
|
|
- Secret Access Key: \`GARAGE_ACCESS_KEY_SECRET\` in \`.env\`
|
|
- Endpoint: \`${PUBLIC_IP}:${S3_API_PORT}\` (plain HTTP — no TLS on this node)
|
|
|
|
## Using this as a Kopia backup mirror target
|
|
On the box running \`services/backup.sh\`, when adding an additional mirror,
|
|
this is an \`s3\`-type destination rather than \`sftp\` — point Kopia at it with:
|
|
\`\`\`bash
|
|
kopia repository sync-to s3 \\
|
|
--bucket=$BUCKET_NAME \\
|
|
--endpoint=${PUBLIC_IP}:${S3_API_PORT} \\
|
|
--access-key=<GARAGE_ACCESS_KEY_ID from .env> \\
|
|
--secret-access-key=<GARAGE_ACCESS_KEY_SECRET from .env> \\
|
|
--disable-tls
|
|
\`\`\`
|
|
\`--disable-tls\` matters — this node serves plain HTTP, not HTTPS, unlike
|
|
Backblaze B2's endpoint.
|
|
|
|
## Adding another bucket or key later
|
|
\`\`\`bash
|
|
docker exec garage /garage bucket create another-bucket
|
|
docker exec garage /garage key create another-key
|
|
docker exec garage /garage bucket allow --read --write --owner another-bucket --key another-key
|
|
\`\`\`
|
|
|
|
## Manage
|
|
\`\`\`bash
|
|
cd $DIR
|
|
docker compose up -d # start
|
|
docker compose down # stop
|
|
docker compose logs -f # logs
|
|
docker exec garage /garage status # cluster/node status
|
|
\`\`\`
|
|
MD
|
|
|
|
echo ""
|
|
echo " S3 endpoint: ${PUBLIC_IP}:${S3_API_PORT} (plain HTTP, no TLS)"
|
|
echo " Bucket: $BUCKET_NAME"
|
|
echo " Credentials: see $DIR/.env"
|
|
echo ""
|
|
}
|
|
|
|
# Run immediately when executed directly (deferred until after function definition)
|
|
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_garage
|