podman builds an OCI image by default and drops Docker's HEALTHCHECK instruction, so the container had no health at all and the play failed waiting for one. The check is now declared on the docker_container task - node is in the image and the exec form needs no shell quoting - which is both what operators see in docker ps and what an orchestrator would use. The wait still asks the app directly rather than reading State.Health, because a container that already exists is only rebuilt when the image tag changes or when -e recreate_container=true is passed, and a wait must not depend on a container attribute an existing one may not have.
The menu deployment
Lives here, beside the app it deploys. One run deploys a Site: it starts the app's container and points the Site's web address at it. There is no older system to keep serving, so there is no staged rollout and nothing to retire — deploying an earlier tag is the rollback.
The app's data root is the directory the old static menus were served from
(/var/lib/menu.<site>), on purpose, so the app inherits the location instead of
a second one. The app only reads and writes data/ and site.env inside it. If
you later want the old menu files gone, delete those files — not the directory,
which now holds the live content.
What lives where
| Thing | Where |
|---|---|
| Name, tagline, icon, categories, menus | on the server under /var/lib/menu.<site>/data — the admin edits them |
| Theme colours | config/<site>.site.json in the repo, baked into the image |
| Admin password hash, session secret | /var/lib/menu.<site>/site.env (untracked, mode 600) |
| Which image tag runs | -e tag=<tag> |
| Which Site is deployed | the inventory entry in hosts |
| The web address it serves | host: in vars/<site>.yml |
Deploying
From the repo root:
./deploy.sh 2026-09-22 # checks, podman build+push, then deploys
SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only
deploy.sh runs make build-production TAG=<tag>, which builds a multi-arch
podman manifest (linux/amd64,linux/arm64) and pushes latest and the tag to
registry.tanshu.com/menu-admin. Then it runs this playbook against the Site
named by SITE (default hopsngrains).
By hand:
make build-production TAG=2026-09-22
cd ansible
ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains -e tag=2026-09-22
Every Site runs on the same box, so hosts has one entry per Site pointing at
the same ansible_host with its own var_file. ansible_host is written as the
resolvable name rather than an ssh alias, so the inventory works whatever a
machine's ~/.ssh/config says. (ssh on some machines complains about
/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf being owned by nobody; that is
a local file-permission bug whose fix is sudo chown -R root:root /etc/ssh/ssh_config.d, not something this playbook works around.)
Adding a Site is one line in hosts and a copy of vars/hopsngrains.yml.
Checking it landed
npm run check-site -- https://menu.hopsngrains.com
Read-only, exits non-zero on failure. Then from a phone: the landing page, a
Section page, the page counter, tap-to-zoom, and signing into /admin to
publish something.
The admin password
The app starts with the admin disabled while /var/lib/menu.<site>/site.env
is missing, and the playbook says so rather than failing. Create it once per
Site:
scp scripts/provision-site-env.sh monoco:/tmp/
ssh monoco bash /tmp/provision-site-env.sh /var/lib/menu.hopsngrains.com
ssh monoco docker restart menu-app.hopsngrains.com
Rolling back
Deploy the previous tag:
./deploy.sh <previous-tag>
docker logs menu-app.<site> is where a failed container says why.