Files
menu/ansible
tanshu 041b8b62fe Say what vhost, parallel run and cutover mean
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.
2026-09-22 23:24:50 +05:30
..

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.com is one. Older writing calls this a vhost, which just means "virtual host", and vhost is the variable name in vars/.
  • 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.