From 1842bd332c751bedd1601bd25fb9d61d7d8ccb4b Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Thu, 16 Jul 2026 22:39:45 -0700
Subject: [PATCH] fix(selfhost): templatize the docker-prune systemd unit for
multi-host use
Renames loopover-docker-prune.{service,timer}.example to the
%i-instantiated loopover-docker-prune@.{service,timer}.example, using
%I for WorkingDirectory/ExecStart so one pair of unit files can be
enabled per self-host instance path (systemd-escape -p) instead of
requiring a hand-copied, uniquely-named unit per operator.
Closes #4894
---
.../content/docs/self-hosting-operations.mdx | 13 +++++--
packages/loopover-miner/DEPLOYMENT.md | 2 +-
systemd/loopover-docker-prune.service.example | 27 --------------
systemd/loopover-docker-prune.timer.example | 13 -------
.../loopover-docker-prune@.service.example | 37 +++++++++++++++++++
systemd/loopover-docker-prune@.timer.example | 14 +++++++
6 files changed, 61 insertions(+), 45 deletions(-)
delete mode 100644 systemd/loopover-docker-prune.service.example
delete mode 100644 systemd/loopover-docker-prune.timer.example
create mode 100644 systemd/loopover-docker-prune@.service.example
create mode 100644 systemd/loopover-docker-prune@.timer.example
diff --git a/apps/loopover-ui/content/docs/self-hosting-operations.mdx b/apps/loopover-ui/content/docs/self-hosting-operations.mdx
index 86a2e3c1c8..02f8c3e75b 100644
--- a/apps/loopover-ui/content/docs/self-hosting-operations.mdx
+++ b/apps/loopover-ui/content/docs/self-hosting-operations.mdx
@@ -444,13 +444,18 @@ less than 7 days is left alone, so a recent deploy is never at risk):
+This is a templated (instantiated) unit — the instance name is your loopover install path,
+systemd-escaped, so the same pair of installed unit files can prune Docker resources for more
+than one loopover install on a host, or be copied verbatim across a fleet, without editing a
+per-install path by hand.
+
Run it manually at any time with `docker system df` before and after to see what
it reclaimed: `sh scripts/selfhost-docker-prune.sh`.
diff --git a/packages/loopover-miner/DEPLOYMENT.md b/packages/loopover-miner/DEPLOYMENT.md
index 40e2ffd6d8..8ef06b5622 100644
--- a/packages/loopover-miner/DEPLOYMENT.md
+++ b/packages/loopover-miner/DEPLOYMENT.md
@@ -170,7 +170,7 @@ sudo systemctl daemon-reload
sudo systemctl enable --now loopover-miner.service
```
-Because `loop` is a **long-running daemon that schedules its own cycles**, it is a persistent `Type=simple` service (with `Restart=on-failure`) — **not** a oneshot unit driven by a `.timer`, unlike the periodic `loopover-docker-prune.*.example` hygiene job in [`systemd/`](../../systemd/). Keep `GITHUB_TOKEN` (and any coding-agent credentials) in a root-owned `0600` `EnvironmentFile`, never in the unit file. Follow the loop with `journalctl -u loopover-miner -f`; `systemctl stop` sends SIGTERM, which the loop handles cleanly at its next kill-switch check.
+Because `loop` is a **long-running daemon that schedules its own cycles**, it is a persistent `Type=simple` service (with `Restart=on-failure`) — **not** a oneshot unit driven by a `.timer`, unlike the periodic `loopover-docker-prune@.*.example` hygiene job in [`systemd/`](../../systemd/). Keep `GITHUB_TOKEN` (and any coding-agent credentials) in a root-owned `0600` `EnvironmentFile`, never in the unit file. Follow the loop with `journalctl -u loopover-miner -f`; `systemctl stop` sends SIGTERM, which the loop handles cleanly at its next kill-switch check.
Want the dashboard too? [`systemd/loopover-miner-ui.service.example`](../../systemd/loopover-miner-ui.service.example) is a companion unit that serves `apps/loopover-miner-ui` persistently over the same local state — see that app's [README](../../apps/loopover-miner-ui/README.md#running-as-a-persistent-service).
diff --git a/systemd/loopover-docker-prune.service.example b/systemd/loopover-docker-prune.service.example
deleted file mode 100644
index 50b6a23891..0000000000
--- a/systemd/loopover-docker-prune.service.example
+++ /dev/null
@@ -1,27 +0,0 @@
-# Runs scripts/selfhost-docker-prune.sh on a schedule (paired with loopover-docker-prune.timer.example) to
-# reclaim unused Docker images and build cache before disk fills up on a long-running self-host deployment.
-#
-# Install (adjust the path below to wherever you cloned/deployed loopover):
-# sudo cp systemd/loopover-docker-prune.service.example /etc/systemd/system/loopover-docker-prune.service
-# sudo cp systemd/loopover-docker-prune.timer.example /etc/systemd/system/loopover-docker-prune.timer
-# sudo $EDITOR /etc/systemd/system/loopover-docker-prune.service # fix WorkingDirectory / ExecStart path
-# sudo systemctl daemon-reload
-# sudo systemctl enable --now loopover-docker-prune.timer
-#
-# This is a HOST-level unit, not a Docker Compose service: reclaiming images/build-cache needs real Docker
-# daemon access, which no container in docker-compose.yml is granted (see that file's docker-proxy and
-# runner service comments for why raw /var/run/docker.sock exposure into a container is avoided).
-
-[Unit]
-Description=LoopOver self-host Docker resource hygiene (image + build-cache prune)
-After=docker.service
-Requires=docker.service
-
-[Service]
-Type=oneshot
-# REQUIRED: point this at wherever you cloned loopover.
-WorkingDirectory=/opt/loopover
-ExecStart=/bin/sh /opt/loopover/scripts/selfhost-docker-prune.sh
-# Optional: override the default 7-day (168h) safety window before an unused image/build-cache entry is
-# eligible for removal.
-# Environment=LOOPOVER_DOCKER_PRUNE_RETAIN_HOURS=168
diff --git a/systemd/loopover-docker-prune.timer.example b/systemd/loopover-docker-prune.timer.example
deleted file mode 100644
index f9f959b0cc..0000000000
--- a/systemd/loopover-docker-prune.timer.example
+++ /dev/null
@@ -1,13 +0,0 @@
-# Pairs with loopover-docker-prune.service.example. See that file for install instructions.
-
-[Unit]
-Description=Run loopover-docker-prune.service daily
-
-[Timer]
-OnCalendar=daily
-Persistent=true
-# Spread the run over a window instead of firing at exactly midnight on every host.
-RandomizedDelaySec=30m
-
-[Install]
-WantedBy=timers.target
diff --git a/systemd/loopover-docker-prune@.service.example b/systemd/loopover-docker-prune@.service.example
new file mode 100644
index 0000000000..e6437c4684
--- /dev/null
+++ b/systemd/loopover-docker-prune@.service.example
@@ -0,0 +1,37 @@
+# Runs scripts/selfhost-docker-prune.sh on a schedule (paired with loopover-docker-prune@.timer.example) to
+# reclaim unused Docker images and build cache before disk fills up on a long-running self-host deployment.
+#
+# TEMPLATED UNIT (#4894): the `@` in the filename makes this a systemd instantiated unit, so ONE pair of
+# installed unit files can prune Docker resources for any number of loopover installs on the same host (or
+# be copied verbatim to every host in a fleet) without hand-editing a WorkingDirectory/ExecStart path per
+# instance. The instance name (the string after `@` when you enable it) IS the install path, systemd-escaped
+# -- `%I` below expands back to the real, unescaped path.
+#
+# Install (repeat the last two lines once per loopover install path on this host):
+# sudo cp systemd/loopover-docker-prune@.service.example /etc/systemd/system/loopover-docker-prune@.service
+# sudo cp systemd/loopover-docker-prune@.timer.example /etc/systemd/system/loopover-docker-prune@.timer
+# sudo systemctl daemon-reload
+# INSTANCE=$(systemd-escape -p /opt/loopover) # -> "opt-loopover"; repeat per install path if more than one
+# sudo systemctl enable --now "loopover-docker-prune@${INSTANCE}.timer"
+#
+# A single-instance host works exactly the same way -- there is no non-templated form anymore; just pick one
+# instance name (e.g. `systemd-escape -p /opt/loopover`) and enable that.
+#
+# This is a HOST-level unit, not a Docker Compose service: reclaiming images/build-cache needs real Docker
+# daemon access, which no container in docker-compose.yml is granted (see that file's docker-proxy and
+# runner service comments for why raw /var/run/docker.sock exposure into a container is avoided).
+
+[Unit]
+Description=LoopOver self-host Docker resource hygiene (image + build-cache prune) for %I
+After=docker.service
+Requires=docker.service
+
+[Service]
+Type=oneshot
+# %I is the unescaped instance name -- the loopover install path you passed to `systemd-escape -p`.
+WorkingDirectory=%I
+ExecStart=/bin/sh %I/scripts/selfhost-docker-prune.sh
+# Optional: override the default 7-day (168h) safety window before an unused image/build-cache entry is
+# eligible for removal. Per-instance overrides need a matching per-instance drop-in
+# (systemctl edit "loopover-docker-prune@.service") rather than editing this shared template.
+# Environment=LOOPOVER_DOCKER_PRUNE_RETAIN_HOURS=168
diff --git a/systemd/loopover-docker-prune@.timer.example b/systemd/loopover-docker-prune@.timer.example
new file mode 100644
index 0000000000..e4012b913f
--- /dev/null
+++ b/systemd/loopover-docker-prune@.timer.example
@@ -0,0 +1,14 @@
+# Pairs with loopover-docker-prune@.service.example. See that file for install instructions -- this is
+# also a templated (instantiated) unit, enabled per instance: `loopover-docker-prune@.timer`.
+
+[Unit]
+Description=Run loopover-docker-prune@%i.service daily
+
+[Timer]
+OnCalendar=daily
+Persistent=true
+# Spread the run over a window instead of firing at exactly midnight on every host.
+RandomizedDelaySec=30m
+
+[Install]
+WantedBy=timers.target