The deployment readme used three pieces of jargon without defining any of them, which reads as a locked door to anyone doing their first deploy. It now opens with what each word means, the table of which address serves what at each stage, and the note that skipping the parallel run is a legitimate choice. Domain terms stay in CONTEXT.md; these are operational words for this directory, so they are defined here.
The menu deployment
Lives here, beside the app it deploys, the way the other apps on this machine do.
It owns the menu-app container and the Site's snippet in the shared Caddyfile.
Words used below
The deployment reads as jargon if you have not done one before, so:
- web address — one hostname the web server answers for. Caddy keeps a block
per address;
menu.hopsngrains.comis one. Older writing calls this a vhost, which just means "virtual host", andvhostis the variable name invars/. - parallel run — putting the app on a second, temporary address
(
menu-new.hopsngrains.com) while the old site keeps the real one, so the new one can be checked on a phone without customers seeing it. It needs a DNS record for the temporary name, because Caddy has to fetch a certificate for it. - cutover — the moment the real address is pointed at the new app instead of the old nginx container, and that container is stopped. Customers are on the new app from then on. It is reversible.
| Stage | Address customers use | Address you check |
|---|---|---|
| today | menu.hopsngrains.com → old nginx |
— |
| parallel run | unchanged | menu-new.hopsngrains.com → this app |
| after cutover | menu.hopsngrains.com → this app |
— |
Skipping the parallel run is a legitimate choice: cutover on its own is one command, one Caddy reload, and a rollback if anything looks wrong. The parallel run is for finding out that a container will not start, or a font will not load, without anyone watching.
What lives where
| Thing | Where |
|---|---|
| Name, tagline, icon, categories, menus | on the server, under /var/lib/menu-app.<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-app.<site>/site.env (untracked, mode 600) |
| Which image tag runs | -e tag=<tag> |
| Which Site is being deployed | the inventory entry in hosts |
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 with the fix sudo chown -R root:root /etc/ssh/ssh_config.d, not something this playbook works around.)
The first run, and cutover
The first run targets a temporary vhost, so the live site keeps serving:
ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains \
-e tag=2026-09-22 -e vhost=menu-new.hopsngrains.com
menu-new.hopsngrains.com needs a DNS A record pointing at the same address as
the live site before this runs: Caddy fetches a certificate for the name, which
needs it to resolve publicly.
Then, from the outside (read-only, exits non-zero on failure):
npm run check-site -- https://menu-new.hopsngrains.com
And from a phone on restaurant wifi: the landing page, a Section page, the page
counter, tap-to-zoom, and signing into /admin to publish something.
Cutover is the same playbook with the real vhost and the two retire flags:
ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains \
-e tag=2026-09-22 -e vhost=menu.hopsngrains.com \
-e retire_nginx_snippet=true -e retire_nginx_container=true
That repoints the real vhost at the app, removes the snippet the old nginx-menu
playbook wrote for that hostname, and stops the old container. The old container
and /var/lib/menu.hopsngrains.com are kept, not deleted: deleting 319 MB of the
only copy of the old menus is a human decision, made once the new site has been
trusted.
The admin password
The app starts with the admin disabled when /var/lib/menu-app.<site>/site.env
is missing, and the playbook says so. Create it once per Site:
scp scripts/provision-site-env.sh monoco:/tmp/
ssh monoco bash /tmp/provision-site-env.sh /var/lib/menu-app.hopsngrains.com
ssh monoco docker restart menu-app.hopsngrains.com
Rolling back
ssh monoco docker start nginx-menu.hopsngrains.com
then point Caddy back at it — the cutover flags above with
retire_nginx_snippet=false.