Skip to content

Latest commit

 

History

History
358 lines (288 loc) · 15.5 KB

File metadata and controls

358 lines (288 loc) · 15.5 KB

NAS Docker deployment (generic runbook)

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).

Why this shape

  • The NAS (TerraMaster TOS) has no git — so there's no git clone/git pull on 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, then docker 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.


One-time setup (per NAS, shared across every app you deploy this way)

1. A GitHub token the NAS can use (private repos only)

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 repo scope 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 whose nas-update.sh points at it.
  • Save it on the NAS, readable only by you:
    echo "ghp_yourtoken" > /root/.cull-token
    chmod 600 /root/.cull-token
    nas-update.sh probes 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-token as your only copy: $HOME is empty in some NAS shells (su, cron, a bare sh), which silently turns $HOME/.cull-token into /.cull-token and the script won't find it. Use an absolute path like /root/.cull-token, or override per-run with TOKEN_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.

2. SSH access from Windows (for the one-command refresh)

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:
    where ssh
    
    If missing: Settings → Apps → Optional Features → Add a feature → OpenSSH Client.
  • Password auth works fine — ssh will 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.


The per-app update script (nas-update.sh)

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.

The self-overwrite guard

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.

Fetching the code (curl)

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).

Installing the code (rsync)

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.

Building and running (docker)

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:latest

Patterns 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.yaml as a second, overridable source (-v host-config.yaml:/app/config.yaml:ro).
  • --user UID:GID to match volume ownership. The image runs as a non-root user by default (APP_UID/APP_GID build args, default 1000). If the NAS volumes you're mounting in are root-owned (ls -n shows 0 0), the container needs --user 0:0 to actually read/write them, or everything fails with PermissionError. If instead you chown the volumes to a dedicated uid, point both the build arg and --user at 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.

Config that must exist before the mount

if [ ! -f "$APP_DIR/config.yaml" ]; then
  cp "$APP_DIR/config.example.yaml" "$APP_DIR/config.yaml"
fi

A 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.

The settings, and where they live

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.


The Windows-side one-command refresh (nas-refresh.bat)

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

One-tap update from the app (nas-update-watch.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.

Why it can't just be an endpoint

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".

Setup

  1. Nothing to configure: the watcher reads APP_DIR and STATE_DIR from the same /root/.cull-deploy.conf as nas-update.sh.
  2. Turn it on in the NAS-side config.yaml:
    update_enabled: true
    (or -e CULL_UPDATE_ENABLED=true on the docker run), then restart cull.
  3. Start the watcher as root — it needs Docker, same as nas-update.sh:
    nohup sh /home/youruser/image-cull/nas-update-watch.sh >/dev/null 2>&1 &
    To survive a NAS reboot, add it to root's crontab as @reboot. If you'd rather not keep a process alive, run nas-update-watch.sh --once from cron every minute instead; it checks for a request and exits.
  4. 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.

Files in $STATE_DIR/update

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.


Applying this to another app

  1. Copy nas-update.sh into the new app's repo.
  2. Edit the --- edit these to match your setup --- block: REPO, APP_DIR, whatever volumes that app needs, PORT, and swap the docker run env vars for that app's actual config keys.
  3. Reuse the same GitHub token (if repo-scoped) — no new setup needed.
  4. Copy nas-refresh.bat, change NAS_PATH to point at the new app's script (or pass it as an argument at run time instead of editing).
  5. First run: sh nas-update.sh directly on the NAS once, to catch any path typos with immediate output before wiring up the remote trigger.

Security notes

  • 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.0 but 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.yaml is .gitignore'd; verify git status doesn't show it before ever committing from the NAS side.

Troubleshooting quick reference

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