The default buildx builder (docker driver) is bound to the daemon's native platform, so on an Apple Silicon Mac `--platform linux/amd64 --load` was silently producing an arm64 image. The bundle then failed on the linux/amd64 target host with the "exec format error" that install.sh's arch sanity check now surfaces as "Image architecture (arm64) does not match this host (amd64)". package.sh now: - Creates a dedicated `sha-remote-agent-builder` (docker-container driver) on first run so cross-arch builds actually work. - Best-effort installs tonistiigi/binfmt QEMU handlers when the target platform differs from the host. - Uses `--output type=docker,dest=...` instead of `--load` + `docker save`, bypassing the local daemon's cross-arch storage limits entirely. - Verifies the produced image's Architecture against --platform after the build and aborts if they disagree, so a broken ZIP can never leave the build host. - Only re-tags :latest when the built platform matches the host, to avoid leaving a broken cross-arch :latest in the local daemon. README documents the trap and the mitigations. Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|---|---|---|
| .. | ||
| deploy | ||
| .env.example | ||
| docker-compose.yml | ||
| Dockerfile | ||
| package.json | ||
| package.sh | ||
| README.md | ||
StoreHealthAnalyzer Remote Agent — Docker
Standalone container for the remote agent that proxies SIW / MDM requests from an internal network back to the main StoreHealthAnalyzer server over an authenticated WebSocket.
What ships in the image
node:22-alpineruntime withtinias PID 1 sodocker stopreaches Node's SIGTERM handler and the websocket closes cleanly.- Just the agent script (
remoteAgent.js) and its three runtime deps (ws,axios,dotenv). No bot framework, no Express, no test tooling. - Runs as the unprivileged
nodeuser.
Final image size is small (roughly 60–80 MB depending on architecture),
compared to ~180 MB if the root package.json were installed.
Files in this folder
| File | Purpose |
|---|---|
Dockerfile |
Two-stage build (deps → runtime). Uses the repo root as the build context so it can pull in remoteAgent.js. |
package.json |
Minimal manifest: ws, axios, dotenv. |
docker-compose.yml |
Convenience wrapper for local builds; run from the repo root. |
.env.example |
Copy to .env, fill in WS_URL + WS_TOKEN. |
package.sh |
Builds the image and produces a self-contained deploy ZIP under dist/. |
deploy/ |
Files that get bundled into the deploy ZIP (runtime compose, install.sh, remote README). |
dist/ |
Generated ZIPs (gitignored). |
Prerequisites
- Docker 24+ (BuildKit is default and required for the
syntax=line). - The main StoreHealthAnalyzer server reachable from the host that will run this container (outbound only — the agent doesn't listen on any port).
- A shared
WS_TOKENvalue matching the one configured on the server.
Build
Always build from the repository root — the Dockerfile expects that
context so it can copy remoteAgent.js:
# From the repo root
docker build \
-f docker/remote-agent/Dockerfile \
-t sha-remote-agent:latest \
.
Tag with a version too if you plan to ship it to a registry:
docker tag sha-remote-agent:latest ghcr.io/<owner>/sha-remote-agent:1.0.0
docker push ghcr.io/<owner>/sha-remote-agent:1.0.0
Configure
cp docker/remote-agent/.env.example docker/remote-agent/.env
$EDITOR docker/remote-agent/.env
Required values:
WS_URL— websocket URL of the main server (e.g.wss://sha.example.com/ws).WS_TOKEN— shared secret matching the server'sWS_TOKEN.
Both .env and .env.* are excluded by the top-level .dockerignore, so
the file is never baked into the image.
Run
Docker CLI
docker run --rm -it \
--name sha-remote-agent \
--env-file docker/remote-agent/.env \
sha-remote-agent:latest
Add -d for detached mode and --restart unless-stopped if you want it to
auto-recover on host reboots.
Docker Compose (recommended)
# From the repo root
docker compose -f docker/remote-agent/docker-compose.yml up -d --build
# Tail logs
docker compose -f docker/remote-agent/docker-compose.yml logs -f
# Stop
docker compose -f docker/remote-agent/docker-compose.yml down
Compose sets restart: unless-stopped and 10 MB / 3-file JSON log rotation
so the container survives host restarts and doesn't fill the disk with
reconnect chatter.
Deploy elsewhere (ZIP bundle — recommended)
For hosts you can't reach with a registry, use the packaging script — it produces a single ZIP with the image, a runtime compose file, an installer, and a checksum:
# From the repo root — defaults to building for linux/amd64
npm run agent:package
# or, equivalently:
./docker/remote-agent/package.sh
Output lands in docker/remote-agent/dist/sha-remote-agent-<version>.zip
(the folder is gitignored). Transfer that one file to the remote host and:
unzip sha-remote-agent-<version>.zip
cd sha-remote-agent-<version>
./install.sh # loads the image, seeds .env, starts the container
Full remote-host instructions ship inside the ZIP as README.md and are
also visible here for reference: deploy/README.md.
The script tags the image both sha-remote-agent:<version> and
sha-remote-agent:latest, so local docker compose still works after
packaging.
Target-platform selection (very important on Apple Silicon)
Docker images are architecture-specific. If you build on an Apple Silicon
Mac with docker build, you get an arm64 image — which will fail to
start on a typical x86_64 Linux server (RHEL, Rocky, CentOS, Ubuntu)
with exec /sbin/tini: exec format error. The packaging script uses
docker buildx build --platform ... to avoid that.
The default target is linux/amd64. Override with --platform when your
remote host is different:
# x86_64 Linux (the default — Linux RH / Rocky / CentOS / Ubuntu on Intel/AMD)
./docker/remote-agent/package.sh --platform linux/amd64
# ARM Linux (Raspberry Pi 4/5, Ampere servers, etc.)
./docker/remote-agent/package.sh --platform linux/arm64
# For local testing on Apple Silicon
./docker/remote-agent/package.sh --platform linux/arm64
install.sh on the remote host also detects image_arch != host_arch and
refuses to start with a clear message pointing at the right rebuild command,
so a wrong-arch ZIP fails fast instead of after docker run.
Cross-building requires docker buildx — Docker Desktop ships it by
default; on Linux install the docker-buildx-plugin package if it isn't
already there.
How the script avoids the "silent arm64 image" trap
The default buildx builder on Docker Desktop uses the docker driver, which
is bound to the daemon's native platform. Passing --platform linux/amd64
to it from an Apple Silicon host can silently produce an arm64 image (or,
depending on the Desktop version, ignore the flag with only a warning). To
sidestep that, package.sh:
- Creates a dedicated
sha-remote-agent-builderwith thedocker-containerdriver on first run (isolated BuildKit instance, cross-arch capable). - Best-effort installs
tonistiigi/binfmtQEMU handlers when the target platform doesn't match the host. - Writes the image directly to a tarball via
--output type=docker,dest=...instead of--load+docker save, so the local daemon's cross-arch storage limits are irrelevant. - Verifies the produced image's
Architectureagainst--platformafter the build and aborts the packaging run if they disagree — so a broken ZIP can never leave the build host.
If the verification ever fires, install binfmt explicitly and rebuild:
docker run --privileged --rm tonistiigi/binfmt --install all
./docker/remote-agent/package.sh --platform linux/amd64
Manual export (without the packaging script)
If you'd rather do it by hand:
# Export from the build host
docker save sha-remote-agent:latest | gzip > sha-remote-agent.tar.gz
# Import on the target host
gunzip -c sha-remote-agent.tar.gz | docker load
# On the target: only .env is needed; no source tree required
docker run --rm -d \
--name sha-remote-agent \
--restart unless-stopped \
--env-file /path/to/remote-agent.env \
sha-remote-agent:latest
Networking
The agent is a websocket client — nothing listens inside the container, so there's no port to publish. You just need outbound network access from the container to:
- The main StoreHealthAnalyzer server (
WS_URL). - Whatever internal APIs the agent proxies for (SIW, MDM, ...).
If the internal APIs live only on the container host's network (e.g. a
private VLAN accessible only from the host), uncomment network_mode: host
in docker-compose.yml (Linux only). On Docker Desktop for macOS/Windows,
prefer running the container on a user-defined bridge network that has route
access to the required endpoints.
Verifying it works
Startup logs from a healthy agent look like:
🔄 Connecting to wss://sha.example.com/ws...
✅ Remote Agent connected to StoreHealthAnalyzer
On the main server side you should see a matching Remote agent connected
log line. From then on, st [number] commands that need SIW data will
succeed instead of degrading to the "Remote agent is not connected" banner.
Signals and shutdown
The agent handles SIGTERM and SIGINT explicitly (see
remoteAgent.js), closing the websocket before exiting. Because we run
tini as PID 1, docker stop (which sends SIGTERM then kills after the
grace period) reaches Node correctly and the exit is clean.
Rebuilding after code changes
Because remoteAgent.js is copied in during the runtime stage, changing
the script requires a rebuild (--build with compose, or a fresh
docker build). The deps stage is cached whenever package.json is
unchanged, so incremental rebuilds are fast.