Skip to content

🏗️ Build & deploy

An own app is compiled on a dev machine, never on the server. deploy.ps1 builds its linux/arm64 image with Docker Desktop, publishes it to the registry, pushes the commit and waits until production runs it. The server only pulls and restarts.


🧱 One Dockerfile, five stages

BUILD MACHINE'S CPU FROM --platform=$BUILDPLATFORM TARGET: linux/arm64 the image chef rust:<v>-slim-trixie build-essential · pkg-config chef-target TARGETARCH → triple + cross tools if foreign backend COPY platform crates Cargo.toml · .sqlx migrations · src cargo build --release --target cache mounts fleet-cargo-target-* fleet-cargo-registry shared by every app frontend node:<NODE_VERSION>-alpine · COPY platform packages · npm ci npm run build → dist/, the same bytes on any CPU runtime debian:<DEBIAN_TAG> apt-get ca-certificates tini curl uid 1000 · tini /usr/local/bin/<app> /app/static (the SPA) ENV APP_REVISION … registry.fum-server.fr <app>:<app sha> .<platform sha> binary
Only the runtime stage runs on the target's platform; everything that compiles runs on the build machine's own CPU.
  • One file, three users: build/Dockerfile builds the local stack (run-local.ps1), the production image (deploy.ps1) and, on a server without DEPLOY_REGISTRY, the .build units.
  • Inputs, most volatile last: Cargo.toml Cargo.lock → .sqlx/ → migrations/ → src/, so an edit in src/ reuses every layer above it.
  • Pinned bases: RUST_VERSION, NODE_VERSION and DEBIAN_TAG come from the app's env file as --build-arg. Nothing floats.
  • The revision inside: APP_REVISION, PLATFORM_REVISION and BUILD_TIME are the runtime stage's last ENV, so /api/version names exactly what was built.
  • Sidecars (MediaScope's tmdb, TripWise's gmaps and osrm) are more stages of the same file, published under the same tag.

💻 Two dev machines, one image

ARM64 LAPTOP Windows on ARM · Docker Desktop chef native nothing extra to install backend native rustc aarch64 out of the box frontend native Vite bundle runtime native apt-get at full speed X86_64 DESKTOP Windows 11 · Docker Desktop chef native x86 + crossbuild-essential-arm64 backend cross rustc x86 compiler → aarch64 binary frontend native x86 same bytes as on ARM runtime emulated (QEMU) RUN apt-get: slow, cached once Same image either way: deploy.ps1 checks its platform is linux/arm64 and its binary's ELF header says aarch64 before any push.
The same docker build --platform linux/arm64 on both machines.
RefApp, measured 2026-10-03 ARM64 laptop x86_64 desktop
rustc native cross-compiles for aarch64
runtime stage's RUN native emulated by QEMU, cached once per machine
cold build 46 s 4 min 32 s
warm rebuild seconds 1 s
  • Before cross-compiling, an x86 build emulated rustc too: 47 minutes for RefApp, worse than the ~20 minutes the ODROID itself took.
  • How deploy.ps1 knows: a FROM --platform=$BUILDPLATFORM … AS chef line marks a Dockerfile that cross-compiles. Without it, a foreign build would emulate everything, and deploy.ps1 asks before starting.
  • The emulated part: 9 of the 11 runtime images install ca-certificates tini curl with the same line, so it is one cached layer per machine. MediaScope (ffmpeg, libsmbclient) and LightFlow (python, postgresql-client) install their own.
  • Native C libraries: MediaScope's libsmbclient cross-links through Debian multiarch (libsmbclient-dev:arm64); every other crate is pure Rust or bundles its C.

♻️ What is shared

DOCKER BUILDS deploy.ps1 · run-local fleet-cargo-target-<rust> compiled crates at /app/target a Rust bump starts a new one fleet-cargo-registry downloaded crate sources 40 GB build cache Docker Desktop's gc policy keeps the runtime apt layers and both mounts warm ONE DEPENDENCY SET what makes it pay same versions 12 lockfiles aligned · fleet-lockfiles same features platform-app declares the union same profile cargo's default release profile rustls only, no OpenSSL RUST_VERSION pinned in every Dockerfile NATIVE BUILDS the gates on Windows <tree>\cargo-build one build-dir for every repo from <tree>\.cargo\config.toml ~67 GB → ~4 GB of per-repo target\ folders each target\ keeps hard links never cargo clean without -p: it empties the shared folder for every repo
Two caches, one for Docker builds and one for native ones, both paid for by one dependency set.
  • Compiled once for the fleet: every Rust stage mounts the same two caches, so a crate built for one app is reused by the next. A fleet rebuild after a release went from 307 s to 181 s, and the build cache from 38 GB to 12 GB (x86 desktop, 2026-10-04).
  • The stamp: the build RUN first touches every .rs of the platform and the app. Cargo does not track .sqlx/ or migrations/, and a platform reverted to older code must not reuse the newer build the cache holds.
  • Native gates (cargo clippy, build, test) share <tree>\cargo-build, a folder no Docker build sees.
🐳 Dev machine — keep Docker Desktop's build cache

Every own app builds on the dev machine, and its warm cache is what makes a rebuild take seconds: the shared toolchain stages, the shared cargo cache and the runtime stages' apt-get layers, the only steps an x86 machine still emulates. Docker Desktop's default policy drops build cache unused for 48 h; this one keeps up to 40 GB. Paste it under Settings → Docker Engine (or into %USERPROFILE%\.docker\daemon.json), keeping the keys already there, then Apply & restart:

