The fleet on emily is rootless Podman with Quadlet units under ~/.config/containers/systemd and state in ~/podman-data, all behind one shared Caddy unit. This brings the menus in line instead of leaving them the last Docker deployment. The build moves with them: podman reads a Containerfile as readily as a Dockerfile, so the name now says which toolchain is real. The app is one .container and one .network file. Two details carry it. UserNS=keep-id maps the image's own node account onto the user that owns the units, so data/ and site.env stay owned by tanshu: plain tar backups, no subuids. provision-site-env.sh writes site.env with the same mapping rather than --user, which under rootless Podman would have produced a subuid-owned file the app could not read. And the Site registers its own network with Caddy by writing a drop-in beside caddy.container, so Caddy never lists its upstreams and never changes when a Site is added. The unit keeps the Docker-era container name for the scripts and muscle memory that look it up, while the Caddyfile proxies to a dotless NetworkAlias, because a container name with dots in it is a liability in DNS. The unit keeps podman's Pull=missing, so a restart or a reboot never depends on the registry being reachable, while the play pulls explicitly at deploy time. That is also where a missing `podman login` for the private registry now fails, with the instruction, rather than as a unit that will not start. copy-site-data.sh moves a Site's data across, and is not a plain rsync: /var/lib/menu.* on monoco belongs to uid 1000 while the ssh user there is 1001, so the read needs sudo; monoco cannot ssh to emily, so the workstation relays the tar; and the extraction runs as tanshu on emily, which is what makes the files land owned by the user keep-id maps the app onto. It proves the copy by comparing a SHA-256 of the whole tree against the source and checking every file's owner. Not yet done: the copies themselves and the DNS move, which need a human.
7.4 KiB
Operating the menus
For whoever is on the other end of "the menu is wrong". The design is in
plan.md, the vocabulary in CONTEXT.md, and the deployment in
../ansible/readme.md.
Each Site is one rootless Podman Quadlet unit on emily — a systemd user
service named menu-<site> — behind the shared Caddy unit. There is no Docker in
this path.
Where things are
| Thing | Where |
|---|---|
| The app | user unit menu-<site>.service on emily, container menu-app.<site>, image registry.tanshu.com/menu-admin:<tag> |
| Menu content | ~/podman-data/menu-<site>/data — originals/, drafts/, published/ |
| Admin | https://<vhost>/admin |
| The password | ~/podman-data/menu-<site>/site.env (mode 600, never in git) |
| Categories | data/sections.json, edited in the admin |
| Brand and theme | config/<site>.site.json in this repo, baked into the image (the palette follows the Stitch system in docs/design/stitch/) |
Everything runs as the user tanshu, so every command below is run on emily as
that user. sudo is never needed, and never works for these units.
Everyday tasks
Publish a new menu. Sign in at /admin, find the Section, fill in "New Menu
Set" (label, optional valid-until date), Create Draft, then drop the photos in.
Drag to reorder — the order in the editor is the order customers see. Press
Preview to check it, then Publish. Publishing archives whatever was current.
Put an older menu back. Open the Section's "older menus" fold in the admin, open the set you want and press "Make current". Its URL does not change.
Change or reset the admin password. Run the provision script again with
--force: it overwrites the password hash, and generates a fresh session secret,
which signs out anyone who was signed in. Nothing else changes.
ssh -t emily bash /tmp/provision-site-env.sh ~/podman-data/menu-hopsngrains --force
ssh emily systemctl --user restart menu-hopsngrains
There is no minimum length and no password policy — the one rule is that the password cannot be empty. It can also be set from a machine that has this repo checked out, with the prompt on that machine rather than the server:
cd ~/programming/menu-admin
read -r -s -p 'Admin password: ' PW; echo
HASH=$(printf '%s' "$PW" | node -e "let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>s+=c).on('end',async()=>{const m=await import('./src/auth.ts');process.stdout.write(await m.hashPassword(s))})")
SECRET=$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")
printf 'ADMIN_PASSWORD_HASH=%s
SESSION_SECRET=%s
' "$HASH" "$SECRET" | ssh emily "podman run --rm -i --userns=keep-id:uid=1000,gid=1000 -v ~/podman-data/menu-hopsngrains:/data --entrypoint sh registry.tanshu.com/menu-admin:2026-09-22 -c 'umask 077 && cat > /data/site.env'"
ssh emily systemctl --user restart menu-hopsngrains
Add, rename, reorder or remove a category. In the admin, open the Categories fold at the bottom of the dashboard. A new category starts empty, so customers do not see it until you publish a menu into it. Reordering there is the order customers see. Two rules: a category's web address is fixed when it is created (rename the name as often as you like — a printed QR code keeps working), and a category can only be removed once it holds no menus.
Change the restaurant's name or tagline, the icon, or the published dates. In the admin, open the Site identity fold: the name and tagline customers see, a checkbox for whether a menu says when it was published, and an SVG upload for the icon (under 256 KB; the upload replaces the bundled mark and "Remove the uploaded icon" brings it back). None of this changes your web addresses.
Change the colours. Those are still config/<site>.site.json, so they need a
rebuild and push (./deploy.sh <tag>) and the playbook with the new tag.
Fix a missing image. If a Page's thumbnail or viewing image is lost while its Original survives, the app can rebuild them:
podman run --rm --userns=keep-id:uid=1000,gid=1000 -e MENU_ROOT=/data \
-e MENU_SITE_CONFIG=/app/config/hopsngrains.site.json \
-v ~/podman-data/menu-hopsngrains:/data \
--entrypoint node registry.tanshu.com/menu-admin:<tag> src/cli/repair.ts
src/cli/verify.ts does the same inventory without changing anything, and is
what the server refuses to boot on. Both need the --userns=keep-id mapping, or
they would see the files as a different user and fail to write.
When something looks wrong
| Symptom | Look here |
|---|---|
| Landing page says the menus are being updated | no Section has a Current Menu Set — publish one in the admin |
| A Section 404s | that Section has no Current set, or the URL is misspelled |
| Sign-in rejects the right password | podman logs menu-app.<site>, then check site.env exists and is owned by tanshu |
| Admin is missing entirely | site.env was unreadable at boot, so the admin is disabled: run the provision script and systemctl --user restart menu-<site> |
| A photo uploads but looks wrong | the browser shrinks to 3000px before upload; that is expected, and the Original is kept |
| The container will not start | systemctl --user status menu-<site> and podman logs menu-app.<site> — a fatal integrity problem makes it refuse to serve a data directory it cannot trust |
| The unit is "not found" | its .network file is missing, so Quadlet refused to generate it: check ~/.config/containers/systemd/ |
| Caddy reload failed during a deploy | nothing is down: Caddy validates before applying. Read the error in the playbook output |
| Everything is slow | check the tag is current; a Viewing image is ~1600px, so a Section page should not be megabytes |
Logs. podman logs -f menu-app.<site>, or journalctl --user -u menu-<site> -f for the unit. The app logs JSON lines, one per event, including every upload,
publish, restore and failed sign-in.
Restarting. systemctl --user restart menu-<site>. A reload of the Caddy
config is podman exec caddy caddy reload --config /etc/caddy/Caddyfile, which
does not drop the other sites.
Checking from outside. No sign-in, no writes, exits non-zero on failure, so it can gate a cutover:
npm run check-site -- https://menu.hopsngrains.com
Backups
The app is the only writer. The irreplaceable part is
~/podman-data/menu-<site>/data/originals — the photos exactly as uploaded, from
which everything else can be regenerated with repair. Back that directory up;
site.env is worth keeping too (otherwise you re-provision the password).
Because of keep-id, everything there is owned by tanshu, so a plain
tar/rsync of ~/podman-data/menu-<site> is complete — no subuid-owned files
to trip over and no podman unshare needed.
Rolling back
Deploy the previous tag — ./deploy.sh <previous-tag> — which rebuilds the Site
at that version. podman logs menu-app.<site> is where a container that will not
start says why.
The Docker-era container was left on monoco, stopped and not removed, so the
last resort is to start it again and point Caddy back at it.