🏗️ 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¶
- One file, three users:
build/Dockerfilebuilds the local stack (run-local.ps1), the production image (deploy.ps1) and, on a server withoutDEPLOY_REGISTRY, the.buildunits. - Inputs, most volatile last:
Cargo.toml Cargo.lock→.sqlx/→migrations/→src/, so an edit insrc/reuses every layer above it. - Pinned bases:
RUST_VERSION,NODE_VERSIONandDEBIAN_TAGcome from the app's env file as--build-arg. Nothing floats. - The revision inside:
APP_REVISION,PLATFORM_REVISIONandBUILD_TIMEare the runtime stage's lastENV, so/api/versionnames exactly what was built. - Sidecars (MediaScope's
tmdb, TripWise'sgmapsandosrm) are more stages of the same file, published under the same tag.
💻 Two dev machines, one image¶
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 chefline 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 curlwith 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
libsmbclientcross-links through Debian multiarch (libsmbclient-dev:arm64); every other crate is pure Rust or bundles its C.
♻️ What is shared¶
- 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
RUNfirst touches every.rsof the platform and the app. Cargo does not track.sqlx/ormigrations/, 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, anddockerd --validateaccepts 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
-Updateagain whenever the canonical copy changes. Nevercargo cleanwithout-pin a repo: it deletes the shared folder for every app — PodmanClean's Native builds option clears it instead.
🔒 One dependency set: fleet-lockfiles¶
- 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 treeinside the fleet's Rust image. Any drift is exit 1. -Update: insidefleet-gate:<RUST_VERSION>, never with the dev machine's own cargo, each crate runscargo updateand its gates against one shared volume,fleet-lock-cargo. Files no crate read for a day are swept.- When: every full
/fleet-rolloutruns-Updateafter 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¶
| 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/*.buildunits.
⚙️ What podmanctl does¶
- 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/runningorrefs/deploy/failedat the commit in the bare repo;git ls-remote origin 'refs/deploy/*'reads it back. - Install and commands: Podman — Push deploys.