{
  "builder": {
    "gc": {
      "defaultKeepStorage": "40GB",
      "enabled": true,
      "policy": [
        { "filter": ["unused-for=48h", "type=source.local"], "reservedSpace": "5GB" },
        { "filter": ["unused-for=1440h"], "reservedSpace": "40GB" },
        { "reservedSpace": "40GB" },
        { "all": true, "reservedSpace": "40GB" }
      ]
    }
  }
}

Each filter key appears at most once per entry: a repeated type= stops the engine or breaks every collection, and dockerd --validate accepts both.

🦀 Dev machine — one shared cargo build folder

The gates (cargo clippy, build, test) compile natively on the dev machine. One .cargo\config.toml at the root of the folder holding every repo (D:\Sources\podman) gives them a single build-dir, cargo-build beside the repos, and a lean dev profile: a dependency compiles once for the whole fleet instead of once per app (~67 GB of per-repo target\ down to ~4 GB). Docker builds never see it. Its canonical copy is embedded in shared-core's scripts/fleet-deploy-script.ps1; from the shared-core clone, check it, then install it:

./scripts/fleet-deploy-script.ps1 -Script cargo           # MISSING, DRIFT or OK
./scripts/fleet-deploy-script.ps1 -Script cargo -Update   # writes <tree>\.cargo\config.toml

Run -Update again whenever the canonical copy changes. Never cargo clean without -p in a repo: it deletes the shared folder for every app — PodmanClean's Native builds option clears it instead.


🔒 One dependency set: fleet-lockfiles

12 LOCKFILES refapp cardbox claudenames kouizine lightflow mediascope + tmdb pooping tripwise + gmaps + osrm platform workspace REPORT always versions one version per crate series profiles the same [profile.*] lines features cargo tree in fleet-gate -UPDATE after every full rollout's apply fleet-gate:<rust> rust:<v>-slim-trixie + clippy · rustfmt + smbclient, clang per crate cargo update clippy -D warnings build · test fmt --check fleet-lock-cargo one shared volume files unread for a day are swept OUTCOME exit 0 aligned: the app can ship exit 1 any drift: the rollout holds it back -Commit one chore commit per changed repo
The report runs before every ship; the update runs once per full release.
  • Why: the shared cache only hits when two apps compile a crate with the same version, the same features and the same profile.
  • Report: version drift per crate series (Windows-only and wasm shims ignored), profile drift, and feature drift read with cargo tree inside the fleet's Rust image. Any drift is exit 1.
  • -Update: inside fleet-gate:<RUST_VERSION>, never with the dev machine's own cargo, each crate runs cargo update and its gates against one shared volume, fleet-lock-cargo. Files no crate read for a day are swept.
  • When: every full /fleet-rollout runs -Update after its apply and the report before its ship; a drifting app is not shipped. Otherwise never casually: every app then recompiles the crates that moved.
# from the shared-core checkout
./scripts/fleet-lockfiles.ps1                       # the report: exit 0 aligned, 1 drift
./scripts/fleet-lockfiles.ps1 -Update -Commit       # refresh, gate, commit each repo's lockfile

🚀 Life of a commit

DEV MACHINE deploy.ps1 REGISTRY ODROID-C4 hook · podmanctl refuse early uncommitted app changes · a diverged branch an unpushed or dirty platform no docker login for the registry nothing to push? production already runs HEAD → exit 3 docker build, every image --platform linux/arm64 · --build-context platform labels: the app and platform revisions check every image platform = linux/arm64 binary's ELF header = aarch64 older local tags removed docker push <app>:<app sha>.<platform sha> git push fires the bare repo's hook wait for production every 10 s, up to 10 min names the commit → exit 0 DEPLOYED it does not → exit 4 NOT RUNNING post-receive hook podmanctl --deploy <app> <sha> queued, then running /run/lock/fleet-deploy.lock update git pull --rebase · re-own the clone no image? refuse, nothing stopped stop · pull · labels · tag · start settle nginx/ changed? nginx -t, then reload receipt: refs/deploy/running or refs/deploy/failed log: /var/log/<app>-deploy.log the running app /api/version names the commit and the platform release stored every tag kept pull meanwhile on the server GET /api/version
Publish first, push second: the hook can never fire for an image that does not exist.
Exit Means Output starts with
0 built, published, deployed and running DEPLOYED
1 refused or failed the reason
3 nothing to deploy: production runs HEAD, or cannot say SKIPPED: / UNCONFIRMED:
4 the commit is on the server but production runs another NOT RUNNING: + the one command left
deploy.ps1 switches
Switch Effect
-NoPush build and check only
-NoGitPush build and publish the images, push nothing
-NoWait push, then return without asking production
-Yes build a Dockerfile that does not cross-compile without asking
-Platform the target, linux/arm64 by default
-EnvFile / -ProductionUrl override <service>.env / the vhost's https://<fqdn>
-Help print these switches and the exit codes

The script is byte-identical in the eight repos (RefApp's is the canonical copy) and names no app: it reads the images to build from the repo's own quadlet/*.build units.


⚙️ What podmanctl does

git pull re-own the clone DEPLOY_REGISTRY set pull · verify labels · tag :latest own apps · platform .build units podman build on the server immich · calibre · vaultwarden · mkdocs neither restart on the pinned images gokapi · planka · registry start units reset-failed first nginx -t reload if nginx/ changed receipt refs/deploy/running or refs/deploy/failed
One script on the server, three ways to update a service, the same ending for all.
  • One deploy at a time: every service takes /run/lock/fleet-deploy.lock. A push during a deploy waits, then deploys the branch head.
  • Refused before the stop: a missing image stops nothing. A published image whose labels name another commit is caught after the stop: the stack starts again on its previous image and the deploy fails.
  • The receipt: every update points refs/deploy/running or refs/deploy/failed at the commit in the bare repo; git ls-remote origin 'refs/deploy/*' reads it back.
  • Install and commands: Podman — Push deploys.