Public demo deploy (nash-srv)
Push to main and, once CI
passes, .github/workflows/deploy.yml
deploys the whole stack to nash-srv - a shared box that also runs a few
other projects behind one Caddy instance. https://timberdoodle.nash.engineering
is the app, https://docs.timberdoodle.nash.engineering is this site
(rebuilt and republished on every deploy too).
Deploys land via a self-hosted GitHub Actions runner on the box, not a
GitHub-hosted runner SSHing in - no deploy key ever leaves nash-srv. The
runner runs as a dedicated github-runner OS user that is not in the
docker group (docker-group membership is root-equivalent host access, and
this box runs unrelated projects for other people) - it can only touch
baler at all via one root-owned, no-argument wrapper script it's
allowed to sudo through a sudoers rule scoped to exactly that script
(the same user may hold rules for other projects' wrappers on this shared
box; Baler's own rule is just this one):
Code
That wrapper (written by deploy/nash-srv/setup-runner.sh) runs docker compose -f docker-compose.yml -f deploy/nash-srv/docker-compose.override.yml --env-file ~/.config/baler/nash-srv.env as a rolling deploy (#166): it
builds first, then replaces the HTTP APIs one at a time, each with up -d --no-deps --wait, so every route keeps answering while its backend is
swapped. The rest of the stack (infra, migrate, daemons) comes up after,
and the gateway is never restarted for a backend change; it is only
reloaded (nginx -t && nginx -s reload) when gateway/ itself changed. The
override file rebinds every port the base compose file publishes
(postgres, oxigraph, mosquitto, jaeger, grafana, gateway) to
127.0.0.1, matching how every other project on this box is bound. Caddy is
the only thing listening on 0.0.0.0:80/:443; nothing else is reachable
from outside the box except SSH. No gateway restart is needed because its
upstreams re-resolve at runtime (#108, see the main CLAUDE.md gotcha). The
first deploy on this wrapper (2d863dd, #258) saw no gateway 5xx during the
deploy window (#229).
postgres-data/oxigraph-data are named Docker volumes the deploy never
touches (no down -v anywhere in the pipeline), so the demo/e2e data your
friends generate survives every redeploy.
The data volumes on nash-srv predate the rename, so they carry the old
names (BALER_VOLUME_PREFIX=timberdoodle, WINDROW_STATE_VOLUME=fbf-state
in nash-srv.env, see deploy/MIGRATING-TO-BALER.md) and were created by
the timberdoodle compose project. The override file marks those five
(postgres-data, oxigraph-data, grafana-data, mosquitto-data and
Windrow's windrow-state) external: true, with the same name: as the
base file. Compose then uses them as they are, instead of warning on every
deploy that they belong to another project, and never creates or removes
them. The base docker-compose.yml doesn't do this, because a fresh install
has no such volumes and compose refuses to start with a missing external
volume.
A from-scratch nash-srv (no old volumes, prefix unset) has to create them
once before its first deploy, or that deploy stops with
external volume "baler_postgres-data" not found:
Code
One-time setup
- DNS - add CNAMEs (not A records - nash-srv's public IP is dynamic):
This is the only step that isn't scripted - it needs your DNS provider's own UI/credentials.Code - Everything else on nash-srv - one script does the rest: generates
real prod secrets (
~/.config/baler/nash-srv.env, outside the git checkout soactions/checkout'sgit clean -ffdxcan't touch it), installs the runner/wrapper/sudoers rule viasetup-runner.sh, and appends+reloads the Caddy block. All three sub-steps are safe to re-run (each skips itself if already done).
GetCode<REGISTRATION_TOKEN>from the repo's Settings → Actions → Runners → New self-hosted runner (valid ~1 hour, single use - grab it right before running).BALER_ALLOW_PRIVATE_WEBHOOKSis deliberately left unset in the generated env file - this box is public, so the webhook SSRF guard (webhooks.validate_url) needs to stay on, unlike local dev or CI. - Demo credentials - already set as
DEMO_ADMIN_EMAIL/DEMO_ADMIN_PASSWORDGitHub Actions repo secrets (generated once, pushed withgh secret set, never committed to the repo). The first deploy creates that account viaPOST /auth/orgs; every deploy after that logs in with it first and only falls back to creating it if login fails (/auth/orgsisn't safe to call twice for the same email - seeauth.py). Those are the credentials to hand to friends - pull them from GitHub's secret UI (write-only, so re-set rather than "view") if you need a reminder, or rotate them there directly.
After the deploy, the workflow's verify job checks the live install (read-only) and files or
closes live-check issues. It needs a credentials file on the runner, created once by hand: see
Post-deploy verification.
It runs in its own queue, so the next deploy never waits on it, ideally on a second runner instance
(nash-srv-baler-verify) that the same setup-runner.sh installs when re-run with a fresh token: see
The deploy and verify queues.
Demo data (mock hospital)
Without this, the demo is just an empty app past login. deploy/nash-srv/docker-compose.windrow.yml
runs a simulated BACnet device (windrow.mock_device) and Windrow's own discovery/
API service (windrow.api) as two more services in the regular deploy - built
from deploy/nash-srv/windrow.Dockerfile against the fbf/ submodule, joined
to a small fixed-IP bacnet-mock network (172.30.0.0/24) since BACnet/IP
needs a literal address on both sides, not a hostname. Confirmed working
over a normal Docker bridge network - BACnet unicast (not broadcast, which
doesn't cross bridge networks) is all this needs. windrow_api also joins the
default network to reach mosquitto by service name, same as every other
daemon. Its connection state persists in a named volume (windrow-state, like
postgres-data/oxigraph-data) so redeploys don't wipe provisioning.
One-time (not per-deploy - scripts/seed_derivations_and_faults.py isn't
safe to re-run, it'd create duplicate derivations):
Code
Provisions windrow.api against the mock device (~500 points across ~90
equipment), waits for mqtt_listener to ingest some of it, runs autotag --no-llm (rules only - no model key needed; what the rules can't place
is left as mapping proposals with ranked candidates, see
the model step), then seeds real Brick derivations/fault rules through the
gateway using the same demo account. Run as jacob (already in the
docker group on this box) - not through the runner's sudoers wrapper,
this needs docker compose exec, not just up.
Edge nodes on the LAN
The Pis of the Home Edge Pilot (192.168.128.228 BACnet, .230 Modbus) are
polled by windrow_api by IP. deploy/nash-srv/check-edge.sh checks them end
to end, and an opt-in host-network Windrow (docker-compose.lan.yml,
BALER_WINDROW_LAN=1) adds broadcast discovery on nash-srv's LAN. See
Edge nodes.
Windrow submodule
fbf/ is a separate, independently-versioned public repo (jacobnash/windrow),
vendored here as a git submodule rather than merged in - this repo's docs
(docs-site/pages/..., the API catalog) reference fbf/openapi.yaml
directly. actions/checkout's submodules: true (set in deploy.yml)
clones it directly - no separate step or deploy key needed now that it's
public. (It used to be private, requiring a read-only SSH deploy key at
/home/github-runner/.ssh/windrow_deploy_key on the runner - if that key/config
is still on nash-srv from before, it's dead weight and safe to remove.)
Reaching the parts that aren't public
Only the app and its docs are public. Everything else (Postgres, Oxigraph,
Grafana, Jaeger, MQTT) is bound to 127.0.0.1 on nash-srv - reach it over
an SSH tunnel, e.g.:
Code
Rotating the demo password
Change DEMO_ADMIN_PASSWORD in GitHub Actions secrets, then either wait for
the next deploy or run one manually (Actions → Deploy → Run workflow) -
there's no in-app "change password" endpoint to call instead, and the
deploy step only creates the account, it doesn't reset an existing
password. If you need it changed immediately, update it directly against
Postgres on the box instead of waiting on a redeploy.

