initial: unraid-1 bootstrap (garage + basement + watchtower + host-agent)

This commit is contained in:
2026-05-18 21:48:35 -07:00
commit 618ef68534
7 changed files with 422 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0"?>
<!--
Unraid Community Apps template for basement-ui (Garage admin UI).
Source of truth: pq/scripts on Gitea (https://git.docker.pq.io/pq/scripts).
Served via gitea raw URL: https://git.docker.pq.io/pq/scripts/raw/branch/main/unraid-1/basement.template.xml
This file is NOT installed directly. install.sh fetches it, substitutes the
__GARAGE_ADMIN_TOKEN__ placeholder with the same secret used by the garage
container, and writes the result to /boot/config/plugins/dockerMan/templates-user/my-basement.xml.
Caddy on classe reverse-proxies https://basement.pq.io to this container's
port 8080. DNS for basement.pq.io and the Caddy vhost are managed on classe,
not here.
-->
<Container version="2">
<Name>basement</Name>
<Repository>ghcr.io/mattjackson/basement-ui:latest</Repository>
<Registry>https://github.com/mattjackson/basement-ui</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support/>
<Project/>
<Overview>basement-ui: admin UI for the local garage S3 container. Caddy on classe terminates TLS for basement.pq.io and reverse-proxies to this container's port 8080.</Overview>
<Category>Network:Web Cloud:</Category>
<WebUI>http://[IP]:[PORT:8080]/</WebUI>
<TemplateURL/>
<Icon>https://garagehq.deuxfleurs.fr/img/logo.svg</Icon>
<ExtraParams>--restart=unless-stopped --add-host=host.docker.internal:host-gateway --label com.centurylinklabs.watchtower.enable=true</ExtraParams>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Description>
Admin UI for the garage container running on this host.
Reaches garage's admin API at http://host.docker.internal:3903 (the host-gateway alias resolves to unraid-1's docker bridge IP, where the garage container publishes port 3903). The admin token below is shared with the garage container — install.sh pre-fills both.
Publicly served at https://basement.pq.io via Caddy on classe (reverse proxy → unraid-1:8080).
</Description>
<Config Name="WebUI" Target="8080" Default="8080" Mode="tcp" Description="HTTP port. Caddy on classe proxies basement.pq.io here." Type="Port" Display="always" Required="true" Mask="false">8080</Config>
<Config Name="GARAGE_ADMIN_URL" Target="GARAGE_ADMIN_URL" Default="http://host.docker.internal:3903" Mode="" Description="Garage admin API base URL (reaches the sibling garage container via the host-gateway alias)." Type="Variable" Display="always" Required="true" Mask="false">http://host.docker.internal:3903</Config>
<Config Name="GARAGE_ADMIN_TOKEN" Target="GARAGE_ADMIN_TOKEN" Default="" Mode="" Description="Bearer token for Garage admin API. Pre-filled by install.sh (same value as the garage container's GARAGE_ADMIN_TOKEN)." Type="Variable" Display="always" Required="true" Mask="true">__GARAGE_ADMIN_TOKEN__</Config>
</Container>
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0"?>
<!--
Unraid Community Apps template for Garage (single-node S3 server).
Source of truth: pq/scripts on Gitea (https://git.docker.pq.io/pq/scripts).
Served via gitea raw URL: https://git.docker.pq.io/pq/scripts/raw/branch/main/unraid-1/garage.template.xml
This file is NOT installed directly. install.sh fetches this, substitutes
the three __PLACEHOLDER__ secrets it just generated, and writes the result
to /boot/config/plugins/dockerMan/templates-user/my-garage.xml.
Unraid then surfaces it in the "Template" dropdown on the Add Container page.
-->
<Container version="2">
<Name>garage</Name>
<Repository>dxflrs/garage:v1.0.1</Repository>
<Registry>https://hub.docker.com/r/dxflrs/garage</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support/>
<Project>https://garagehq.deuxfleurs.fr/</Project>
<Overview>Single-node Garage S3 server. Caddy on classe terminates TLS for s3.pq.io and reverse-proxies to this container's port 3900.</Overview>
<Category>Backup: Cloud: Network:Other</Category>
<WebUI/>
<TemplateURL/>
<Icon>https://garagehq.deuxfleurs.fr/img/logo.svg</Icon>
<ExtraParams>--restart=unless-stopped --label com.centurylinklabs.watchtower.enable=true</ExtraParams>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Description>
Single-node Garage S3 (lmdb engine, replication_factor=1). Path-style endpoint: https://s3.pq.io/&lt;bucket&gt;/&lt;key&gt;.
After the container starts the FIRST time only, initialise the cluster layout from the unraid console:
docker exec garage /garage status
# copy the node-id from the output
docker exec garage /garage layout assign -z dc1 -c 1T &lt;node-id&gt;
docker exec garage /garage layout apply --version 1
Then create a bucket and an access key:
docker exec garage /garage bucket create my-bucket
docker exec garage /garage key create my-key
docker exec garage /garage bucket allow --read --write --owner my-bucket --key my-key
docker exec garage /garage key info --show-secret my-key
</Description>
<Config Name="S3 API" Target="3900" Default="3900" Mode="tcp" Description="S3-compatible API. Caddy on classe proxies s3.pq.io here." Type="Port" Display="always" Required="true" Mask="false">3900</Config>
<Config Name="S3 Static Web" Target="3902" Default="3902" Mode="tcp" Description="Static site hosting (optional, not currently exposed via Caddy)." Type="Port" Display="advanced" Required="false" Mask="false">3902</Config>
<Config Name="Admin / Metrics" Target="3903" Default="3903" Mode="tcp" Description="Admin API + Prometheus metrics. Caddy proxies /-/health to this port." Type="Port" Display="always" Required="true" Mask="false">3903</Config>
<Config Name="Metadata (SSD)" Target="/var/lib/garage/meta" Default="/mnt/user/appdata/garage/meta" Mode="rw" Description="LMDB metadata. MUST live on SSD (appdata is cache-only by default — good)." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/garage/meta</Config>
<Config Name="Data (parity)" Target="/var/lib/garage/data" Default="/mnt/user/s3/data" Mode="rw" Description="Object data chunks. Use a parity-protected share with cache:yes for SSD-fast writes + array durability." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/s3/data</Config>
<Config Name="garage.toml" Target="/etc/garage.toml" Default="/mnt/user/appdata/garage/garage.toml" Mode="ro" Description="Garage config file. install.sh placed this for you." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/garage/garage.toml</Config>
<Config Name="GARAGE_RPC_SECRET" Target="GARAGE_RPC_SECRET" Default="" Mode="" Description="32-byte hex inter-node secret. Pre-filled by install.sh." Type="Variable" Display="always" Required="true" Mask="true">__GARAGE_RPC_SECRET__</Config>
<Config Name="GARAGE_ADMIN_TOKEN" Target="GARAGE_ADMIN_TOKEN" Default="" Mode="" Description="32-byte hex bearer token for admin API. Pre-filled by install.sh." Type="Variable" Display="always" Required="true" Mask="true">__GARAGE_ADMIN_TOKEN__</Config>
<Config Name="GARAGE_METRICS_TOKEN" Target="GARAGE_METRICS_TOKEN" Default="" Mode="" Description="32-byte hex bearer token for Prometheus /metrics. Pre-filled by install.sh." Type="Variable" Display="always" Required="true" Mask="true">__GARAGE_METRICS_TOKEN__</Config>
</Container>
+35
View File
@@ -0,0 +1,35 @@
# Garage v1 single-node config. Lives on unraid-1.
# Secrets (rpc_secret, admin_token, metrics_token) come from env vars
# defined in .env — NOT in this file. See Garage docs:
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
# lmdb is the v1 default; sled was deprecated. lmdb is more crash-resilient
# and the recommended engine for new deployments.
db_engine = "lmdb"
# Single-node: every object stored once. No HA.
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
# rpc_secret -> GARAGE_RPC_SECRET (env)
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
# Path-style: https://s3.pq.io/<bucket>/<key>
# Virtual-host style: https://<bucket>.s3.pq.io/<key> (requires DNS wildcard)
root_domain = ".s3.pq.io"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.s3.pq.io"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
# admin_token -> GARAGE_ADMIN_TOKEN (env)
# metrics_token -> GARAGE_METRICS_TOKEN (env) — used by Prometheus scrape
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0"?>
<!--
Unraid Community Apps template for host-agent (per-host metrics + fan ctrl).
Source of truth: pq/scripts on Gitea (https://git.docker.pq.io/pq/scripts).
Served via gitea raw URL: https://git.docker.pq.io/pq/scripts/raw/branch/main/unraid-1/host-agent.template.xml
This file is NOT installed directly. install.sh fetches it and writes it to
/boot/config/plugins/dockerMan/templates-user/my-host-agent.xml. No secrets
to substitute.
host-agent is one container bundling: fan-controller (Dell PowerEdge only —
self-disables on consumer hardware like unraid), node_exporter, cadvisor,
ipmi_exporter, smartctl_exporter, nvidia_gpu_exporter, vmagent (remote_write
to Prometheus). Each sub-service probes its prerequisites on start and
sleep-infinity's if absent, so the same image runs on a Dell R730xd and an
unraid box on consumer hardware.
On unraid-1: fan-controller + ipmi_exporter + nvidia_gpu_exporter self-
disable (no Dell BMC, no nvidia GPU). node + cadvisor + smartctl + vmagent
run, ship metrics to monitoring stack on classe.
-->
<Container version="2">
<Name>host-agent</Name>
<Repository>ghcr.io/mattjackson/host-agent:latest</Repository>
<Registry>https://github.com/mattjackson/host-agent</Registry>
<Network>host</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>true</Privileged>
<Support/>
<Project>https://github.com/mattjackson/host-agent</Project>
<Overview>Per-host Prometheus exporters (node, cadvisor, ipmi, smartctl, nvidia-gpu) plus Dell PowerEdge fan controller. Each sub-service self-disables if hardware prerequisites are missing — same image runs on Dell servers and consumer Unraid hardware.</Overview>
<Category>Tools: Status: Productivity:</Category>
<WebUI/>
<TemplateURL/>
<Icon>https://raw.githubusercontent.com/prometheus/prometheus/main/web/ui/static/img/prometheus_logo_grey.svg</Icon>
<ExtraParams>--restart=unless-stopped --cgroupns=host -v /:/host:ro,rslave --label com.centurylinklabs.watchtower.enable=true</ExtraParams>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Description>
host-agent: single container, s6-supervised, bundles per-host metrics + Dell fan control.
On unraid-1 (consumer hardware): fan-controller + ipmi_exporter + nvidia_gpu_exporter self-disable. node_exporter (CPU/mem/disk/net), cadvisor (per-container), smartctl_exporter (drive SMART), and vmagent (remote_write) run and push to your Prometheus.
The root mount (/:/host:ro,rslave) is wired via ExtraParams since Unraid's Path config doesn't expose mount propagation flags; rslave is required for node_exporter's rootfs view to track later host mounts.
</Description>
<Config Name="/sys" Target="/sys" Default="/sys" Mode="ro" Description="Required: cadvisor reads /sys/fs/cgroup directly (its own cgroup ns is just /, even with --privileged)." Type="Path" Display="advanced" Required="true" Mask="false">/sys</Config>
<Config Name="docker.sock" Target="/run/docker.sock" Default="/var/run/docker.sock" Mode="rw" Description="Required: cadvisor opens /run/docker.sock at this hardcoded path for container metadata." Type="Path" Display="advanced" Required="true" Mask="false">/var/run/docker.sock</Config>
<Config Name="containerd" Target="/run/containerd" Default="/run/containerd" Mode="ro" Description="Required: cadvisor opens /run/containerd/containerd.sock at this hardcoded path." Type="Path" Display="advanced" Required="true" Mask="false">/run/containerd</Config>
<Config Name="/var/lib/docker" Target="/var/lib/docker" Default="/var/lib/docker" Mode="ro" Description="Required: cadvisor reads container metadata from here." Type="Path" Display="advanced" Required="true" Mask="false">/var/lib/docker</Config>
<Config Name="/dev" Target="/dev" Default="/dev" Mode="rw" Description="Required: ipmitool + smartctl hardcode /dev/* paths and Docker doesn't auto-import host devices." Type="Path" Display="advanced" Required="true" Mask="false">/dev</Config>
<Config Name="host-agent state" Target="/var/lib/host-agent" Default="/mnt/user/appdata/host-agent" Mode="rw" Description="Persistent state: fan-controller EWMA baseline + vmagent WAL." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/host-agent</Config>
<Config Name="PROMETHEUS_REMOTE_WRITE_URL" Target="PROMETHEUS_REMOTE_WRITE_URL" Default="http://docker-1.internal.pq.io:9090/api/v1/write" Mode="" Description="Where vmagent pushes metrics. Defaults to monitoring stack on classe (docker-1). Prometheus must be started with --web.enable-remote-write-receiver (already is)." Type="Variable" Display="always" Required="true" Mask="false">http://docker-1.internal.pq.io:9090/api/v1/write</Config>
<Config Name="PROMETHEUS_REMOTE_WRITE_BEARER_TOKEN" Target="PROMETHEUS_REMOTE_WRITE_BEARER_TOKEN" Default="" Mode="" Description="Optional bearer token for remote_write auth (LAN-internal Prometheus typically needs none)." Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="NVIDIA_VISIBLE_DEVICES" Target="NVIDIA_VISIBLE_DEVICES" Default="all" Mode="" Description="Harmless if no NVIDIA driver — nvidia_gpu_exporter sub-service self-disables." Type="Variable" Display="advanced" Required="false" Mask="false">all</Config>
<Config Name="NVIDIA_DRIVER_CAPABILITIES" Target="NVIDIA_DRIVER_CAPABILITIES" Default="utility" Mode="" Description="Harmless if no NVIDIA driver." Type="Variable" Display="advanced" Required="false" Mask="false">utility</Config>
</Container>
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
# unraid-1 installer — sets up the unraid box with the standard fleet of
# containers (garage S3, basement UI, watchtower, host-agent).
#
# Usage on the unraid console:
# bash <(curl -sL https://git.docker.pq.io/pq/scripts/raw/branch/main/unraid-1/install.sh)
#
# What it does:
# 1. Validates this is an unraid host with the array started.
# 2. Confirms the "s3" share exists (parity-protected; you create it).
# 3. mkdir's /mnt/user/appdata/garage/{meta,} and /mnt/user/s3/data.
# 4. Generates 3 garage secrets (rpc, admin, metrics) → secrets.env (preserved).
# 5. Fetches garage.toml → /mnt/user/appdata/garage/garage.toml.
# 6. Fetches 4 unraid container templates (garage / basement / watchtower /
# host-agent), substitutes secrets where needed, writes them to
# /boot/config/plugins/dockerMan/templates-user/my-*.xml.
#
# After: Unraid UI → Docker → Add Container → Template dropdown → Apply each.
#
# Idempotent: re-running keeps existing secrets and garage.toml. FORCE=1
# rotates the garage secrets (you'll then need to re-Apply garage + basement).
set -euo pipefail
BASE="${UNRAID1_INSTALL_BASE:-https://git.docker.pq.io/pq/scripts/raw/branch/main/unraid-1}"
APPDATA=/mnt/user/appdata/garage
DATADIR=/mnt/user/s3/data
TEMPLATE_DIR=/boot/config/plugins/dockerMan/templates-user
TOML_DEST="$APPDATA/garage.toml"
SECRETS="$APPDATA/secrets.env"
say() { printf "install.sh: %s\n" "$*"; }
die() { printf "install.sh: %s\n" "$*" >&2; exit 1; }
# ── Sanity ──
[ -d /boot/config/plugins/dockerMan ] || die "no /boot/config/plugins/dockerMan — not an unraid host?"
[ -d /mnt/user ] || die "/mnt/user missing — is the array started?"
[ -d /mnt/user/s3 ] || die "share /mnt/user/s3 missing. Create it in unraid UI first (Shares → Add Share → 's3', cache:yes recommended), then re-run."
mkdir -p "$APPDATA/meta" "$DATADIR" "$TEMPLATE_DIR"
# ── garage.toml ──
if [ -f "$TOML_DEST" ] && [ "${FORCE:-}" != "1" ]; then
say "$TOML_DEST exists — keeping (FORCE=1 to overwrite)"
else
say "fetching $BASE/garage.toml"
curl -fsSL "$BASE/garage.toml" -o "$TOML_DEST"
fi
# ── Secrets ──
if [ -f "$SECRETS" ] && [ "${FORCE:-}" != "1" ]; then
say "reusing existing secrets at $SECRETS (FORCE=1 to rotate)"
# shellcheck disable=SC1090
. "$SECRETS"
else
GARAGE_RPC_SECRET=$(openssl rand -hex 32)
GARAGE_ADMIN_TOKEN=$(openssl rand -hex 32)
GARAGE_METRICS_TOKEN=$(openssl rand -hex 32)
umask 077
cat > "$SECRETS" <<EOF
GARAGE_RPC_SECRET=$GARAGE_RPC_SECRET
GARAGE_ADMIN_TOKEN=$GARAGE_ADMIN_TOKEN
GARAGE_METRICS_TOKEN=$GARAGE_METRICS_TOKEN
EOF
say "generated secrets → $SECRETS"
fi
# ── Templates ──
# Fetch each, optionally sed-substitute __PLACEHOLDER__ secrets, write to
# templates-user/. Secrets are hex chars only — safe in sed delimiters.
fetch_template() {
local src=$1 dest=$2 tmp
shift 2
tmp=$(mktemp)
say "fetching $src"
curl -fsSL "$src" -o "$tmp"
if [ "$#" -gt 0 ]; then
sed "$@" "$tmp" > "$dest"
else
cp "$tmp" "$dest"
fi
rm -f "$tmp"
say "wrote $dest"
}
fetch_template \
"$BASE/garage.template.xml" \
"$TEMPLATE_DIR/my-garage.xml" \
-e "s|__GARAGE_RPC_SECRET__|$GARAGE_RPC_SECRET|" \
-e "s|__GARAGE_ADMIN_TOKEN__|$GARAGE_ADMIN_TOKEN|" \
-e "s|__GARAGE_METRICS_TOKEN__|$GARAGE_METRICS_TOKEN|"
fetch_template \
"$BASE/basement.template.xml" \
"$TEMPLATE_DIR/my-basement.xml" \
-e "s|__GARAGE_ADMIN_TOKEN__|$GARAGE_ADMIN_TOKEN|"
fetch_template \
"$BASE/watchtower.template.xml" \
"$TEMPLATE_DIR/my-watchtower.xml"
fetch_template \
"$BASE/host-agent.template.xml" \
"$TEMPLATE_DIR/my-host-agent.xml"
cat <<'EOF'
────────────────────────────────────────
Done. Apply each template in Unraid UI → Docker → Add Container → Template dropdown:
1. "my-garage" → Apply. Initialise cluster after first start:
docker exec garage /garage status # copy node id
docker exec garage /garage layout assign -z dc1 -c 1T <node-id>
docker exec garage /garage layout apply --version 1
Smoke test from your laptop:
aws --endpoint-url https://s3.pq.io --region garage s3 ls
2. "my-basement" → Apply. Reachable at https://basement.pq.io.
3. "my-watchtower" → Apply. Auto-updates the labelled containers above + below.
4. "my-host-agent" → Apply. Pushes node/cadvisor/smartctl metrics to your
Prometheus (defaults to docker-1.internal.pq.io:9090).
────────────────────────────────────────
EOF
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0"?>
<!--
Unraid Community Apps template for Watchtower (auto-image-updater).
Source of truth: pq/scripts on Gitea (https://git.docker.pq.io/pq/scripts).
Served via gitea raw URL: https://git.docker.pq.io/pq/scripts/raw/branch/main/unraid-1/watchtower.template.xml
This file is NOT installed directly. install.sh fetches it and writes it to
/boot/config/plugins/dockerMan/templates-user/my-watchtower.xml. No secrets
to substitute — Watchtower needs none for these (public) images.
Mirrors the watchtower service on classe (docker-server/docker-compose.yml):
same nickfedor/watchtower:1.17.0 image, same fleet-wide 300s poll interval,
same opt-in via label. The other unraid-1 templates (garage, basement, host-
agent) all carry the enable label, so this container will pick them up.
-->
<Container version="2">
<Name>watchtower</Name>
<Repository>nickfedor/watchtower:1.17.0</Repository>
<Registry>https://hub.docker.com/r/nickfedor/watchtower</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support/>
<Project>https://github.com/nicholas-fedor/watchtower</Project>
<Overview>Auto-updates running containers when newer images are published. Opt-in: only watches containers labelled com.centurylinklabs.watchtower.enable=true (garage + basement both carry this).</Overview>
<Category>Tools: Utilities:</Category>
<WebUI/>
<TemplateURL/>
<Icon>https://raw.githubusercontent.com/containrrr/watchtower/main/logo.png</Icon>
<ExtraParams>--restart=unless-stopped</ExtraParams>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Description>
Watches all containers with the label `com.centurylinklabs.watchtower.enable=true` and pulls + restarts them when a newer image tag is published.
Configured identically to the watchtower service on classe (docker-server/docker-compose.yml) so unraid + classe behave the same way:
- polls every 300s (5min — fleet-wide default; gentle on ghcr/dockerhub rate limits)
- cleans up the old image after replacing a container
- opt-in only (WATCHTOWER_LABEL_ENABLE=true) — does NOT touch unlabelled containers
Does not watch itself: a new Watchtower version requires re-Applying this template (or manually pulling).
</Description>
<Config Name="docker.sock" Target="/var/run/docker.sock" Default="/var/run/docker.sock" Mode="rw" Description="Required: lets Watchtower talk to the Docker daemon to inspect and recreate containers." Type="Path" Display="advanced" Required="true" Mask="false">/var/run/docker.sock</Config>
<Config Name="WATCHTOWER_POLL_INTERVAL" Target="WATCHTOWER_POLL_INTERVAL" Default="300" Mode="" Description="Seconds between checks for new images. 300s (5min) is the fleet-wide default — gentle on ghcr/dockerhub rate limits, still effectively instant for new pushes." Type="Variable" Display="always" Required="true" Mask="false">300</Config>
<Config Name="WATCHTOWER_CLEANUP" Target="WATCHTOWER_CLEANUP" Default="true" Mode="" Description="Delete the previous image after a successful update (frees disk)." Type="Variable" Display="always" Required="true" Mask="false">true</Config>
<Config Name="WATCHTOWER_LABEL_ENABLE" Target="WATCHTOWER_LABEL_ENABLE" Default="true" Mode="" Description="Only watch containers with com.centurylinklabs.watchtower.enable=true. Leave true." Type="Variable" Display="always" Required="true" Mask="false">true</Config>
</Container>