refactor(dev): convert dev-* recipes to Docker Compose #115

Merged
David merged 1 commit from feat/dev-recipes-to-compose into main 2026-05-15 11:45:48 +02:00
Owner

Summary

Move every dev-* recipe onto Docker Compose, with a 1:1 mapping from recipe name to compose file name. Fixes the panic-invisible footgun in dev/server-up (bare docker run --rm --detach swept the container off the moment the server panicked, leaving the operator with no logs - the SQLite CANTOPEN regression on /data was hidden behind it).

Just recipe Compose file Purpose
just dev-web compose.dev-web.yml Production-shape build via ci-build/Dockerfile.web behind Traefik
just dev-web-saas compose.dev-web-saas.yml Same with BUILD_MODE=saas
just dev-web-local compose.dev-web-local.yml cargo run from source on 127.0.0.1:18080
just dev-web-local-saas compose.dev-web-local-saas.yml Same with --no-default-features --features saas
just dev-desktop compose.dev-desktop.yml Tao+Wry desktop binary with X11/Wayland forwarding

Each start recipe gets a matching -down, and where data volumes exist, a -clean recipe.

Why this fixes "the server doesn't start"

Compose's default keeps the container around after exit, so docker compose logs app always has the real error and docker compose down is the explicit teardown. The bare docker run --rm --detach in dev/server-up swept the container off the moment the server panicked, leaving the operator with no logs.

The compose.dev-web-local{,-saas}.yml and compose.dev-desktop.yml files also mirror dev/cargo's chown-then-setpriv pattern: the container starts as root, idempotently chowns the named volumes to $HOST_UID:$HOST_GID (passed in by the just recipe from id -u / id -g), then setpriv --reuid --regid --clear-groups drops to the host user before exec'ing cargo. That's what unblocks the SQLite CANTOPEN on a fresh data volume.

Compose interpolation gotcha

Bash variable references in the inline entrypoints have to be written as $$VAR so they survive Compose's own interpolation pass and reach bash literal at container runtime. Without the escape, for d in ...; do ... "$d" ... prints stat: cannot statx '': No such file or directory because Compose ate $d first. Comment in each of the three local-* / desktop compose files points at compose.dev-web-local.yml as the canonical reference.

Cleanup

  • compose.dev.yml removed (renamed-with-BUILD_MODE-pinned successors are compose.dev-web.yml + compose.dev-web-saas.yml).
  • dev/server-logs removed (unused; docker compose --file <file> logs --follow replaces it).
  • dev/server-up and dev/server-down are kept because just verify (in the [test] group, out of scope here) still uses them. Converting verify is a clean follow-up.

Test plan

  • just dev-web-local end-to-end: compiles the server inside ghcr.io/niceguyit/rust-builder-glibc:v1.0.0-rust1.94-trixie, chowns the new data volume, drops to host uid via setpriv, the server starts, and curl http://127.0.0.1:18080/login returns 200.
  • just dev-web-local-down removes the container cleanly.
  • docker compose --file <file> config --quiet parses cleanly on all five new compose files.
  • just dev-web against a fresh checkout still builds the production image and is reachable at https://${USER}-chat.a8n.run through the Traefik network.
  • just dev-desktop against a running dev-web-local opens the lets-chat window on the host's desktop (X11 or Wayland).
  • just dev-web-saas produces the saas binary and serves at the same Traefik host without colliding with a parallel dev-web because the compose project names are now distinct.

Migration note

Existing developers have data in the chat-${USER}_lets-chat-data volume from the old compose.dev.yml project name. The new dev-web-${USER} project creates a fresh dev-web-${USER}_lets-chat-data, so the old dev data is orphaned (not deleted - docker volume ls | grep chat- still shows it). If you want it back, docker run --rm -v chat-${USER}_lets-chat-data:/old -v dev-web-${USER}_lets-chat-data:/new alpine cp -a /old/. /new/ moves it over. If you don't care (dev data is usually throwaway), no action needed.

