Backup, Upgrades and Reproducibility of a Model Estate
By the end of this lesson you will be able to say which parts of your model estate you could lose without much regret and which would take months to reconstruct, take a backup of the second category that you have actually tested, pin every engine to something that cannot move underneath you, upgrade one with a rollback you tested before you needed it, and hand a new machine enough information to become this one.
The test for all of it is a single question: if this machine’s disk failed tonight, what would you be unable to get back?
What is actually irreplaceable
Section titled “What is actually irreplaceable”The instinct is to back up the largest thing, which is the model library. That is almost exactly backwards.
| What | Replaceable? | What it really costs to lose |
|---|---|---|
| Model weights | Yes, if you recorded the repository, revision and hash | A download, and an afternoon if the file was later withdrawn |
| Engine binaries and images | Yes, if you pinned a version or a digest | A rebuild; much worse if you only recorded “latest” |
| Gateway and monitoring configuration | Only from memory | Days of rediscovering settings that took you months to converge on |
| Fine-tuned adapters from Part 13 | No | The training run, the data preparation, and the hyperparameters you no longer have |
| Your own evaluation sets from Parts 10 and 16 | No | The most valuable thing on the machine. Nobody else has your test cases |
| The lab notebook | No | Every measurement you made, and the reason behind every decision |
| Dashboards and alert rules | Only if they were clicked rather than provisioned | An evening, and the ones you forget to rebuild are the ones you needed |
| Secrets and certificates | Regenerable, at a cost | Reissuing every key and reconfiguring every client |
The pattern is that the big files are cheap and the small ones are dear. Your evaluation set is a few hundred kilobytes of questions with expected answers, it is the only thing that can tell you whether next year’s model is better for your work, and it exists nowhere else in the world.
RunnableAll tracks
#!/usr/bin/env bash# Purpose: take a restorable backup of everything about your model estate that is not the# weights - configurations, adapters, evaluation sets, the lab notebook, the# dashboard, and a manifest recording every model file's hash - verify the# archive can be read back, and record what was taken# Platform: all (Linux, macOS and WSL2)# Minimum memory: 8 GB, which is the service being backed up; this script needs almost none# Assumes: tar on PATH, python3 for the manifest step, and enough free space in the# destination. Model weights are excluded on purpose: they are large, they are# re-downloadable, and the manifest records the hash of each one so you can prove# that what you download next year is what you had this year. Pass# --include-weights when you have somewhere to put them and a reason.## Usage: bash backup-estate.sh --gateway ~/gateway --models ~/models --dest ~/backups# bash backup-estate.sh --gateway ~/gateway --models ~/models --dest ~/backups \# --extra ~/adapters --extra ~/evals --labbook labbook.mdset -euo pipefail
GATEWAY_DIR=""MODELS_DIR=""DEST="${HOME}/backups"LABBOOK=""INCLUDE_WEIGHTS=0EXTRAS=()
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"ENV_FILE="${ENV_FILE:-${HERE}/.env}"if [ -f "$ENV_FILE" ]; then set -a # shellcheck source=/dev/null . "$ENV_FILE" set +a DEST="${BACKUP_DIR:-$DEST}"fi
while [ $# -gt 0 ]; do case "$1" in --gateway) GATEWAY_DIR="${2:?--gateway needs a directory}"; shift 2 ;; --models) MODELS_DIR="${2:?--models needs a directory}"; shift 2 ;; --dest) DEST="${2:?--dest needs a directory}"; shift 2 ;; --extra) EXTRAS+=("${2:?--extra needs a directory}"); shift 2 ;; --labbook) LABBOOK="${2:?--labbook needs a file}"; shift 2 ;; --include-weights) INCLUDE_WEIGHTS=1; shift ;; -h|--help) sed -n '2,22p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esacdone
command -v tar >/dev/null 2>&1 || { echo "tar is not on PATH." >&2; exit 1; }[ -n "$GATEWAY_DIR" ] || { echo "Give --gateway <directory>: the Part 9 gateway directory." >&2; exit 1; }[ -d "$GATEWAY_DIR" ] || { echo "No such directory: ${GATEWAY_DIR}" >&2; exit 1; }
mkdir -p "$DEST"STAMP="$(date -u +%Y%m%dT%H%M%SZ)"WORK="$(mktemp -d)"trap 'rm -rf "$WORK"' EXITARCHIVE="${DEST}/estate-${STAMP}.tar.gz"
echo "==> Writing the manifest"MANIFEST="${WORK}/estate-manifest.json"if [ -f "${HERE}/estate-manifest.py" ] && command -v python3 >/dev/null 2>&1; then MANIFEST_ARGS=(--gateway "$GATEWAY_DIR" --output "$MANIFEST") [ -n "$MODELS_DIR" ] && MANIFEST_ARGS+=(--models "$MODELS_DIR") python3 "${HERE}/estate-manifest.py" "${MANIFEST_ARGS[@]}"else echo " estate-manifest.py is not beside this script; skipping the manifest." >&2 printf '{"note":"no manifest was written"}\n' > "$MANIFEST"fi
echo "==> Collecting"STAGE="${WORK}/estate"mkdir -p "$STAGE"cp "$MANIFEST" "${STAGE}/estate-manifest.json"
# The gateway directory, minus the one file in it that must never leave the machine.mkdir -p "${STAGE}/gateway"tar -cf - -C "$(dirname "$GATEWAY_DIR")" \ --exclude='.env' --exclude='*.key' --exclude='*.pem' \ "$(basename "$GATEWAY_DIR")" | tar -xf - -C "${STAGE}/gateway"
for extra in ${EXTRAS+"${EXTRAS[@]}"}; do if [ -d "$extra" ]; then echo " including ${extra}" mkdir -p "${STAGE}/extra" tar -cf - -C "$(dirname "$extra")" "$(basename "$extra")" | tar -xf - -C "${STAGE}/extra" else echo " skipping ${extra}: not a directory" >&2 fidone
if [ -n "$LABBOOK" ] && [ -f "$LABBOOK" ]; then cp "$LABBOOK" "${STAGE}/labbook.md"fi
if [ "$INCLUDE_WEIGHTS" -eq 1 ]; then [ -n "$MODELS_DIR" ] || { echo "--include-weights needs --models." >&2; exit 1; } echo " including the model library, which will take a while and a lot of space" mkdir -p "${STAGE}/models" tar -cf - -C "$(dirname "$MODELS_DIR")" "$(basename "$MODELS_DIR")" \ | tar -xf - -C "${STAGE}/models"fi
echo "==> Archiving"tar -czf "$ARCHIVE" -C "$WORK" estate
echo "==> Verifying the archive can be read back"ENTRIES="$(tar -tzf "$ARCHIVE" | wc -l | tr -d ' ')"BYTES="$(wc -c < "$ARCHIVE" | tr -d ' ')"if [ "$ENTRIES" -lt 2 ]; then echo "The archive contains almost nothing; something went wrong." >&2 exit 1fiif ! tar -tzf "$ARCHIVE" | grep -q 'estate/estate-manifest.json'; then echo "The manifest is not in the archive; something went wrong." >&2 exit 1fi
echo " ${ARCHIVE}"echo " ${ENTRIES} entries, ${BYTES} bytes"echo " .env, private keys and certificates were excluded on purpose. Keep those"echo " somewhere else, deliberately, and know where."
if [ -n "$LABBOOK" ]; then printf '{"lab":"part-23/backup","archive":"%s","entries":%s,"bytes":%s,"weights_included":%s,"taken":"%s"}\n' \ "$(basename "$ARCHIVE")" "$ENTRIES" "$BYTES" "$INCLUDE_WEIGHTS" "$STAMP" >> "$LABBOOK" echo " recorded in ${LABBOOK}"fi
echo "==> Done. Now restore it somewhere harmless and check that it is what you think."The script archives configuration, adapters, evaluation sets and the notebook, writes a
manifest of every model file beside them, verifies that the archive can be read back, and
excludes .env, private keys and certificates on purpose. That exclusion is a decision, not
an oversight: those things need somewhere else, chosen deliberately, and an archive you copy
to another machine is not it.
RunnableAll tracks
bash backup-estate.sh \ --gateway ~/gateway \ --models ~/models \ --extra ~/adapters \ --extra ~/evals \ --labbook labbook.md \ --dest ~/backupsPinning, so that nothing moves underneath you
Section titled “Pinning, so that nothing moves underneath you”Three kinds of thing can change without you doing anything, and each has its own pin.
Engine versions. Every tool this course teaches is in its version table, which exists so
that a page cannot describe behaviour from a version nobody checked. Your estate deserves the
same: record the exact version of each engine you run, from its own --version output rather
than from what you believe you installed.
Container digests. A tag is a name that points at an image today. Docker’s documentation describes pulling by digest as specifying “exactly which version of an image to pull”, which “allows you to ‘pin’ an image to that version”, and it is candid about the trade: a pinned digest “does therefore not pull updated versions of an image, which may include security updates. If you want to pull an updated image, you need to change the digest accordingly”. That is the whole bargain. You get reproducibility and you take on the job of deciding when to move.
RunnableAll tracks
docker image pull ghcr.io/mostlygeek/llama-swap:unified-cuda13
docker image inspect --format '{{index .RepoDigests 0}}' \ ghcr.io/mostlygeek/llama-swap:unified-cuda13Take the string that comes back, with its @sha256: part, and put that in .env instead of
the tag. LiteLLM’s deployment page makes the same recommendation for its own image, and Part
9’s environment file already pins LiteLLM 1.100.0 · verified 2026-09-08 by tag for exactly this
reason.
Model revisions. The Hub’s download guide documents revision for a branch, a tag or a
commit hash, noting that a commit hash “must be the full-length hash instead of a
7-character commit hash”. Record the revision you downloaded, not just the repository name.
Then hf cache verify, documented as validating “local files against their checksums on the
Hub”, is how you confirm later that what is on your disk is what was published.
RunnableAll tracks
hf cache verify Qwen/Qwen3-8B-GGUFAn upgrade that tests itself
Section titled “An upgrade that tests itself”The upgrade that goes wrong is never the one you were careful about. It is the routine one, done on a Tuesday, where the new image reads one configuration key differently and the service comes back up looking healthy and answering nothing.
An engine upgrade, with the rollback built into the procedure
- Resolve the new image to a digestPull the tag, read the digest, and record that. From here on the upgrade refers to something that cannot change.
- Start it beside the live oneA second container on a spare port, with the same configuration mounted read-only, and without the model directory so that it starts without loading weights.
- Check the canaryDoes it become healthy, and does it publish the model list your configuration describes? Those two questions catch a configuration format change, which is the usual fault.
- Optionally ask it for a real answerOnly if the machine has room for a second copy of a model. Most do not, which is why this step is opt-in rather than default.
- Swap the live serviceWrite the new digest into the environment, keep the old one beside it, and recreate the service.
- Re-run the gateway checkPart 9 check-gateway.sh, unchanged. This is the moment the upgrade is either confirmed or refuted.
- Roll back automatically if it failsThe old digest is one environment variable away, and the script puts it back without asking, because a broken service is not a good place to have a conversation.
- Record it either wayFrom, to, and what happened. A successful upgrade is as worth recording as a failed one, because next time you will want to know when this version arrived.
RunnableAll tracks
#!/usr/bin/env bash# Purpose: upgrade the container image behind the gateway without finding out in# production whether the new one works - pull it, resolve it to a digest, run it# beside the live one on a spare port, check it, swap it in, re-check the# gateway, and put the old digest back automatically if the re-check fails# Platform: spark, strix, nvidia (Docker Engine with the Compose plugin), and mac where# Docker Desktop is installed. A native install has no image to swap; the same# procedure applies with a second binary and is described on the page.# Minimum memory: 8 GB. The canary is started without loading a model, so it costs almost# nothing; --deep asks it to answer a real request and needs room for a second# copy of the model, which most machines do not have.# Assumes: run from the gateway directory built in Part 9, holding compose.yaml and a .env# with LLAMA_SWAP_IMAGE in it. This script edits exactly two keys in .env,# LLAMA_SWAP_IMAGE and LLAMA_SWAP_IMAGE_PREVIOUS, and touches nothing else. It# never prints the contents of .env, which holds your keys.## Usage: bash upgrade-engine.sh --image ghcr.io/example/engine:sometag# bash upgrade-engine.sh --image ghcr.io/example/engine:sometag --deep# bash upgrade-engine.sh --rollbackset -euo pipefail
COMPOSE_FILE="compose.yaml"SERVICE="swap"IMAGE_KEY="LLAMA_SWAP_IMAGE"CANARY_PORT="9293"CHANGELOG=""NEW_IMAGE=""DEEP=0ROLLBACK=0ASSUME_YES=0
usage() { sed -n '2,20p' "$0"; }
while [ $# -gt 0 ]; do case "$1" in --image) NEW_IMAGE="${2:?--image needs an image reference}"; shift 2 ;; --compose) COMPOSE_FILE="${2:?--compose needs a file}"; shift 2 ;; --service) SERVICE="${2:?--service needs a compose service name}"; shift 2 ;; --key) IMAGE_KEY="${2:?--key needs an .env variable name}"; shift 2 ;; --port) CANARY_PORT="${2:?--port needs a port}"; shift 2 ;; --changelog) CHANGELOG="${2:?--changelog needs a file}"; shift 2 ;; --deep) DEEP=1; shift ;; --rollback) ROLLBACK=1; shift ;; --yes) ASSUME_YES=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esacdone
command -v docker >/dev/null 2>&1 || { echo "docker is not on PATH." >&2; exit 1; }command -v curl >/dev/null 2>&1 || { echo "curl is not on PATH." >&2; exit 1; }[ -f "$COMPOSE_FILE" ] || { echo "no such compose file: ${COMPOSE_FILE}" >&2; exit 1; }[ -f ".env" ] || { echo "no .env beside ${COMPOSE_FILE}; run this from the gateway directory." >&2; exit 1; }
# Read one key out of .env without sourcing the whole file, so that a stray line in a file# holding your keys cannot execute.env_value() { grep -E "^$1=" .env | tail -n 1 | cut -d= -f2- || true}
# Replace one key in .env, or append it. A temporary file and a move, so an interrupted# write cannot leave you with half a .env.set_env_value() { local key="$1" value="$2" tmp tmp="$(mktemp)" if grep -qE "^${key}=" .env; then sed "s|^${key}=.*|${key}=${value}|" .env > "$tmp" else cp .env "$tmp" printf '%s=%s\n' "$key" "$value" >> "$tmp" fi mv "$tmp" .env}
confirm() { [ "$ASSUME_YES" -eq 1 ] && return 0 printf '%s [y/N] ' "$1" read -r answer case "$answer" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac}
record() { # record <action> <from> <to> <result> [ -n "$CHANGELOG" ] || return 0 printf '{"lab":"part-23/upgrades","action":"%s","service":"%s","from":"%s","to":"%s","result":"%s","at":"%s"}\n' \ "$1" "$SERVICE" "$2" "$3" "$4" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$CHANGELOG" echo " recorded in ${CHANGELOG}"}
CURRENT="$(env_value "$IMAGE_KEY")"PREVIOUS="$(env_value "${IMAGE_KEY}_PREVIOUS")"
if [ "$ROLLBACK" -eq 1 ]; then [ -n "$PREVIOUS" ] || { echo "No ${IMAGE_KEY}_PREVIOUS in .env; nothing to roll back to." >&2; exit 1; } echo "==> Rolling back ${SERVICE}" echo " from ${CURRENT}" echo " to ${PREVIOUS}" confirm "Proceed?" || { echo "Nothing changed."; exit 0; } set_env_value "$IMAGE_KEY" "$PREVIOUS" set_env_value "${IMAGE_KEY}_PREVIOUS" "$CURRENT" docker compose -f "$COMPOSE_FILE" up -d "$SERVICE" echo "==> Rolled back. Run check-gateway.sh to confirm." record rollback "$CURRENT" "$PREVIOUS" "applied" exit 0fi
[ -n "$NEW_IMAGE" ] || { echo "Give --image <reference>, or --rollback." >&2; usage >&2; exit 2; }
echo "==> Pulling ${NEW_IMAGE}"docker image pull "$NEW_IMAGE" >/dev/null
# A tag moves; a digest does not. Resolve now, so that what is recorded in .env and in the# changelog is the thing that actually ran, not a name that pointed at it this morning.DIGEST="$(docker image inspect --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' "$NEW_IMAGE" 2>/dev/null || true)"if [ -z "$DIGEST" ]; then echo " This image has no repository digest, which means it was built locally rather" echo " than pulled. Continuing with the reference you gave, which is not pinned." DIGEST="$NEW_IMAGE"fiecho " resolved to ${DIGEST}"
echo "==> Starting a canary on port ${CANARY_PORT}, beside the live service"CANARY="upgrade-canary-$$"CLEANED=0cleanup_canary() { [ "$CLEANED" -eq 1 ] && return 0 CLEANED=1 docker rm -f "$CANARY" >/dev/null 2>&1 || true}trap cleanup_canary EXIT
# The canary runs the same configuration as the live service, read-only, on a spare port.# It is not given the model directory unless --deep was asked for, so it starts, answers# and stops without ever loading weights.CANARY_ARGS=(run -d --name "$CANARY" -p "127.0.0.1:${CANARY_PORT}:8080" -v "$(pwd)/llama-swap.yaml:/app/config.yaml:ro")if [ "$DEEP" -eq 1 ]; then MODELS_DIR="$(env_value MODELS_DIR)" [ -n "$MODELS_DIR" ] || { echo "--deep needs MODELS_DIR in .env." >&2; exit 1; } CANARY_ARGS+=(-v "${MODELS_DIR}:/models:ro" -e "MODELS_DIR=/models" -e "LLAMA_BIN=llama-server")fidocker "${CANARY_ARGS[@]}" "$NEW_IMAGE" --config /app/config.yaml --listen 0.0.0.0:8080 >/dev/null
echo "==> Checking the canary"CANARY_URL="http://127.0.0.1:${CANARY_PORT}"OK=1for _ in $(seq 1 30); do if curl -sf "${CANARY_URL}/health" >/dev/null 2>&1; then OK=0; break; fi sleep 2doneif [ "$OK" -ne 0 ]; then echo " FAIL the canary never became healthy. Its log follows; nothing was changed." >&2 docker logs --tail 40 "$CANARY" >&2 || true record upgrade "$CURRENT" "$DIGEST" "canary-failed" exit 1fiecho " PASS health endpoint answers"
if curl -sf "${CANARY_URL}/v1/models" | grep -q '"id"'; then echo " PASS the model list is published"else echo " FAIL the model list is empty; the new image reads your configuration differently." >&2 docker logs --tail 40 "$CANARY" >&2 || true record upgrade "$CURRENT" "$DIGEST" "canary-failed" exit 1fi
if [ "$DEEP" -eq 1 ]; then echo " ... asking the canary for a real completion; this loads a model and takes a while" if curl -sf --max-time 600 "${CANARY_URL}/v1/chat/completions" \ -H "Content-Type: application/json" \ -d '{"model":"local/chat","messages":[{"role":"user","content":"Reply with one word: ready"}],"max_tokens":8}' \ | grep -q '"content"'; then echo " PASS the canary generated a completion" else echo " FAIL the canary could not generate. Nothing was changed." >&2 docker logs --tail 40 "$CANARY" >&2 || true record upgrade "$CURRENT" "$DIGEST" "canary-failed" exit 1 fifi
cleanup_canary
echo "==> Swapping the live service"echo " from ${CURRENT}"echo " to ${DIGEST}"confirm "Proceed?" || { echo "Nothing changed."; exit 0; }
set_env_value "${IMAGE_KEY}_PREVIOUS" "$CURRENT"set_env_value "$IMAGE_KEY" "$DIGEST"docker compose -f "$COMPOSE_FILE" up -d "$SERVICE"
echo "==> Re-checking the gateway"if [ -x ./check-gateway.sh ] || [ -f ./check-gateway.sh ]; then if bash ./check-gateway.sh; then echo "==> Upgrade complete. The previous digest is in .env as ${IMAGE_KEY}_PREVIOUS." record upgrade "$CURRENT" "$DIGEST" "applied" exit 0 fi echo " The gateway check failed after the swap. Putting the old digest back." >&2 set_env_value "$IMAGE_KEY" "$CURRENT" set_env_value "${IMAGE_KEY}_PREVIOUS" "$DIGEST" docker compose -f "$COMPOSE_FILE" up -d "$SERVICE" record upgrade "$CURRENT" "$DIGEST" "reverted-automatically" exit 1fi
echo " check-gateway.sh from Part 9 is not in this directory, so the swap was not"echo " verified. Run your own check now, and 'bash upgrade-engine.sh --rollback' if it"echo " is unwell."record upgrade "$CURRENT" "$DIGEST" "applied-unverified"RunnableTrack N · NVIDIA GPU
bash upgrade-engine.sh \ --image ghcr.io/mostlygeek/llama-swap:unified-cuda13 \ --changelog changelog.mdRunnableTrack N · NVIDIA GPU
bash upgrade-engine.sh --rollbackThe script edits exactly two keys in .env, the image and its predecessor, and touches
nothing else. That constraint is deliberate: an upgrade tool that rewrites your configuration
is an upgrade tool you will stop trusting the first time it reformats something.
Reproducing this machine somewhere else
Section titled “Reproducing this machine somewhere else”The manifest is what turns a pile of files into a description.
RunnableAll tracks
#!/usr/bin/env python3"""Write down exactly what your model estate is, so it can be rebuilt.
Purpose: produce one JSON file listing every model file with its size and hash, every engine with its version, every container image with its digest, and every configuration file with its hash, so that a new machine can be brought to the same state and an old one can be proved to have changed.Platform: all (spark, strix, mac, nvidia). Pure Python, no dependencies.Minimum memory: 8 GB, which is the estate being described; this script needs almost none.Assumes: a model library laid out as Part 4 built it, the gateway directory from Part 9, and whichever engines are installed on PATH. Hashing a large model library reads every byte of it, so hashes are taken from the .sha256 sidecar files Part 4's downloader wrote where those exist, and computed only for files with --hash. Nothing is modified.
Usage: python3 estate-manifest.py --models ~/models --gateway ~/gateway python3 estate-manifest.py --models ~/models --gateway ~/gateway --output estate.json python3 estate-manifest.py --models ~/models --hash --output estate.json"""import argparseimport hashlibimport jsonimport platformimport reimport shutilimport subprocessimport sysfrom datetime import datetime, timezonefrom pathlib import Path
WEIGHT_SUFFIXES = {".gguf", ".safetensors", ".bin", ".pt", ".pth", ".npz"}CONFIG_SUFFIXES = {".yaml", ".yml", ".json", ".conf", ".toml", ".md", ".txt"}
# Engines the course installs, and the argument that makes each print its version. A tool# that is not on PATH is recorded as absent rather than omitted, because "not installed" is# part of the description of a machine.ENGINE_VERSION_COMMANDS = [ ("llama-server", ["llama-server", "--version"]), ("llama-swap", ["llama-swap", "--version"]), ("vllm", ["vllm", "--version"]), ("litellm", ["litellm", "--version"]), ("mlx_lm.server", ["mlx_lm.server", "--help"]), ("ollama", ["ollama", "--version"]), ("docker", ["docker", "--version"]),]
def run(cmd, timeout=20): if shutil.which(cmd[0]) is None: return None try: out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) except (OSError, subprocess.TimeoutExpired): return None text = (out.stdout or "") + (out.stderr or "") return text.strip().splitlines()[0] if text.strip() else ""
def sha256_of(path, chunk=1024 * 1024): digest = hashlib.sha256() with path.open("rb") as handle: for block in iter(lambda: handle.read(chunk), b""): digest.update(block) return digest.hexdigest()
def sidecar_hash(path): """Part 4's downloader writes <file>.sha256 beside each model file it verified.""" sidecar = path.with_name(path.name + ".sha256") if not sidecar.is_file(): return None text = sidecar.read_text(encoding="utf-8", errors="replace").strip() match = re.search(r"\b[0-9a-f]{64}\b", text) return match.group(0) if match else None
def describe_models(models_dir, compute_hashes, verbose): rows = [] if models_dir is None: return rows root = Path(models_dir).expanduser() if not root.is_dir(): print(f"estate-manifest: no such model directory: {root}", file=sys.stderr) return rows for path in sorted(root.rglob("*")): if not path.is_file() or path.suffix.lower() not in WEIGHT_SUFFIXES: continue stat = path.stat() digest = sidecar_hash(path) source = "sidecar" if digest else None if digest is None and compute_hashes: if verbose: print(f" hashing {path.name} ...", file=sys.stderr) digest = sha256_of(path) source = "computed" rows.append({ "path": str(path.relative_to(root)), "bytes": stat.st_size, "modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(), "sha256": digest, "sha256_source": source or "not recorded", }) return rows
def describe_engines(): rows = [] for name, cmd in ENGINE_VERSION_COMMANDS: version = run(cmd) rows.append({ "tool": name, "present": version is not None, "version_line": version if version else "not installed", }) return rows
def image_references(gateway_dir): """Every image name mentioned in the gateway's .env or compose files.""" names = set() if gateway_dir is None: return names root = Path(gateway_dir).expanduser() for candidate in list(root.glob("*.yaml")) + list(root.glob("*.yml")) + [root / ".env"]: if not candidate.is_file(): continue for line in candidate.read_text(encoding="utf-8", errors="replace").splitlines(): match = re.search(r"(?:image:\s*|_IMAGE=)[\"']?([A-Za-z0-9._/-]+(?::[A-Za-z0-9._-]+)?)", line) if not match: continue reference = match.group(1).strip("\"'") # A bare word is a Compose variable that was not expanded, not an image. if "/" in reference or ":" in reference: names.add(reference) return names
def describe_images(gateway_dir): rows = [] if shutil.which("docker") is None: return rows for name in sorted(image_references(gateway_dir)): digest = run(["docker", "image", "inspect", "--format", "{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}", name]) rows.append({ "reference": name, "digest": digest if digest else "not pulled on this machine", }) return rows
def describe_configs(gateway_dir): rows = [] if gateway_dir is None: return rows root = Path(gateway_dir).expanduser() if not root.is_dir(): print(f"estate-manifest: no such gateway directory: {root}", file=sys.stderr) return rows for path in sorted(root.rglob("*")): if not path.is_file() or path.suffix.lower() not in CONFIG_SUFFIXES: continue # A secrets file is named here so that its absence from the list is deliberate # rather than accidental, and its contents are never read. if path.name.startswith(".env"): rows.append({"path": str(path.relative_to(root)), "sha256": "not recorded", "note": "excluded on purpose: this file holds keys"}) continue rows.append({"path": str(path.relative_to(root)), "sha256": sha256_of(path)}) return rows
def describe_machine(): return { "system": platform.system(), "release": platform.release(), "machine": platform.machine(), "python": platform.python_version(), "processor": platform.processor(), }
def main(): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--models", default=None, help="model library directory") parser.add_argument("--gateway", default=None, help="the Part 9 gateway directory") parser.add_argument("--output", default=None, help="write here instead of standard output") parser.add_argument("--hash", dest="compute_hashes", action="store_true", help="compute SHA-256 for model files with no .sha256 beside them") parser.add_argument("--quiet", action="store_true", help="do not report progress") args = parser.parse_args()
if args.models is None and args.gateway is None: parser.error("give --models, --gateway, or both; there is nothing to describe otherwise")
manifest = { "written": datetime.now(timezone.utc).isoformat(timespec="seconds"), "machine": describe_machine(), "engines": describe_engines(), "images": describe_images(args.gateway), "configs": describe_configs(args.gateway), "models": describe_models(args.models, args.compute_hashes, not args.quiet), } manifest["summary"] = { "model_files": len(manifest["models"]), "model_bytes": sum(m["bytes"] for m in manifest["models"]), "model_files_without_hash": sum(1 for m in manifest["models"] if not m["sha256"]), "config_files": len(manifest["configs"]), "engines_present": sum(1 for e in manifest["engines"] if e["present"]), }
text = json.dumps(manifest, indent=2, sort_keys=True) if args.output: Path(args.output).expanduser().write_text(text + "\n", encoding="utf-8") if not args.quiet: print(f"wrote {args.output}: {manifest['summary']['model_files']} model file(s), " f"{manifest['summary']['config_files']} configuration file(s)") missing = manifest["summary"]["model_files_without_hash"] if missing: print(f"{missing} model file(s) have no recorded hash. Run again with --hash " "when you have time to read every byte.") else: print(text)
if __name__ == "__main__": main()RunnableAll tracks
python3 estate-manifest.py \ --models ~/models \ --gateway ~/gateway \ --output estate-manifest.jsonIt records every model file with its size and its hash, taking the hash from the .sha256
sidecars Part 4’s downloader wrote and computing it only when you ask with --hash; every
engine on the path with the version it reports; every container image mentioned in the
gateway’s files with the digest that is actually pulled; every configuration file with its
hash; and the machine itself. Nothing is modified and no secret is read: .env appears in
the list with a note saying it was excluded on purpose, which is better than being silently
absent.
Rebuilding a machine from it is then a procedure rather than an archaeology:
- Install the engines at the recorded versions.
- Pull the images at the recorded digests.
- Download the models at the recorded repositories and revisions, and verify the hashes against the manifest.
- Restore the configuration from the backup archive and check the hashes match.
- Recreate the secrets, which are the one category that is deliberately not in either file.
- Run
check-gateway.shfrom Part 9 andcheck-monitoring.shfrom this part’s lab.
The sixth step is the point of the first five. A rebuild is finished when the same checks pass, not when the files are in place.
Restoring, and the quarterly ten minutes
Section titled “Restoring, and the quarterly ten minutes”The two things that make all of the above real are a restore you have actually performed and a short review you actually repeat.
Restore into a scratch directory, not over the original. Extract the archive somewhere harmless and look at what came out. Does the configuration directory contain the files you expected? Does the manifest list the models you believe you have? Is the notebook the current one or one from before you last worked on it? Those three questions catch the two common backup faults, which are a script that has been silently failing since you changed a path, and a backup that was working perfectly on the wrong directory.
Compare hashes rather than eyeballing files. The manifest already holds the hash of every configuration file, so a restored copy either matches it or does not. That comparison takes seconds and it is the only way to know that the archive is intact rather than merely present.
Check that the secrets you deliberately excluded are somewhere. This is the step that
turns an exclusion into a decision. If you cannot say, in one sentence and without looking,
where your .env and your private key would come from after a disk failure, you have not
finished designing the backup; you have only finished writing the script.
Then there is the review, which takes about ten minutes and belongs in the calendar rather than in your intentions. Four questions, once a quarter:
- Has anything you pinned moved on? Look at the releases page for each engine and each image. You are not obliged to upgrade. You are obliged to know that you are three versions behind, so that the decision is a decision.
- Does the model library still match its hashes?
hf cache verifyagainst the repositories you care about answers this, and it occasionally finds a file that a disk corrupted quietly months ago. - When does the certificate expire? If the answer is “before the next review”, renew it now rather than discovering it on the day.
- Does the backup still restore? Run the restore into a scratch directory again. A backup script that broke when you moved a directory will otherwise be discovered at the worst possible time, and the whole point of this lesson is to move that discovery earlier.
The changelog
Section titled “The changelog”One file, appended to, never reorganised. It is the cheapest tool in this part and the one you will be most grateful for.
What goes in it: every model that went behind an alias and when, with the quantisation and where it came from; every engine or image upgrade with the digest before and after; every configuration change that was not obvious; every limit you set and why; every incident, including the ones that turned out to be nothing; every key issued and revoked, by alias rather than by value; and the date of every certificate expiry you are relying on.
What does not go in it: anything secret, and anything you would have to maintain. A changelog that has to be kept tidy stops being kept.
Perform a restore that cannot rely on the original service
Section titled “Perform a restore that cannot rely on the original service”A backup is useful only if it contains enough state to reconstruct the intended service. Test restoration into an isolated directory or host with different temporary ports so the original service cannot accidentally answer the probes. Restore configuration, required credentials through the approved secret mechanism, model identities and persistent application data.
Check a known user-visible object and a representative model request. Compare hashes for immutable artefacts and inspect application-level records for databases. A successful archive extraction proves only that files were unpacked. Record elapsed restoration time and any undocumented manual step.
For upgrades, keep old and new configurations and run the same acceptance suite before promotion. Include template-dependent features, not just a health endpoint. Define rollback before changing the active alias. Some state migrations are not reversible by swapping an image tag, so test the documented data rollback path too. Retain the evidence that the restored or rolled-back service works independently of the machine you hoped never to lose.
The big files are replaceable and the small ones are not: configuration, adapters, evaluation sets and the notebook are what you cannot get back, and weights are replaceable if you recorded the repository, revision and hash. Back up the second category, exclude secrets on purpose, and restore it once so that it is a fact rather than a hope. Pin engines by version, images by digest, and models by revision, accepting the documented trade that a pinned digest does not receive updates until you move it. Upgrade by starting the new image beside the live one, checking it, swapping, re-running the check, and rolling back automatically when the check fails. Keep a manifest that describes the machine well enough to rebuild it, and finish every rebuild by running the same checks rather than by looking at the files. And append one line to the changelog every time something changes, because the question you will ask in six months is always “when did this start”, and there is no other way to answer it.
Check your understanding
Sources for this lesson
6 verified · checked 2026-09-09
- 01Docker — docker image pull§ Pull an image by digestdocs.docker.com/reference/cli/docker/image/pull2026-09-09
- 02Hugging Face Hub — Command line interface§ hf cache verifyhuggingface.co/docs/huggingface_hub/guides/cli2026-09-09
- 03Hugging Face Hub — Download files from the Hub§ Downloading a specific revision; the LFS SHA-256huggingface.co/docs/huggingface_hub/guides/download2026-09-09
- 04llama-swap — README§ Container images and tags; endpointsgithub.com/mostlygeek/llama-swap2026-09-09
- 05LiteLLM — Deployment§ Container image; pinning a version tagdocs.litellm.ai/docs/proxy/deploy2026-09-09
- 06Grafana — Provisioning§ Dashboards from filesgrafana.com/docs/grafana/latest/administration/provisioning2026-09-09
Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.