How cull gets from "code on GitHub" to "running container on the NAS," triggered by one command from a Windows desktop. Written so the same pattern can be copied to any other app you deploy the same way — the mechanics don't change, only the per-app variables (repo name, port, volumes, env vars).
- The NAS (TerraMaster TOS) has no git — so there's no
git clone/git pullon the NAS itself. - Docker is available on the NAS.
- So the deploy is: fetch a source snapshot over plain HTTPS (
curl, with a GitHub token if the repo is private), unpack it into an app directory,docker build, thendocker run(or restart) with config passed as env vars and data as bind mounts. - One shell script (
nas-update.sh) does all of that in one shot. One Windows batch script (nas-refresh.bat) SSHes in and runs it, so "pull and rebuild" is a single double-click from the desktop.
This same four-step shape — fetch → build → restart → verify — is the reusable part. Everything else in this doc is either a one-time setup step or the specific values cull happens to use.
TOS has no git, so the NAS pulls the code from GitHub's tarball API. A public repo needs no credentials — skip this step. For a private repo, the NAS authenticates with a Personal Access Token, not SSH keys or a login.
- GitHub → Settings → Developer settings → Personal access tokens.
- Classic token with the
reposcope is simplest — it reads every private repo on your account, so one token works for every app. If you want tighter scoping, a fine-grained token limited to specific repos also works; just make sure it covers each repo whosenas-update.shpoints at it. - Save it on the NAS, readable only by you:
echo "ghp_yourtoken" > /root/.cull-token chmod 600 /root/.cull-token
nas-update.shprobes a list of likely locations (TOKEN_CANDIDATES) so it finds the token regardless of which shell you run it from. Don't rely on$HOME/.cull-tokenas your only copy:$HOMEis empty in some NAS shells (su, cron, a baresh), which silently turns$HOME/.cull-tokeninto/.cull-tokenand the script won't find it. Use an absolute path like/root/.cull-token, or override per-run withTOKEN_FILE=/path sh nas-update.sh. Reuse the same file for other apps' scripts, or give each app its own; a shared file is simpler and the scope is identical either way.
The Windows batch script SSHes into the NAS to run the update script remotely.
- Windows 10/11 ship an SSH client as an optional feature. Check it's there:
If missing: Settings → Apps → Optional Features → Add a feature → OpenSSH Client.
where ssh - Password auth works fine —
sshwill just prompt each time you run the refresh script. For a true one-click (no typing), set up key-based login once:ssh-keygen -t ed25519 REM if you don't already have a key type %USERPROFILE%\.ssh\id_ed25519.pub | ssh youruser@yournas "cat >> ~/.ssh/authorized_keys"
That's the whole one-time setup. Everything below is per-app.
This is the file that actually does the work, run either directly on the NAS
(sh nas-update.sh) or remotely via the batch script. Walking through what
each part does, since these are the pieces you'd change for another app.
if [ -z "${CULL_UPDATER_REEXEC:-}" ]; then
_self_copy="$(mktemp)"
cp "$0" "$_self_copy"
CULL_UPDATER_REEXEC=1 exec sh "$_self_copy" "$@"
fi/bin/sh reads a script incrementally as it executes. Since this script
later overwrites its own directory (the rsync step below), running the
original file straight through means the interpreter can end up reading a
different byte offset in the newly-written file mid-execution and crash with
a confusing syntax error. The fix — and the one general lesson worth
reusing in any self-updating shell script — is to re-exec from a
throwaway copy in /tmp before touching anything, so the running file can
never change under the interpreter's feet.
DL="https://api.github.com/repos/$REPO/tarball/$BRANCH"
curl -fSL -H "Authorization: Bearer $TOKEN" "$DL" -o "$TMP/app.tar.gz"This is GitHub's tarball API — it works for any repo the token's owner can
read, public or private, no git required. $REPO is owner/name, $BRANCH
is usually main. For a NAS box without curl, wget --header=... does the
same thing (the script falls back to it automatically).
rsync -a --delete --exclude=config.yaml "$SRC"/ "$APP_DIR"/--delete matters: without it, files removed from the repo upstream would
linger forever in $APP_DIR, since a tarball extract only ever adds/updates
files. --exclude=config.yaml protects the NAS-local config (which isn't in
the repo anyway, but this is a keep-belt-and-suspenders guard against a
future config.yaml accidentally landing in the tree). If a box has no
rsync, the script falls back to stash-config → wipe → copy → restore.
docker build -t app:latest --build-arg SOME_FLAG=value "$APP_DIR"
docker stop app 2>/dev/null || true
docker rm app 2>/dev/null || true
docker run -d --name app --restart unless-stopped -p HOST_PORT:CONTAINER_PORT \
--user UID:GID \
-e SOME_CONFIG_VAR=value \
-v /host/data:/container/data \
app:latestPatterns worth reusing for any app deployed this way:
--restart unless-stopped— survives NAS reboots without a login session or cron entry.- Config via env vars, not baked into the image — the same built image
works across environments; changing a setting is a container restart, not
a rebuild. cull also supports a bind-mounted
config.yamlas a second, overridable source (-v host-config.yaml:/app/config.yaml:ro). --user UID:GIDto match volume ownership. The image runs as a non-root user by default (APP_UID/APP_GIDbuild args, default 1000). If the NAS volumes you're mounting in are root-owned (ls -nshows0 0), the container needs--user 0:0to actually read/write them, or everything fails withPermissionError. If instead youchownthe volumes to a dedicated uid, point both the build arg and--userat that uid/gid pair instead.- Data lives in bind mounts, never
COPY'd into the image — code and data have separate lifecycles; rebuilding the image never touches data.
if [ ! -f "$APP_DIR/config.yaml" ]; then
cp "$APP_DIR/config.example.yaml" "$APP_DIR/config.yaml"
fiA Docker bind mount for a file that doesn't exist on the host creates a
directory with that name instead, silently breaking startup. Always
guard for this before the first docker run on a fresh box.
nas-update.sh rsyncs over itself on every update, so editing the values in
the script doesn't survive. They come from /root/.cull-deploy.conf instead
(override the location with CULL_DEPLOY_CONF), which also keeps your paths
out of version control. nas-update-watch.sh reads the same file, so the two
can't drift apart.
cat > /root/.cull-deploy.conf <<'EOF'
REPO="youruser/image-cull"
APP_DIR="/home/youruser/image-cull"
RAW_DIR="/volume1/yourshare/pictures"
STATE_DIR="/volume1/yourshare/.cull"
STARRED_DIR="/volume1/yourshare/starred"
EOF
chmod 600 /root/.cull-deploy.conf| Variable | Default | What it is |
|---|---|---|
REPO |
— | GitHub owner/repo to pull from |
BRANCH |
main |
branch to deploy |
APP_DIR |
— | where the code lands on the NAS |
RAW_DIR |
— | the image library (bind mount) |
STATE_DIR |
— | index/action-log/thumbs (bind mount) |
STARRED_DIR |
— | starred-copy destination |
PORT |
8080 |
host port |
CONTAINER_USER |
0:0 |
root, to match root-owned NAS volumes |
INSTALL_TAGGER |
true |
bakes in the WD auto-tagger's onnxruntime |
The script refuses to run while APP_DIR is still the placeholder, so a
missing conf fails loudly instead of deploying somewhere unexpected.
Volume mount convention differs by NAS vendor — TerraMaster (TOS) mounts
at /Volume1 (capital V), Synology at /volume1. Check with df -h before
copying paths between NAS boxes.
nas-refresh.bat
SSHes into the NAS and runs its nas-update.sh, streaming the output back
to your terminal. Edit the two variables at the top of the file
(NAS_HOST, NAS_PATH) once per app; see nas-refresh.bat.
Optional override without editing the file:
nas-refresh.bat ~/other-app/nas-update.sh
The ⚙ settings panel in cull has an "Update from GitHub" button, so an update doesn't need SSH or a desktop. It is off by default and needs one helper running on the NAS.
nas-update.sh ends with:
docker stop cull
docker rm cull
docker run -d --name cull ...It destroys the container serving the request. Anything cull spawned — a subprocess, a thread, a container started through a mounted Docker socket — dies with it, halfway through the build, and the HTTP reply never arrives.
So the work is split. cull's half is a file write and nothing else: POST /update drops a small JSON request into $STATE_DIR/update and returns.
The privileged half is nas-update-watch.sh, running on the NAS host, which
polls for that request, runs nas-update.sh, and writes progress and the
result back into the same directory. That directory is a bind mount, so the
log and the outcome outlive the container being replaced — the rebuilt cull
reads them and can show how the update it was asked for actually went.
cull gains no new privileges from this. There is no Docker socket in the
container, no subprocess, and no shell. POST /update takes no body and no
parameters, so which branch is built and where it is deployed stay hardcoded
in the script on the NAS; the entire input is "go".
- Nothing to configure: the watcher reads
APP_DIRandSTATE_DIRfrom the same/root/.cull-deploy.confasnas-update.sh. - Turn it on in the NAS-side
config.yaml:(orupdate_enabled: true
-e CULL_UPDATE_ENABLED=trueon thedocker run), then restart cull. - Start the watcher as root — it needs Docker, same as
nas-update.sh:To survive a NAS reboot, add it to root's crontab asnohup sh /home/youruser/image-cull/nas-update-watch.sh >/dev/null 2>&1 &
@reboot. If you'd rather not keep a process alive, runnas-update-watch.sh --oncefrom cron every minute instead; it checks for a request and exits. - Open ⚙ in the app. It should read "Ready". If it says the helper isn't running, the watcher isn't alive or is pointed at a different directory. Starting it twice is safe: the second one exits and tells you the pid of the one already holding the lock.
The watcher writes a heartbeat every poll and cull refuses to accept a request when that heartbeat is stale, so a missing watcher is reported in the UI rather than swallowing the button press.
| File | Written by | What it is |
|---|---|---|
request.json |
cull | {id, ts} — an update was asked for |
status.json |
the watcher | {id, running, ok, exit_code, started_ts, finished_ts} |
watcher.json |
the watcher | heartbeat, {ts, pid} |
watcher.pid |
the watcher | lock: refuses to start a second watcher |
update.log |
the watcher | nas-update.sh's combined output, trimmed past ~1 MB |
Only one watcher may run at a time. Two would both see the same
request.json before either wrote .handled, so both would launch
nas-update.sh — concurrent docker builds racing the same stop/rm/run.
The watcher.pid lock makes a second one exit with the pid of the first
instead of starting. A pidfile left behind by a crash is reclaimed
automatically (the lock checks whether that pid still exists), so there's
nothing to clean up by hand.
Note that duplicate watchers are hard to spot from the heartbeat alone: they
overwrite each other's watcher.json, and the same one tends to write last
each cycle, so the pid there looks perfectly stable. Check at the process
level (jobs -l, or ps -ef | grep '[s]h /tmp/tmp\.' — the watcher re-execs
from a /tmp copy, so grepping for its own name finds nothing).
The phase shown in the app is just the most recent [cull] ... line in the
log, which is why nas-update.sh needed no changes. The watcher's own bookend
lines use [cull-watch] so they aren't mistaken for a phase.
- Copy
nas-update.shinto the new app's repo. - Edit the
--- edit these to match your setup ---block:REPO,APP_DIR, whatever volumes that app needs,PORT, and swap thedocker runenv vars for that app's actual config keys. - Reuse the same GitHub token (if
repo-scoped) — no new setup needed. - Copy
nas-refresh.bat, changeNAS_PATHto point at the new app's script (or pass it as an argument at run time instead of editing). - First run:
sh nas-update.shdirectly on the NAS once, to catch any path typos with immediate output before wiring up the remote trigger.
- The token file is
chmod 600, lives outside any repo tree, and is never committed. Don't put it in a script — always read it from a file at runtime. - The container binds
0.0.0.0but is only actually reachable over your tailnet/LAN — nothing is exposed publicly, and the app itself does not authenticate requests (app-level auth is a separate, deliberate decision to make before exposing anything wider). config.yamlis.gitignore'd; verifygit statusdoesn't show it before ever committing from the NAS side.
| Symptom | Likely cause |
|---|---|
Container can't read/write mounted volumes (PermissionError) |
--user doesn't match the volume's owning uid/gid — check with ls -n |
docker run mounts an empty directory instead of your data |
Volume-path case mismatch (/Volume1 vs /volume1) — check with df -h |
| Startup fails immediately, config looks like a folder | config.yaml didn't exist before the bind mount — seed it first |
nas-update.sh dies with a syntax error mid-run |
Missing the self-overwrite re-exec guard, or running an old copy without it |
| Update script removed nothing that was deleted upstream | Using cp -a instead of rsync --delete (or rsync isn't installed) |
| ⚙ panel says "Update helper not running" | nas-update-watch.sh isn't running, or its STATE_DIR doesn't match cull's update_dir — check $STATE_DIR/update/watcher.json is being touched |
| ⚙ panel says updating is off | update_enabled isn't set in the NAS-side config.yaml, or cull wasn't restarted after setting it |
| Update button does nothing, no error | The watcher is alive but wedged mid-run; check $STATE_DIR/update/update.log and restart it. A run stuck over an hour is ignored automatically |
| App sits on "Rebuilding…" and never returns | The rebuild failed after the container was removed, so nothing came back up. docker logs cull is empty in that case; read $STATE_DIR/update/update.log on the NAS |