## Summary Move every `dev-*` recipe onto Docker Compose, with a 1:1 mapping from recipe name to compose file name. Fixes the panic-invisible footgun in `dev/server-up` (bare `docker run --rm --detach` swept the container off the moment the server panicked, leaving the operator with no logs - the SQLite `CANTOPEN` regression on `/data` was hidden behind it). | Just recipe | Compose file | Purpose | |----------------------------|------------------------------------|---------| | `just dev-web` | `compose.dev-web.yml` | Production-shape build via `ci-build/Dockerfile.web` behind Traefik | | `just dev-web-saas` | `compose.dev-web-saas.yml` | Same with `BUILD_MODE=saas` | | `just dev-web-local` | `compose.dev-web-local.yml` | `cargo run` from source on `127.0.0.1:18080` | | `just dev-web-local-saas` | `compose.dev-web-local-saas.yml` | Same with `--no-default-features --features saas` | | `just dev-desktop` | `compose.dev-desktop.yml` | Tao+Wry desktop binary with X11/Wayland forwarding | Each start recipe gets a matching `-down`, and where data volumes exist, a `-clean` recipe. ## Why this fixes "the server doesn't start" Compose's default keeps the container around after exit, so `docker compose logs app` always has the real error and `docker compose down` is the explicit teardown. The bare `docker run --rm --detach` in `dev/server-up` swept the container off the moment the server panicked, leaving the operator with no logs. The `compose.dev-web-local{,-saas}.yml` and `compose.dev-desktop.yml` files also mirror `dev/cargo`'s chown-then-setpriv pattern: the container starts as root, idempotently chowns the named volumes to `$HOST_UID:$HOST_GID` (passed in by the just recipe from `id -u` / `id -g`), then `setpriv --reuid --regid --clear-groups` drops to the host user before exec'ing cargo. That's what unblocks the SQLite `CANTOPEN` on a fresh `data` volume. ## Compose interpolation gotcha Bash variable references in the inline entrypoints have to be written as `$$VAR` so they survive Compose's own interpolation pass and reach bash literal at container runtime. Without the escape, `for d in ...; do ... "$d" ...` prints `stat: cannot statx '': No such file or directory` because Compose ate `$d` first. Comment in each of the three local-* / desktop compose files points at compose.dev-web-local.yml as the canonical reference. ## Cleanup - `compose.dev.yml` removed (renamed-with-`BUILD_MODE`-pinned successors are `compose.dev-web.yml` + `compose.dev-web-saas.yml`). - `dev/server-logs` removed (unused; `docker compose --file <file> logs --follow` replaces it). - `dev/server-up` and `dev/server-down` are kept because `just verify` (in the `[test]` group, out of scope here) still uses them. Converting verify is a clean follow-up. ## Test plan - [x] `just dev-web-local` end-to-end: compiles the server inside `ghcr.io/niceguyit/rust-builder-glibc:v1.0.0-rust1.94-trixie`, chowns the new `data` volume, drops to host uid via setpriv, the server starts, and `curl http://127.0.0.1:18080/login` returns 200. - [x] `just dev-web-local-down` removes the container cleanly. - [x] `docker compose --file <file> config --quiet` parses cleanly on all five new compose files. - [ ] `just dev-web` against a fresh checkout still builds the production image and is reachable at `https://${USER}-chat.a8n.run` through the Traefik network. - [ ] `just dev-desktop` against a running `dev-web-local` opens the lets-chat window on the host's desktop (X11 or Wayland). - [ ] `just dev-web-saas` produces the saas binary and serves at the same Traefik host without colliding with a parallel `dev-web` because the compose project names are now distinct. ## Migration note Existing developers have data in the `chat-${USER}_lets-chat-data` volume from the old `compose.dev.yml` project name. The new `dev-web-${USER}` project creates a fresh `dev-web-${USER}_lets-chat-data`, so the old dev data is orphaned (not deleted - `docker volume ls | grep chat-` still shows it). If you want it back, `docker run --rm -v chat-${USER}_lets-chat-data:/old -v dev-web-${USER}_lets-chat-data:/new alpine cp -a /old/. /new/` moves it over. If you don't care (dev data is usually throwaway), no action needed.
refactor(dev): convert dev-* recipes to Docker Compose
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m7s
39a579b1cb
The dev/* tooling was a mix: `just dev-web` / `dev-web-saas` already ran via Compose against `compose.dev.yml`, but `dev-web-local` / `dev-web-local-saas` shelled out to `dev/server-up` (bare `docker run --rm --detach`) and `dev-desktop` shelled out to `dev/cargo-desktop run`. The bare `docker run` path silently swept the container off the moment the server panicked at startup, leaving the operator with no logs - the recent SQLITE_CANTOPEN regression on `/data` was hidden behind this until I dug through `docker inspect` to find that the container was already gone.

Move every `dev-*` recipe onto Compose, with a 1:1 mapping from recipe name to file name so the relationship is obvious without having to grep the justfile:

| Just recipe              | Compose file                       |
|--------------------------|------------------------------------|
| `just dev-web`           | `compose.dev-web.yml`              |
| `just dev-web-saas`      | `compose.dev-web-saas.yml`         |
| `just dev-web-local`     | `compose.dev-web-local.yml`        |
| `just dev-web-local-saas`| `compose.dev-web-local-saas.yml`   |
| `just dev-desktop`       | `compose.dev-desktop.yml`          |

Compose's default behaviour (no `--rm` on the service container) keeps the container around after exit, so `docker compose logs app` always has the real error and `docker compose down` is the explicit teardown - directly addressing the panic-invisible footgun from `dev/server-up`. Every recipe gets a matching `-down` and (where data volumes exist) a `-clean` recipe.

### Compose file shapes

`compose.dev-web.yml` and `compose.dev-web-saas.yml` are the renames-with-BUILD_MODE-pinned successors to `compose.dev.yml`: each builds the production multi-stage `ci-build/Dockerfile.web` with the right `BUILD_MODE` arg, attaches to the external `network-traefik-public`, carries the Traefik labels for `${USER}-chat.a8n.run`, and binds the `lets-chat-data` volume. Keeping them as two separate files (instead of one parameterised file as before) is what gives `just dev-web` and `just dev-web-saas` distinct compose project names so both can run simultaneously without colliding on `container_name`.

`compose.dev-web-local{,-saas}.yml` are the compose equivalents of the deleted `dev/server-up`. They run `cargo run -p lets-chat-server` (the saas variant adds `--no-default-features --features saas`) inside `ghcr.io/niceguyit/rust-builder-glibc:v1.0.0-rust1.94-trixie`, bind-mount the repo at `/work`, persist `/usr/local/cargo/{registry,git}`, `/cargo-target`, and `/data` as named volumes, and publish container port 8080 on `127.0.0.1:18080`. An inline entrypoint mirrors `dev/cargo`'s chown-then-setpriv dance: container starts as root, idempotently chowns the named volumes to `$HOST_UID:$HOST_GID` (passed in by the just recipe from `id -u` / `id -g`), then `setpriv --reuid --regid --clear-groups` drops to the host user before exec'ing cargo. This is what unblocks the SQLite `CANTOPEN` you'd hit yesterday on a fresh `lets-chat-rewrite-data` volume.

`compose.dev-desktop.yml` shares the same cargo volumes as the local-server files (so a `cargo` triggered by either picks up the other's compiled deps) and adds the X11/Wayland forwarding `dev/cargo-desktop` previously did imperatively in bash: `network_mode: host`, `ipc: host`, env-forwarded `DISPLAY` / `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR`, and bind-mounted `/tmp/.X11-unix` + `$XDG_RUNTIME_DIR`. Defaults assume a typical Linux desktop (uid 1000, DISPLAY=:0, /run/user/1000); users on non-standard setups can override any of them via their shell env.

### Compose interpolation gotcha

Bash variable references in inline entrypoints have to be written as `$$VAR` so they survive Compose's own interpolation pass and reach bash literal at container runtime. Got bit by this on the first smoke run - the `for d in ...; do ... "$d" ...` loop printed `stat: cannot statx '': No such file or directory` because Compose ate `$d` before bash ever saw it. All three local entrypoints carry a one-line comment pointing at compose.dev-web-local.yml as the canonical example.

### Cleanup

- `compose.dev.yml` deleted (superseded by the two `compose.dev-web*.yml` files).
- `dev/server-logs` deleted (unused; `docker compose --file compose.dev-web-local.yml logs --follow` replaces it).
- `dev/server-up` and `dev/server-down` are kept because `just verify` still uses them. Converting verify is a clean follow-up - it's in the `[test]` group not `[dev]`, so out of scope here.

### Verification

`just dev-web-local` smoke tested end to end: `cargo run` compiles the server inside the builder image, the entrypoint chowns the new `data` volume to the host uid, setpriv drops to that uid, the server starts, and `curl http://127.0.0.1:18080/login` returns 200. `docker compose --file <file> config --quiet` parses cleanly on all five new compose files. `just dev-web-local-down` removes the container cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
David merged commit 44eb986b15 into main 2026-05-15 11:45:48 +02:00
David deleted branch feat/dev-recipes-to-compose 2026-05-15 11:45:48 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!115
No description provided.