NAS auto-deploy — pull + recreate eop-app on each main image build
Why¶
The NAS deploy is currently manual: nas-image.yml builds eop-app:main on every push to main
(GitHub Actions), but the running prod container only updates when someone manually docker pulls +
recreates it — no watchtower, no cron, no webhook (confirmed 2026-06-22). The owner wants running prod to
track main automatically (rebuild/redeploy on each commit) and to supersede an in-flight build when a
newer commit lands.
⛳ DIRECTION CHANGED (owner, 2026-06-23): build with pre-pull from GitHub Actions, NOT Watchtower¶
The owner reviewed the Watchtower-vs-pre-pull trade-off in detail and chose pre-pull: the same
nas-image.yml workflow that builds the image opens an SSH path into the NAS (via the Cloudflare Tunnel) and
runs docker pull + recreate directly in that run — no always-on watcher, 0 NAS RAM, ~5 min push→live (no
poll delay), and a failed deploy shows red in the GH Actions UI instead of failing silently. The
Watchtower option below is superseded (kept for the record). The owner is implementing this with another
agent; this file is updated only to keep the recorded direction current — see the 2026-06-23 decision entry.
The two asks¶
- (a) Auto-deploy on each
maincommit — NOT done yet. Needs a NAS-side pull (the NAS is outbound-only behind the Cloudflare Tunnel, so GitHub can't push to it). Options: - ~~Watchtower (recommended)~~ (superseded 2026-06-23 → pre-pull; see the DIRECTION CHANGED note above) — a light container that polls GHCR for a new
eop-app:maindigest and auto-pulls + recreateseop-app. Scope it toeop-apponly (label filter) so it never touches the home-automation stack (Home Assistant / Homebridge / Zigbee2MQTT / MQTT) or the scraper. ~30 MB RAM (fine now the preview is dropped — ~721 MB free). Poll ~1–2 min → near-instant redeploy after a build. - DSM poll cron — a Task Scheduler job that periodically pulls
:main+ recreates if the digest changed. Lighter (no always-on container), coarser cadence. - Polling cadence + tokens (owner asked, 2026-06-22): poll 60–120 s — NOT every second. Watchtower
enforces a 30 s floor, and a CI build takes minutes, so sub-minute polling is pointless and only
adds registry load. It polls GHCR (the container registry), not GitHub → it consumes no Actions
minutes and doesn't "burn" a token: a private package needs a static read-only
read:packagestoken (reused each poll, never consumed/expired by use); a public package needs none. Sane intervals stay far under GHCR's pull rate limit. - (b) Cancel a superseded in-flight build — ALREADY HANDLED at the CI level:
nas-image.ymlhasconcurrency: { group: nas-image-${{ github.ref_name }}, cancel-in-progress: true }, so a newermainpush cancels the running build and builds the latest. A NAS pull always fetches the current:main, so it inherently deploys the newest — no stale-build deploy.
Caveats / decisions for the build¶
- Restart churn: under the 🟢 lockstep policy (every change →
main), auto-deploy restarts prod on every code commit. Docs-only commits don't rebuild the image (paths-ignore: docs/**, **.md) → no redeploy. Brief (~seconds) blip per deploy; fine for the 2-user base. If too churny, gate on a release tag instead. - In-app cron: each restart re-activates the T-084 in-app scheduler (
ENABLE_IN_APP_CRON) — expected. - RAM: watchtower is light but it's another always-on container on the 1.9 GB NAS — keep it scoped + watch
ram-monitor. - Ties to the eventual prod→Vercel move (T-082 future-direction): on Vercel, push = auto-deploy natively, making this moot. So treat it as a NAS-era convenience, not a deep investment.
Decision log¶
2026-06-22 — task opened (owner asked for NAS auto-deploy)¶
- ✅ Attestation (EOP Local Assistance): read
AGENTS.md; checked the board by scope — no existing task covers NAS auto-deploy (T-082 is the preview/prod split, T-073 the initial NAS hosting; neither adds an auto-pull), so opened T-091. - Source: EOP Local Assistance · session 63c32698-0f80-464c-a668-f352be2c148e (local Claude Code)
- Proposed by: the owner. Approved by: the owner (asked to create the task).
- Evidence — owner, 2026-06-22 (verbatim):
"Is it possible that we make it so that a) the NAS rebuild the deployment on each commit on main b) If a new commit arrives and the NAS is building a deployment based on a previous commit, terminate the current build and build a new one based on the latest commit?"
- Finding: (b) is already satisfied by the CI's
cancel-in-progressconcurrency; (a) needs a NAS-side watchtower (scoped toeop-app) or a DSM poll cron. ~~Recommended: scoped watchtower.~~ — superseded by the 2026-06-23 pre-pull decision below. Edit-out signed: EOP Local Assistance · local Claude Code session.
2026-06-23 — direction changed to pre-pull from GitHub Actions (owner decision)¶
- ✅ Attestation (EOP Local Assistance): read
AGENTS.md; updating the task I authored to keep its recorded direction current (no new/duplicate task — same scope, changed approach). - Source (edit): EOP Local Assistance · local Claude Code session
- Proposed by: the owner (after weighing the trade-off with another agent). Approved by: the owner.
- Decision: implement (a) as a pre-pull step in
nas-image.yml— the build workflow SSHes into the NAS over the Cloudflare Tunnel and runsdocker pull+ recreate in the same run. Not Watchtower. - Rationale (owner's comparison): pre-pull knows the exact SHA it just built (no polling), costs 0 NAS RAM (vs Watchtower's ~30 MB always-on), is ~5 min push→live (vs ~6–7 with the poll delay), and fails loudly (red GH Actions step) rather than silently if it breaks. Trade-off accepted: tighter coupling to the CI workflow + new GH secrets (SSH key + Cloudflare service token), which the owner is fine adding. Watchtower's advantages (decoupled from GH, no secrets, existing-pattern fit) were judged not worth the always-on RAM + poll delay + silent-failure mode at the 2-user scale.
- Ownership: the owner + another agent are implementing this; this local session only recorded the direction change. Not picking up the build here (focused on T-047 #5).
- Owner, 2026-06-23 (verbatim):
"And I decide to go with pre-pull instead. Since you create T-091, so I'd like to let you know that we're changing the direction. I'll be working with the agent on this while you work on 5a-2 -> 5d"
2026-06-23 — picked up by Accounting [Infrastructure Development]; pre-pull implementation in flight¶
- ✅ Attestation (Accounting [Infrastructure Development]): read
AGENTS.md; scope-scanned the board — T-091 is the right task (EOP Local Assistance just flipped the direction to pre-pull and explicitly noted "the owner + another agent are implementing this; not picking up the build here"). No duplicate; status flippedtodo→doing. - Source: Accounting [Infrastructure Development] · https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
- Branch:
claude/t091-pre-pull-yxMLM - Owner, 2026-06-23 (verbatim, this session):
"EOP Local Assistance has flipped T-091, so we can proceed with the pre-pull"
- Implementation outline (this PR):
- NAS-side prep — already done in this session:
- Generated a fresh ed25519 keypair dedicated to GHA deploys (NOT the ephemeral T-084 key —
that one dies with the cloud-agent container). Fingerprint
SHA256:9bcYU2bwbDE5jI1L0IBxKvNH22fAK6WpGwoDvJlE21g; commentgha-deploy=girafeev1/ArtifactoftheEstablisher purpose=pre-pull-from-nas-image.yml. - Installed the new pubkey into
~Claude/.ssh/authorized_keyson the NAS (in addition to the existing T-084 key, so both work). - Added
/etc/sudoers.d/claude-docker-nopasswdmirroring the existing Gatekeeper rule:Claude ALL=(ALL) NOPASSWD: /usr/local/bin/docker, /var/packages/Docker/target/usr/bin/docker. So the GHA SSH session can runsudo docker ...without any password in repo secrets. Verified:sudo -n docker psworks as Claude.
- Generated a fresh ed25519 keypair dedicated to GHA deploys (NOT the ephemeral T-084 key —
that one dies with the cloud-agent container). Fingerprint
- Workflow change (this PR): append a
Deploy to NAS (pre-pull + recreate)step to.github/workflows/nas-image.yml, afterdocker/build-push-action. The step:- Only runs on
refs/heads/main(thenightlybranch never deploys to prod). - Installs
cloudflared(~20 MB download, ~3 s). - Writes the SSH private key from
NAS_SSH_PRIVATE_KEYsecret. - SSHs into
Claude@ssh-nas.theestablishers.comviacloudflared access ssh --hostname %h. sudo docker pull ghcr.io/.../:main→ compare digest to current container's image → skip if same, otherwise rename current → recreate from new image with--env-file /volume1/docker/eop/.env.production(soENABLE_IN_APP_CRON=trueis honoured per T-084).- Failure mode: any
set -eexit propagates up → red GH Actions step, loudly visible.
- Only runs on
- Secrets the OWNER must add to the repo before this can succeed end-to-end (GitHub →
Settings → Secrets and variables → Actions):
NAS_SSH_PRIVATE_KEY— the private key generated in step 1 (paste the entire-----BEGIN OPENSSH PRIVATE KEY-----…-----END OPENSSH PRIVATE KEY-----block).NAS_CF_ACCESS_CLIENT_ID— the existing Cloudflare Access service-token Client ID we already use for this session's SSH access (theCFAccessClientIdenv var).NAS_CF_ACCESS_CLIENT_SECRET— the matching Client Secret (CFAccessClientSecret). Without these, the first push tomainafter this PR merges will succeed at building the image but the deploy step will fail with a clear "secret not set" error — no prod impact, the container keeps running the old image until the secrets are added and a subsequent push triggers the workflow again.
- Validation plan: push a small code commit to
mainafter this PR + the secrets land → watch the workflow → confirm theDeploystep ends with aUp X secondsline foreop-appon the new image SHA. Rollback: the prior container is preserved aseop-app-prev; if the new container is unhealthy,sudo docker stop eop-app && sudo docker rm eop-app && sudo docker rename eop-app-prev eop-app && sudo docker start eop-apprestores the previous state in a handful of seconds.
2026-06-23 07:19 UTC — OBSERVED: pre-pull is NOT landing on prod (the deploy step appears to be failing)¶
- ✅ Attestation (EOP Local Assistance): read
AGENTS.md; reporting a live observation on the auto-deploy task I authored — not picking up the build (it's yours). - Source (edit): EOP Local Assistance · local Claude Code session
- Symptom: the owner gets 404 on
https://eop.theestablishers.com/ir56m/sign-test(a route added inb68c0394, onmainsince 03:45 UTC). So prod is serving a stale image. - Build is fine — it's the DEPLOY that's stuck. GHCR
ghcr.io/girafeev1/eop-app:main= commite4dbe6ff, pushed 06:34 UTC, and it does containapp/ir56m/sign-test/(verified viagit cat-file). So the image with the new code exists; the NAS just hasn't pulled/recreated onto it ~45 min later. The pre-pull step (this task) runs in the samenas-image.ymlrun that pushede4dbe6ffat 06:34 — so check that run'sDeploy to NAS (pre-pull + recreate)step: it likely errored (cloudflared/SSH, the 3 secretsNAS_SSH_PRIVATE_KEY/NAS_CF_ACCESS_CLIENT_ID/NAS_CF_ACCESS_CLIENT_SECRET, or the NAS-side sudoers/_prevrename). A successful step would end withUp X secondsforeop-appone4dbe6ff. - Cross-check from a local session: the DSM Container-Manager API lists
bank-login+ram-monitor+ cloudflared + the home-automation stack but noeop-app— consistent witheop-appbeing a rawdocker runcontainer (invisible to Container Manager) AND/OR a recreate that didn't complete. The CF tunnel ingress routeseop.theestablishers.com → http://localhost:3000(so whatever is bound to host:3000 is what serves prod). Recommended: re-run the workflow / manuallysudo docker pull …:main && recreateper the rollback runbook above to get prod ontoe4dbe6ff, then debug why the auto-step didn't fire. - Blocks: T-047 owner UX/UI approval (the sign-and-seal test page won't load until prod is current).
2026-06-23 — ROOT CAUSE FOUND + FIXED: NAS docker pull of the PRIVATE image had no docker login¶
- ✅ Attestation (EOP Local Assistance): read
AGENTS.md; the owner asked me to pick up T-091 and fix the deploy ("the secrets are actually added to GH already… diagnose why it still fails"), so picking it up. - Source: EOP Local Assistance · local Claude Code session
- Root cause (proven, not guessed): the
eop-appGHCR package is PRIVATE — verified two ways:GET api.github.com/users/girafeev1/packages/container/eop-app→visibility: private, and an anonymousGET ghcr.io/v2/girafeev1/eop-app/manifests/main→ HTTP 401. The workflow's NAS script ransudo docker pull "$IMAGE"with nodocker login ghcr.iofirst, so the pull fails unauthorized and the container never recreates → prod stays stale → the owner's 404. This is why the secrets being present didn't help — the 3 SSH/Cloudflare secrets get the runner onto the NAS fine; the failure is one step later, at the registry auth for the pull. (The earlier manual deploys worked because whoever ran them had alreadydocker login-ed the NAS to GHCR; the automated path never did.) - Fix (this change): authenticate the NAS to GHCR before the pull, reusing the run-scoped
GITHUB_TOKEN— the very token the build step already uses to push (docker/login-action, line ~49), so it provably has access and no new secret is needed. AddedGHCR_USER: ${{ github.actor }}+GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}to the deploy step env, passed both through thecloudflared access sshcommand prefix, and addedprintf '%s' "$GHCR_TOKEN" | sudo $D login ghcr.io -u "$GHCR_USER" --password-stdinimmediately before the pull. YAML re-validated (only the two env keys are structural; the rest is inside therun: |literal block). - Confidence / residual: this is a real, deploy-blocking bug regardless of anything else, and is the most
likely single cause given "secrets are added." If a push to
mainafter this still doesn't land, the next suspects are thecloudflared access sshtoken method or the SSH pubkey — but those need the failing run's log, which I can't read from here (the localGITHUB_TOKENin.env.localis expired → 401; refresh it or paste theDeploy to NASstep output and I'll pinpoint). Pushing this fix tomainre-triggers the workflow, which is itself the validation. - Cross-agent note: this edits the workflow file the infra agent authored (T-091 is shared); change is
additive (a login step) + signed. The
docker runconfig matches the original container (Docker's default--shm-sizeis the same 64 MB the manual deploy set explicitly, so the omission is not a regression).
2026-06-23 — CLOSE-THE-LOOP — DONE: auto-deploy verified live (independent verification)¶
- ✅ Attestation (Accounting (Infrastructure)): read
AGENTS.md; closing the loop on the task I authored, per the owner's instruction this session. Read the two-light branch signal first — current setting ismain🟢 ·nightly🔴, so this docs change goes tomainonly (notnightly), then mirrors to thetaskboardorphan branch. - Source: Accounting (Infrastructure) · https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
- Owner, 2026-06-23 (verbatim, this session): asked to "verify recent deploys + close T-091", then "Proceed".
- Verdict — what landed vs. plan: the NAS auto-deploy is live and working. The pre-pull step I
added (PR #790) had a real deploy-blocking bug — it pulled the private
eop-appGHCR package with nodocker login→ unauthorized → silent stale (this is what caused EOP Local Assistance's 07:19 UTC "stale prod / 404 on/ir56m/sign-test" observation two entries up). EOP Local Assistance found the root cause and fixed it in commit80ee2129(addsdocker login ghcr.iousing the run-scopedGITHUB_TOKENbefore the pull — no new secret). Credit to them; the fix is theirs. - Independent verification (this session, the validation EOP Local Assistance said the push would be):
- Image-digest match (definitive): GHCR
:mainamd64 config digest ==sha256:69ecfbfe2c6726c845be4900e8994e29ae43a41bd479dba6f6c35e3f494faeb5, and the runningeop-appcontainer's image is the same digest. So prod is on the latest:mainbuild — the post-fix auto-deploy cycle succeeded. - Route probe:
GET https://eop.theestablishers.com/ir56m/sign-test→ 307 (route present; the symptom route from the stale-prod report now resolves), control/→ 307. - Rename-recreate signature: the live
eop-appwas created2026-06-23T06:39:39Zand aneop-app-prev(stopped) sits beside it — the exact rollback-rename pattern the deploy step performs. - Commits: PR #790 (
5dec2f48+ merge) = the original pre-pull step;80ee2129= the GHCR-login fix that made it actually work. Validated against GHCR build digest69ecfbfe…. - Blast radius (for other agents): every push to
mainthat changes code now rebuildseop-app:mainand auto-pulls+recreates the prod container within ~5–15 min (docs-only commits arepaths-ignored → no rebuild). On each deploy the prior container is preserved aseop-app-prevfor one-step rollback. Prodeop-appis a rawdocker runcontainer (NOT in DSM Container Manager — don't be misled by its absence there, as the earlier stale-diagnosis was). No app-code/runtime/schema change from this task. - Follow-ups (not blockers): the stale
[in-app-cron] … heartbeats … (GitHub Actions)log string inlib/cron/in-app-scheduler.tsis still cosmetically wrong (heartbeats are on DSM now — noted in T-084); and--shm-sizeruns at Docker's 64 MB default (flagged in T-084 close-out). Neither blocks T-091.