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:
1 parent
c412181159
commit
e68f8ab00d
23 files changed
+876
-389
No files matched your search
@@ -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
@@ -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.
|
||||
Reference in new issue
Block a user