Run the Sites as rootless Podman Quadlets, and build with podman

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.
This commit is contained in:
tanshu committed 2026-10-16 09:54:52 +05:30
1 parent c412181159
commit e68f8ab00d
23 files changed
+876 -389

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# Sites run as rootless Podman Quadlets behind a shared Caddy
Each Site is deployed as a **rootless Podman** container managed by a systemd
user unit (Quadlet) on `emily`, reached through the one shared Caddy unit rather
than a long-lived rootful Docker container. The image is built by `podman` too,
from a `Containerfile`. This matches how every other service on that host is
deployed, and it takes the deployment off Docker entirely.
The app owns its whole serving path, so it needed the smallest deployment of the
fleet: one `.container` file, one `.network` file, and a block appended to the
Caddyfile. Two details carry the design. `UserNS=keep-id:uid=1000,gid=1000` maps
the image's own `node` account onto the user that owns the units, which is what
keeps `data/` and `site.env` owned by a real host user — plain `tar` backups, no
subuids — and `scripts/provision-site-env.sh` uses the same mapping so both sides
agree on who owns the file. 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 has to be edited when a Site is added or removed.
**Considered Options**: staying on rootful Docker on `monoco`, rejected because
the fleet is moving and leaving one app behind means two runtimes and two Caddy
configs to reason about; running rootful Podman, rejected because it keeps the
same root-owned data and loses the plain-backup property; and naming the Quadlet
unit after the container (`menu-app.<domain>`), rejected because the dots make
the unit name awkward and the name fragile in DNS — the unit is `menu-<site>`
while the container keeps its old name for the scripts that look it up.
**Consequences**: the app is not published on a host port at all — only Caddy,
on the shared Podman network, can reach it — and deployment, logs and restarts
move from `docker` to `systemctl --user` / `podman`. The data root moves to
`~/podman-data/menu-<site>`, so each Site's content has to be copied across from
`/var/lib/menu.<domain>` once, before DNS points at it.
+43 -143
View File
@@ -2,19 +2,26 @@
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-playbooks/menu-admin/readme.md`.
`../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 | container `menu-app.<site>` on `monoco`, image `registry.tanshu.com/menu-admin:<tag>` |
| Menu content | `/var/lib/menu.<site>/data` — `originals/`, `drafts/`, `published/` |
| Admin | `https://<vhost>/admin` |
| The password | `/var/lib/menu.<site>/site.env` (mode 600, never in git) |
| Categories | `data/sections.json`, edited in the admin |
| 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
@@ -30,8 +37,8 @@ open the set you want and press "Make current". Its URL does not change.
which signs out anyone who was signed in. Nothing else changes.
```sh
ssh -t monoco bash /tmp/provision-site-env.sh /var/lib/menu.hopsngrains.com --force
ssh monoco docker restart menu-app.hopsngrains.com
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
@@ -45,8 +52,8 @@ HASH=$(printf '%s' "$PW" | node -e "let s='';process.stdin.setEncoding('utf8');p
SECRET=$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")
printf 'ADMIN_PASSWORD_HASH=%s
SESSION_SECRET=%s
' "$HASH" "$SECRET" | ssh monoco "docker run --rm -i --user 1000:1000 -v /var/lib/menu.hopsngrains.com:/data --entrypoint sh registry.tanshu.com/menu-admin:2026-09-22 -c 'umask 077 && cat > /data/site.env'"
ssh monoco docker restart menu-app.hopsngrains.com
' "$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
@@ -69,14 +76,15 @@ rebuild and push (`./deploy.sh <tag>`) and the playbook with the new tag.
Original survives, the app can rebuild them:
```sh
docker run --rm --user 1000:1000 -e MENU_ROOT=/data \
podman run --rm --userns=keep-id:uid=1000,gid=1000 -e MENU_ROOT=/data \
-e MENU_SITE_CONFIG=/app/config/hopsngrains.site.json \
-v /var/lib/menu.hopsngrains.com:/data \
-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.
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
@@ -84,15 +92,21 @@ what the server refuses to boot on.
| --- | --- |
| 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 | `docker logs menu-app.<site>`, then check `site.env` exists and is owned by the app user |
| Admin is missing entirely | `site.env` was unreadable at boot, so the admin is disabled: run the provision script and `docker restart` |
| 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 | `docker logs` — a fatal integrity problem makes it refuse to serve a data directory it cannot trust |
| 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.** `docker logs -f menu-app.<site>` — JSON lines, one per event, including
every upload, publish, restore and failed sign-in.
**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:
@@ -104,133 +118,19 @@ npm run check-site -- https://menu.hopsngrains.com
## Backups
The app is the only writer. The irreplaceable part is
`/var/lib/menu.<site>/data/originals` — the photos exactly as uploaded, from
`~/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).
## Rolling back
Deploy the previous tag — `./deploy.sh <previous-tag>` — which rebuilds the Site at
that version. `docker logs menu-app.<site>` is where a container that will not start
says why.
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-playbooks/menu-admin/readme.md`.
## Where things are
| Thing | Where |
| --- | --- |
| The app | container `menu-app.<site>` on `monoco`, image `registry.tanshu.com/menu-admin:<tag>` |
| Menu content | `/var/lib/menu.<site>/data` — `originals/`, `drafts/`, `published/` |
| Admin | `https://<vhost>/admin` |
| The password | `/var/lib/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/`) |
## 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.
```sh
ssh -t monoco bash /tmp/provision-site-env.sh /var/lib/menu.hopsngrains.com --force
ssh monoco docker restart menu-app.hopsngrains.com
```
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:
```sh
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 monoco "docker run --rm -i --user 1000:1000 -v /var/lib/menu.hopsngrains.com:/data --entrypoint sh registry.tanshu.com/menu-admin:2026-09-22 -c 'umask 077 && cat > /data/site.env'"
ssh monoco docker restart menu-app.hopsngrains.com
```
**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:
```sh
docker run --rm --user 1000:1000 -e MENU_ROOT=/data \
-e MENU_SITE_CONFIG=/app/config/hopsngrains.site.json \
-v /var/lib/menu.hopsngrains.com:/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.
## 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 | `docker logs menu-app.<site>`, then check `site.env` exists and is owned by the app user |
| Admin is missing entirely | `site.env` was unreadable at boot, so the admin is disabled: run the provision script and `docker restart` |
| 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 | `docker logs` — a fatal integrity problem makes it refuse to serve a data directory it cannot trust |
| 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.** `docker logs -f menu-app.<site>` — JSON lines, one per event, including
every upload, publish, restore and failed sign-in.
**Checking from outside.** No sign-in, no writes, exits non-zero on failure, so
it can gate a cutover:
```sh
npm run check-site -- https://menu.hopsngrains.com
```
## Backups
The app is the only writer. The irreplaceable part is
`/var/lib/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
The old nginx Site is kept, not deleted. To go 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.
```sh
docker start nginx-menu.hopsngrains.com
```
then point Caddy back at it — the cutover section of the playbook readme has the
exact invocation, including the flag that stops the app's snippet from being
re-added.
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.