Seven own web apps share one platform : a Rust + React stack whose apps are local-first
PWAs. They are built on a Windows dev machine, published to a private registry , and
deployed by git push to the ODROID-C4. shared-core runs the fleet. This tab shows how
it fits together; the install runbooks are under Services and Apps .
🚀 One deploy, end to end
DEV MACHINE
Windows 11
docker build
linux/arm64 · native or cross
tag <app sha>.<platform sha>
1
git push
the commit the image names
wait
until /api/version names it
deploy.ps1 runs 1 → 7
app repo + sibling ../platform
USERS
browser · phone
installed PWA, works offline
ODROID-C4
Debian trixie · arm64 · rootless podman
NGINX
443 · TLS
headers
rate limits
one vhost
per site
Registry
127.0.0.1:10090
every tag kept
bare repo
post-receive hook
podmanctl
pull · re-own · restart
<app> service
quadlet units · systemd --user · <app>_svc
app
Rust + SPA
:100xx
postgres
data/postgres-db
sidecars
tmdb · gmaps
osrm
data/ bind mounts under /media/ssd/podman/<app>
restarts with backoff · a failed healthcheck kills it
one Linux user and podman store per service
2
3
4
5
6
7
HTTPS
127.0.0.1
:100xx
One deploy: the image is published first, the commit pushed second, production checked last.
Build : deploy.ps1 builds the app's linux/arm64 image on the dev machine. The
platform crates compile into the binary inside it (Build & deploy ).
Publish : it pushes the image to the registry, tagged <app sha>.<platform sha>.
Push : then, and only then, it git pushes the commit.
Hook : the bare repo's post-receive hook runs podmanctl --deploy <app> <sha>.
Pull : podmanctl pulls the clone, then the image. A missing image refuses the deploy
before anything stops.
Restart : it restarts the app's units on the new image, and reloads nginx only when
nginx/ changed.
Wait : deploy.ps1 polls /api/version until it names the pushed commit.
Every step, check and exit code: the life of a commit .
🧩 The four pieces
PLATFORM REPO
..\platform · 0.26.0
EACH OWN APP
7 repos
crates/
8 Rust crates · platform-*
src/backend
the Rust binary
path deps
packages/
7 npm packages · @platform/*
src/frontend
the React SPA
file: deps
contracts/
the rules every app follows
CLAUDE.md
reads the rules, never the source
read
conformance/
HTTP + browser suites
local stack
run-local.ps1 + its nginx
checks
refapp/
golden app · service platform
new app
/new-app copies refapp
seeds
What : 8 Rust crates, 7 npm packages, 12 contracts, the conformance suites and
RefApp , the golden app (service platform).
How : an app takes the crates by path and the packages by file: against the sibling
clone; its container build gets the clone as --build-context platform=…. Nothing is
published to crates.io or npm.
Version : PLATFORM_VERSION (0.26.0) names the release; each app records the one it
runs in PLATFORM_ADOPTED, and its /api/health names it.
More : Crates & tooling · the Platform service
📦 Registry: the image store
DEV MACHINE
docker push
registry.fum-server.fr
after docker login
one tag per deploy
<app>:<app sha>.<pf sha>
sidecars share it
e.g. mediascope-tmdb
ODROID-C4
NGINX
TLS
basic auth
/v2/ only
registry-server
distribution (registry:2)
127.0.0.1:10090 → :5000
data/registry on the SSD
registry-gc.timer
Sun 04:00 · stop, sweep, start
podmanctl
as <app>_svc
pulls on loopback
checks the labels
refuses
nothing published →
nothing stopped
pull
What : the upstream distribution registry, one container on 127.0.0.1:10090.
Why : the ODROID never compiles an own app. A cold build there took about 20 minutes
per app.
How : dev machines push to registry.fum-server.fr (TLS + basic auth); podmanctl
pulls on the loopback address and checks the image's two revision labels.
Rule : DEPLOY_REGISTRY in an app's env file opts it in. Once set, a missing image
refuses the deploy instead of building; deleting the key on the server is the rollback.
More : the Registry service
🧠 shared-core: the control room
SHARED-CORE
the fleet's control room
knows
fleet.json
own-projects.md
cloned-projects.md
CLAUDE.md
memory/
checks
fleet-check.ps1
nginx-check.ps1
fleet-lockfiles.ps1
fleet-deploy-script.ps1
fleet-version.ps1
runs
/fleet-rollout
/new-app
start-rollout.ps1
records
rollouts/<version>.md · .fleet/ plans and logs · nonregression-plan.md
EVERY REPO
15 + docs
7 own apps + platform
deploy.ps1 · run-local.ps1
dev-nginx.psm1 copies
7 cloned apps
their own deploy.ps1 and
run-local.ps1 family
byte-identical
fleet-deploy-script.ps1
compares each copy with
its canonical one
What : no application code. It holds the inventories, fleet.json, the fleet
scripts, the rollout commands, the rollout records and the sessions' memory.
How : its scripts read fleet.json and act on the sibling clones; the rollout
session runs in it.
Rule : app repos stay autonomous. Shared files are byte-identical copies in each repo,
checked by fleet-deploy-script.ps1, and nothing at runtime depends on shared-core.
More : Crates & tooling ·
Release & rollout
🧱 Own apps: one shape, seven times
IN THE BROWSER
installed PWA
app pages
React 19 · TypeScript · lucide icons
@platform/*
sync · offline · events
auth-ui · users-ui · api-core
service worker · IndexedDB store
outbox · file queue
on the device
rows of every collection
eager images · the shell
unsent edits and files
ON THE SERVER
one container
app routes
axum · tokio · sqlx
platform-* crates
auth · sync · files · events
http · mail · app · http-client
sessions · MFA · error envelope
cache tiers · security headers
postgres
the app's tables
users · sessions · shares
files under data/
REST /api/*
POST /api/sync
SSE /api/events
same origin · nginx in front
build/Dockerfile → one image
quadlet/ → the units
nginx/<fqdn> → the vhost
deploy.ps1 → ships it
What : an axum + tokio + sqlx server on PostgreSQL, behind a React 19 + Vite +
TypeScript PWA, one repo per app.
How : the UI reads and writes the device's store; @platform/sync reconciles it with
the server in one POST /api/sync round (Offline & sync ).
Packaging : build/Dockerfile, quadlet/, nginx/<fqdn>, deploy.ps1,
run-local.ps1 and its own e2e/ suite.
Pages : CardBox · ClaudeNames ·
Kouizine · LightFlow ·
MediaScope · PooPing ·
TripWise
🖥️ What runs on the server
ODROID-C4
nginx → 127.0.0.1:<port> → containers, one Linux user per service
NGINX
443
one vhost
per site
TLS
rate limits
SSE · WS
kouizine
10050
server
postgres
kouizine_svc
mediascope
10055
server
postgres
tmdb
mediascope_svc · Network=host
tripwise
10060
server
postgres
gmaps
osrm
tripwise_svc
claudenames
10065
server
postgres
claudenames_svc · WebSockets
lightflow
10070
server
postgres
lightflow_svc · runs the steps
cardbox
10075
server
postgres
cardbox_svc
pooping
10080
server
postgres
pooping_svc
platform
10085
server
postgres
platform_svc · RefApp
registry
10090
registry
registry_svc · the images
cloned apps (Web Servers tab)
mkdocs 10000
vaultwarden 10005
planka 10010
immich 10015
calibre 10025
gokapi 10045
nginx terminates TLS for every site and proxies to 127.0.0.1:<port>; no container
port is reachable from outside.
One service, one Linux user : <app>_svc runs its own rootless podman, its units are
systemd --user quadlets, and its PostgreSQL is its own.
Self-healing : a failed healthcheck kills the container; systemd restarts it with a
backoff from 10 s to 300 s and gives up after 6 failures in 30 minutes.
🗺️ Where things live
DEV MACHINE
Windows 11
D:\Sources\podman\<repo>
sibling clones (the other machine: D:\Source)
<repo>\<app>.env
gitignored · DEPLOY_REGISTRY=registry.fum-server.fr
D:\Sources\podman\cargo-build
every native cargo run's build folder
Docker Desktop
the images · fleet-cargo-* cache mounts
%USERPROFILE%\.docker\config.json
the registry login
D:\Sources\web\mkdocs\docs.fum-server.fr
this site
ODROID-C4
Debian trixie
/media/ssd/git/podman/<app>.git
bare repo · post-receive · refs/deploy/*
/media/ssd/podman/<app>
the clone: quadlet/ nginx/ data/ <app>.env
/media/ssd/podman-users/<app>
<app>_svc's home: units link into quadlet/
/media/ssd/podman/registry/data
the published images
/etc/nginx/sites-enabled/<fqdn>
a link into the clone's nginx/
/usr/local/bin/podmanctl
one script, one sudoers rule
/var/log/<app>-deploy.log
every deploy · /run/lock/fleet-deploy.lock
git push
docker push
The dev tree's root differs per machine (D:\Sources, D:\Source): every path between
repos is relative.
Every real .env is gitignored. The server's copy decides how a deploy runs, so a new
key goes there before the push that needs it.
The server's units and vhost are links into the clone: a git pull updates them.