# Public demo deploy (nash-srv)

Push to `main` and, once [CI](https://github.com/jacobnash/baler/actions/workflows/ci.yml)
passes, [`.github/workflows/deploy.yml`](https://github.com/jacobnash/baler/blob/main/.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):

```
github-runner ALL=(root) NOPASSWD: /usr/local/sbin/baler-deploy.sh
```

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

```bash
for v in postgres-data oxigraph-data grafana-data mosquitto-data windrow-state; do sudo docker volume create "baler_$v"; done
```

## One-time setup

1. **DNS** - add CNAMEs (not A records - nash-srv's public IP is dynamic):
   ```
   timberdoodle.nash.engineering      CNAME  nash-engineering-qdwgdwmvvt.dynamic-m.com
   docs.timberdoodle.nash.engineering CNAME  nash-engineering-qdwgdwmvvt.dynamic-m.com
   ```
   This is the only step that isn't scripted - it needs your DNS provider's
   own UI/credentials.
2. **Everything else on nash-srv** - one script does the rest: generates
   real prod secrets (`~/.config/baler/nash-srv.env`, outside the git
   checkout so `actions/checkout`'s `git clean -ffdx` can't touch it),
   installs the runner/wrapper/sudoers rule via `setup-runner.sh`, and
   appends+reloads the Caddy block. All three sub-steps are safe to re-run
   (each skips itself if already done).
   ```bash
   git clone git@github.com:jacobnash/baler.git   # skip if already cloned
   cd baler
   bash deploy/nash-srv/bootstrap.sh <REGISTRATION_TOKEN>
   ```
   Get `<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_WEBHOOKS` is 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.
3. **Demo credentials** - already set as `DEMO_ADMIN_EMAIL`/
   `DEMO_ADMIN_PASSWORD` GitHub Actions repo secrets (generated once,
   pushed with `gh secret set`, never committed to the repo). The first
   deploy creates that account via `POST /auth/orgs`; every deploy after
   that logs in with it first and only falls back to creating it if login
   fails (`/auth/orgs` isn't safe to call twice for the same email - see
   `auth.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](/post-deploy-verify#one-time-setup-on-nash-srv).
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](/post-deploy-verify#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):

```bash
cd ~/baler-deploy   # or wherever the checkout actually is
DEMO_ADMIN_EMAIL=<same as the GitHub secret> DEMO_ADMIN_PASSWORD=<same> \
  bash deploy/nash-srv/seed-demo-data.sh
```

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](/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](/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.:

```bash
ssh -L 3033:127.0.0.1:3033 jacob@nash-srv   # then open http://localhost:3033 for Grafana
```

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