Skip to content

Packaging and distribution: the evaluation

Roadmap question: is the current microservice architecture suitable for distributing Smoking Pi through apt, Homebrew and other package managers, and what would have to change to offer a simple install and upgrade?

Answer: yes, as a package that manages a Compose deployment — not as native packages of the services. The stack is nine locally built container images around four pinned upstream ones (SmokePing, InfluxDB, Grafana, PostgreSQL); turning those into Debian packages would mean re-packaging four upstream projects and their init logic, and would still need Docker for nothing to be gained. What a package can do well is own the parts that are currently the user's problem: where the code lives, where state lives, how the stack starts at boot, how it upgrades, and how it goes away. An end-to-end trial (below) built a working .deb from the current tree in seconds; what it could not do is upgrade in place, because the tree is still a runtime dependency. The backlog at the end is the list of changes that close that gap, in order, with costs.

What the system is, shaped for packaging

From the inventory (all counts as of v2.9.0):

  • Pro: 10 declared services, 5 always on (smokeping, postgres, config-manager, web-admin, grafana), 1 near-mandatory profile (influxdb), 4 opt-in (mcp, alerts, ai, clickhouse). Standard: 4. Basic: 1.
  • 9 images built on the target from shared/modules/*/Dockerfile, 4 of them thin wrappers over pinned upstream images, 5 python:3.14-slim apps. Building needs the internet four ways (apk, PyPI, a GitHub clone in config-manager, a Grafana plugin download). CI builds 4 of the 9, on amd64 only, and publishes none. No registry, no version in any artifact; the running code's identity is verified by the doctor hashing .py files against the checkout.
  • State: 8 named volumes (10 with ClickHouse), two of which — PostgreSQL (the config source of truth) and InfluxDB (all history) — are pinned to their majors by hand because a major bump over a live volume initializes an empty cluster. Plus git-tracked directories the running stack mutates (editions/pro/config-manager/{config,output}) and a flat .env generated by setup.sh into the edition directory.
  • The checkout is a runtime dependency, not just a build input: Compose bind-mounts source directories into containers (smokeping-exporters, web-admin/app, Grafana provisioning, SQL init), every build: and mount is ../../shared/... relative to the edition directory, and config-manager needs the host's Docker socket to signal SmokePing.
  • Three services run on the host network for reasons that do not go away: smokeping (raw ICMP, the real first hop, host IPv6, nl80211 for Wi-Fi), alerter and mcp-server (an OpenClaw gateway that listens only on the host's loopback).
  • Nothing starts the stack at boot except Docker's restart policy; there is no unit, no uninstall, no upgrade command. Several helper scripts still hard-code container names from two layouts ago.

The alternatives

A · native packages per service B · split packages + metapackage C · one package that manages the Compose deployment D · installer script only
What ships SmokePing, Grafana, InfluxDB, PostgreSQL and the five Python apps as host services Same, one package each, plus smoking-pi pulling them in The stack definition, the smoking-pi command, a systemd unit; images pulled (or built) on first start A curl \| bash that clones and runs setup.sh
Fits the architecture No: four upstream projects would be re-packaged and their container init logic (s6, custom-cont-init.d, generated-config symlinks) rewritten for the host No, and ×N Yes: it packages what exists Yes, but it packages nothing
Maintenance Tracking four upstreams' packaging across distros; Grafana and InfluxDB already publish their own apt repos with their own opinions Worse The compose files and Dockerfiles, which are maintained anyway Low, and nothing to show for it in apt list
Install / upgrade UX apt install, but then a service-by-service configuration story Same apt install smoking-pi && smoking-pi install; apt upgrade + smoking-pi upgrade Clone, run a script, re-run it later and hope
Boot, status, uninstall systemd per service Same One unit, one command, apt remove keeps data None
Effort Months Months Days–weeks, see backlog A day
Verdict No No Yes Stepping stone at most — the CLI from C works from a clone today, which covers this

The one alternative that deserves a sentence is "simplify the local deployment while keeping the internal modules" — that is what C requires as its first step anyway (relocatable state, no source bind-mounts), so it is not a competitor but the prerequisite.

The trial

packaging/deb/build.sh builds dist/smoking-pi_<version>_all.deb from the committed tree at HEAD (git archive, never the working tree — the first build accidentally took the checkout, and the package carried the reference Pi's generated target list). Contents: the tracked editions/, shared/, docs/, examples/ under /opt/smoking-pi; cli/ (the command) as /usr/lib/smoking-pi, with /usr/bin/smoking-pi a link to its entry; packaging/systemd/smoking-pi.service; /etc/default/smoking-pi as a conffile; postinst/prerm that reload systemd and disable the unit. Depends: docker.io | docker-ce, docker-compose-plugin | docker-compose-v2, openssl, python3, python3-yaml, git; Architecture: all because the package holds no binaries — the images are built or pulled per architecture at first start.

What worked, on the Pi, with no Docker involved in the build:

  • Builds in about two seconds; 1.4 MB; some 370 files. dpkg-deb --info and --contents are what you would expect.
  • Extracted into a scratch root (not installed on the reference Pi), the CLI runs from the /opt-style path: smoking-pi version reads 2.9.0 from CITATION.cff; smoking-pi paths reports the edition, env file and profiles.
  • docker compose config succeeds from /opt/smoking-pi/editions/pro with the influxdb,mcp,alerts profiles: the relative ../../shared build contexts and mounts resolve under a fixed install prefix. The layout does not have to change for a package to work.
  • The unit verifies (systemd-analyze verify, once /usr/bin/smoking-pi exists). Its start timeout is infinity on purpose: when images are built rather than pulled (a clone, or a package whose registry is unreachable), a cold first up can outlast any timeout, and systemd killing a build half-way is the worst outcome — so install is run interactively first and the unit handles every boot after. It is wanted by docker.service as well as multi-user.target: Requires=docker.service stops it (and ExecStop removes the containers) when Docker stops, so without that link a Docker upgrade that stops and then starts the daemon left the monitor down until the next boot. postinst re-enables an enabled unit so an upgrade adds it.
  • smoking-pi install refuses to run over an existing .env: setup.sh regenerates every secret, and the PostgreSQL and InfluxDB volumes keep the old ones. Re-running it is how a working stack stops authenticating to its own database — a hazard the inventory names and the CLI now guards against.

What the trial shows cannot work yet — each is a backlog item below:

  1. .env and the mutated config directories sit inside /opt. A package upgrade replaces /opt/smoking-pi; editions/pro/.env would be orphaned or clobbered, and config-manager/config/targets.yaml — which the running stack rewrites and which was tracked in git — would conflict with the package's copy on every upgrade. dpkg's conffile handling is for /etc, not for a YAML the application edits. Closed by backlog #1 (2026-09-20) — see Relocatable state below.
  2. First start builds nine images on the target, needing the internet in four places and, on a Pi 5, minutes per Python image (matplotlib) — the three images that embed common/ took about ten minutes together to rebuild with warm base layers during the v2.9.0 work. A package install that ends in a twenty-minute build with no progress bar is not "simple".
  3. Source bind-mounts (web-admin/app, smokeping-exporters) point into /opt, which works but means the package is the code the containers run — fine for a package, but the "dev mount" comment on web-admin is then a lie, and an apt upgrade changes code inside a running container without a restart. Closed by backlog #2 (2026-09-20) — see Packaged mode below.
  4. config-manager writes generated output as root into a directory the package owns; upgrades would tread on it. It belongs in /var/lib.
  5. Helper scripts hard-code pro-influxdb-1, pro_default, grafana-influx_* and Compose v1 invocations; harmless while the project name stays pro, but a package should not depend on it. The grafana-influx_* names were worse than that — they were already dead when the trial was written, so the checks that used them had never once fired. Closed by backlog #7 (2026-09-22): no script guesses a container or volume name any more. Pro's documentation followed on 2026-09-22.

What has to change — the backlog

Ordered so each step is useful on its own and unblocks the next. Costs are rough engineer-days for someone who knows the repo.

# Change Cost Unblocks
1 Relocatable state — done (2026-09-20). SMOKING_PI_CONFIG_DIR (default ./config-manager/config), SMOKING_PI_OUTPUT_DIR, SMOKING_PI_ENV_FILE honored by the compose files (${VAR:-default} in every bind mount), by setup.sh/generate-passwords.sh/show-passwords.sh/manage-containers.sh (--env-file) and by the CLI. editions/pro/config-manager/{config,output} are no longer tracked; the seed set is config-manager/templates/, which bootstrap copies into an empty config dir on first start (it always did — the plan's "seeds move to the module defaults" pointed at a stale third copy, now deleted). Packaged layout: /etc/smoking-pi/{env,config}, /var/lib/smoking-pi/output, set in /etc/default/smoking-pi. Also ended the "runtime churn in git status" nuisance on the reference Pi. Details: Relocatable state. 2 everything below; in-place upgrades
2 No source mounts in packaged mode — done (2026-09-20). The exporters are baked into the smokeping image (COPY from a shared/ build context; the :/exporters:ro mount stays for development); docker-compose.packaged.yml (Pro, Standard) drops every bind mount of shared/modules — seven in Pro: exporters, three Grafana provisioning directories, the web-admin app package, the PostgreSQL and ClickHouse init SQL, all of which the images already carry. SMOKING_PI_PACKAGED=1 (set by the package) makes the CLI, setup.sh and manage-containers.sh add it, last. packaging/check-packaged-override.py renders both and fails if the override drops anything else; the doctor's deployed-code-current now also hashes /exporters. Details: Packaged mode. 1 #3, upgrades that restart what changed
3 Published multi-arch images — done (2026-09-20). release.yml builds all nine images for linux/arm64 and linux/amd64 on a vX.Y.Z tag — each architecture natively on its own GitHub-hosted runner (ubuntu-24.04-arm, free for public repositories; QEMU would spend most of an hour on the matplotlib images), pushed by digest and merged into one manifest per service — to ghcr.io/estcarisimo/smoking-pi/<service>:<version> and, once every install test passed, :latest. The compose files name that image next to build: with pull_policy: missing; SMOKING_PI_VERSION unset means :dev, never published, so a clone builds; the package sets its version and pulls. packaging/check-images.py (CI) keeps compose, Dockerfiles and the matrix in agreement. Closed the CI gap where five images were never built at all. Details: Published images. 2–3 a first start measured in seconds; Dependabot-driven rebuilds become releases
4 The smoking-pi command, for real — done (2026-09-20). upgrade (pull the release's images, or build --pull from a clone; up -d; doctor), backup/restore (pg_dumpall, then every volume the active services mount as a tarball with the stack stopped, plus env file and config; restore refills the volumes under this project's name), purge (the volumes, after typing the project name; --config also the env file and directories). install chooses edition, backend and the optional profiles (mcp, alerts, ai) by whiptail or --profiles. packaging/tests/cli.bats (22 tests, CI) runs it against a stubbed docker. Details: The command. 2 the "instalador CLI/TUI" roadmap item
5 A release that produces the package — the .deb half done (2026-09-21). release.yml's package job builds the .deb from the tagged tree with the trial builder (packaging/deb/build.sh; nfpm was not needed), installs it on the runner and checks what a package can prove without Docker — smoking-pi version equals the tag, paths shows the packaged layout and the images pinned to the version, the unit verifies, the doctor's static checks pass from /opt, install refuses over an existing env file, removal keeps /etc/smoking-pi and /var/lib/smoking-pi — keeps it as a workflow artifact, and attaches it to the GitHub release the maintainer created for the tag. The workflow never creates a release: publishing stays a human act. Proven by three throwaway runs, except the two branches a test-* tag cannot take — the version-equality check and the attach step — which the first real vX.Y.Z tag after this proves; watch that run. The apt repository — done (2026-09-22), waiting for the signing key. docs.yml runs when the Release workflow finishes for a vX.Y.Z tag (not on the tag push, which would race the attach job), downloads every release's .deb, builds a flat repository under site/apt (packaging/apt/build-repo.sh: dpkg-scanpackages, apt-ftparchive, InRelease/Release.gpg signed with the APT_SIGNING_KEY secret, the public key exported beside it) and deploys it with the docs site as the one Pages artifact. Without the secret the site is published with no /apt at all — an unsigned repository would only teach people [trusted=yes]. CI builds and signs a repository with a throwaway key on every PR and installs from it with apt. Details: The apt repository. 1–2 apt install smoking-pi
6 Uninstall and data policy — done (2026-09-21). apt remove keeps everything: volumes, /etc/smoking-pi, /var/lib/smoking-pi, the conffile. apt purge (postrm) removes what the stack regenerates — the seeded config directory, the output directory — and the conffile, but never the Docker volumes nor /etc/smoking-pi/env: the env file holds the credentials those volumes are locked with, so deleting it alone would turn kept data into unreadable data (the plan said "purge removes /etc/smoking-pi"; this is the refinement, and postrm prints it). smoking-pi purge --config is the explicit way to delete the measurements, before the package. check-package.sh asserts all of it after every install. Documented in Upgrading. 0.5 trust
7 Script hygiene — done (2026-09-22). No script guesses a container name any more: sync-influx-token.sh and verify-postgres.sh ask Compose (compose ps -q <service>, honoring SMOKING_PI_ENV_FILE), create-tunnel.sh reads the edition and project from Compose's container labels and targets service names on the project's network (Pro's SmokePing is host-networked — its tunnel now goes through the host gateway; the old pro-smokeping-1 target could never have resolved), show-passwords.sh prints the ports the compose files map (Basic 80, Standard 8081, Pro 80 — it said 8080/8081/8081), checks only this edition's ports with bash's /dev/tcp (nc is not on a stock Raspberry Pi OS, so every port read "not accessible" for as long as it used it) and reads service state from compose ps --format. Every docker-compose (v1) call and hint is docker compose or the smoking-pi command; manage-containers.sh is v2-only (v1 cannot parse these files), adds the ClickHouse overlay when the profile is recorded (it never did — a restart rendered the InfluxDB stack), and --edition works from the repository root. shared/docs/maintenance.md rewritten around the command and Compose labels (and, once that removed the reason it was kept off the site, moved to Maintenance and cleanup). 1 #4 without surprises
8 Homebrew — written (2026-09-22), untested on a Mac. Formula/smoking-pi.rb in this repository (brew tap estcarisimo/smoking-pi https://github.com/estcarisimo/smoking-pi && brew install smoking-pi; a homebrew-smoking-pi tap repository is the same file moved, when there is a reason). It installs the release tarball under the Cellar, rewrites #!/bin/bash to #!/usr/bin/env bash (macOS ships bash 3.2), puts Homebrew's bash, coreutils and gnu-sed first on PATH in the wrapper (sed -i, readlink -f), gives the doctor a venv with PyYAML, and brew services start smoking-pi runs smoking-pi up at login. Pro's measurement fidelity is reduced on macOS: network_mode: host is Docker's Linux VM, not the Mac — no real first hop, no nl80211 — so the CPE and Wi-Fi features report the VM; the formula's caveats say so. After each release the maintainer runs packaging/homebrew/bump.sh vX.Y.Z (the tarball's checksum cannot exist before the tag) and merges the bump; brew tap reads main. CI (packaging/homebrew/check.sh) proves the two things a bump can get wrong without a Mac: the formula parses and its sha256 is the tarball's. Everything else — brew install, the wrapper, the venv, brew services — waits for the first person with a Mac; the README says "untested" until then. 1, after #5 Mac users

Total for a real apt install smoking-pi on a Raspberry Pi: about ten days, of which the first two (#1, now done) are the ones that also pay for themselves without any packaging.

Relocatable state

Done 2026-09-20. Three variables decide where the state the stack rewrites lives; unset, everything stays beside the edition's compose file, untracked by git, exactly where it was:

Variable What Default (from a clone) Packaged
SMOKING_PI_ENV_FILE the secrets and settings setup.sh generates editions/<edition>/.env /etc/smoking-pi/env
SMOKING_PI_CONFIG_DIR targets.yaml, probes.yaml, sources.yaml — the YAML config-manager edits (import/export; PostgreSQL is the source of truth) editions/<edition>/config-manager/config /etc/smoking-pi/config
SMOKING_PI_OUTPUT_DIR the generated SmokePing Targets/Probes (Pro; Standard keeps them in a named volume) editions/pro/config-manager/output /var/lib/smoking-pi/output

The two directories are read by the compose files themselves (${SMOKING_PI_CONFIG_DIR:-./config-manager/config}:/app/config), so they work with a bare docker compose as long as they are exported or in the env file; relative values are relative to the edition directory, as every other path in those files. The env file is different: Compose only finds ./.env on its own, so a relocated one has to be named on every call. setup.sh --env-file, generate-passwords.sh --env-file, show-passwords.sh and the smoking-pi command all read SMOKING_PI_ENV_FILE and pass --env-file; the .deb sets all three in /etc/default/smoking-pi, which the systemd unit loads through EnvironmentFile and the smoking-pi command sources itself (the environment still wins), so a manual sudo smoking-pi install lands in the same place as the unit's up; postinst creates the two directories.

Seeding. A fresh config directory holds nothing (from a clone, a .gitkeep). config-manager's bootstrap copies the three YAML files from its own templates/ on first start — the same mechanism that has recovered a deleted file since Sprint 3 — and then generates the output. The image no longer carries a config/ copy, and the stale shared/modules/config-manager/{config,output} copies (five probes, untouched since Sprint 3, never read by anything) are gone; templates/ is the one seed set, and a test holds it to the current probe list. Verified by running the rebuilt image against an empty directory: three files, the HTTP/TCP probes among them, Probes and Targets generated.

What this changes on the reference Pi: nothing at runtime — the defaults are the old paths, and the untracked files stay where they are. git status is clean for the first time since the stack went live. CI renders both compose files with the packaged layout and fails if a mount is hardcoded again.

Still true: smoking-pi install refuses an existing env file, because regenerating secrets against live volumes breaks authentication; upgrade is backlog #4.

Packaged mode

Done 2026-09-20. From a clone, the compose files bind-mount parts of the checkout into the containers as development overlays — edit an exporter or a dashboard and the container sees it without a rebuild. Every one of those directories is also baked into its image, so a packaged install does not need the mounts, and should not have them: with /opt/smoking-pi mounted into running containers, an apt upgrade would change code under a running process (Grafana re-reads provisioning live; a crashed exporter's supervisor would restart it on the new file) with nothing restarted deliberately.

Mount (Pro) Container Baked in by
shared/modules/smokeping-exporters → /exporters smokeping COPY modules/smokeping-exporters/*.py /exporters/ (this change; build context is now shared/, like the alerter, ai-insights, mcp-server and web-admin images)
shared/modules/grafana/provisioning/{dashboards,dashboards-clickhouse,datasources} grafana the Grafana Dockerfile's COPY provisioning/ (there since before the multi-edition layout)
shared/modules/web-admin/app → /app/app web-admin the web-admin Dockerfile's COPY modules/web-admin/
shared/modules/postgres/init → /docker-entrypoint-initdb.d postgres the postgres Dockerfile's COPY init/
shared/modules/clickhouse/init → /docker-entrypoint-initdb.d clickhouse nothing — and it never runs (ClickHouse); the exporter creates the schema

editions/pro/docker-compose.packaged.yml (and a one-service one for Standard) replaces those services' volume lists with the same lists minus the code mounts. It has to be a full replacement — Compose merges volume lists by target and cannot remove one entry — so the file repeats every other volume, and packaging/check-packaged-override.py renders the base and the packaged stack through Compose with every profile on and fails if the override dropped or added anything but bind mounts of shared/modules. CI runs it for both editions. The file goes last in the -f order: docker-compose.clickhouse.yml re-adds the ClickHouse init mount, and only a later !override removes it again (CI checks that too).

SMOKING_PI_PACKAGED=1, set in /etc/default/smoking-pi by the package, makes the smoking-pi command and setup.sh (Pro, Standard) add the file. smoking-pi paths prints the mode. From a clone nothing changes: the variable is unset, the overlays stay, and editing an exporter still needs no rebuild.

The doctor's deployed-code-current check now also hashes /exporters in the smokeping container against shared/modules/smokeping-exporters/*.py, so a stale smokeping image in packaged mode is reported the way a stale alerter image is (from a clone the mount makes it trivially current). The edition's custom-cont-init.d scripts stay bind-mounted: they are edition configuration, not shared code, and are read once at container start.

Published images

Done 2026-09-20. Every service an edition builds now also names its published image:

    build: ../../shared/modules/config-manager
    image: ${SMOKING_PI_REGISTRY:-ghcr.io/estcarisimo/smoking-pi}/config-manager:${SMOKING_PI_VERSION:-dev}
    pull_policy: missing

With image:, build: and pull_policy: missing together, Compose (v2.38 verified) pulls the name first and builds only when the pull fails. That one rule gives both modes without a second compose file:

  • A clone off a release tag leaves SMOKING_PI_VERSION unset → :dev, a tag the release workflow refuses to publish → the pull fails → Compose builds from the checkout, as it always did. The built image is simply named ghcr.io/estcarisimo/smoking-pi/<service>:dev instead of pro-<service>. A :latest default would have done the opposite: a fresh clone would silently run the last release's code with a newer checkout, and only the doctor's deployed-code-current would notice.
  • A clone checked out on a release tag (git checkout v2.13.0): the command takes the version from the tag → the published images of exactly that code, as the package would. The final release wins over a candidate on the same commit (v2.13.0 over v2.13.0-rc.3). Before, only a SMOKING_PI_VERSION typed in front of every command did this, and the reference Pi — on v2.13.0, running the :2.13.0 images — was one bare smoking-pi up from recreating every container on stale local :dev builds. SMOKING_PI_VERSION=dev builds the checkout whatever it is on. Only the command does this; a bare docker compose still means :dev.
  • The package: in packaged mode the command takes SMOKING_PI_VERSION from the tree it installed (/opt/smoking-pi/CITATION.cff) → the pull succeeds → the first start is a download, not a 20-minute build on a Pi, and a new package pulls its own release. smoking-pi paths prints which. It is deliberately not written into /etc/default/smoking-pi: that file is a conffile, and a conffile edited on the host is kept as-is by dpkg on upgrade — a version line there would have pinned every upgrade to the first release installed. Setting it there yourself is a pin (a fork's registry, a test build), and stays one.
  • A tester on any machine can do the same by hand: SMOKING_PI_VERSION=2.12.0 docker compose up -d. SMOKING_PI_REGISTRY points a fork at its own registry.

The images are built by .github/workflows/release.yml on a vX.Y.Z tag: a build job per service × architecture — linux/amd64 on ubuntu-24.04, linux/arm64 on ubuntu-24.04-arm, GitHub-hosted and free for public repositories, chosen over QEMU emulation because the matplotlib/numpy images take most of an hour emulated — pushes by digest, and a merge job per service stitches the two digests into one manifest list carrying :<version> and checks both platforms are in it. :latest is moved to that manifest by a promote job only after every supported host has installed the release and started it; a release that fails there leaves :latest where it was. The workflow refuses a tag whose version differs from CITATION.cff (the package and smoking-pi version report that file, so a mismatch would make a packaged install pull nothing). A candidate tag vX.Y.Z-rc.N runs the whole pipeline and publishes :X.Y.Z-rc.N images and a X.Y.Z~rc.N package (the asset is smoking-pi_X.Y.Z.rc.N_all.deb: GitHub turns ~ into .) on a GitHub pre-release — never latest, never /apt — so the Raspberry Pi acceptance runs on the exact artifacts (Release acceptance). A test-* git tag on any branch runs the same pipeline for a throwaway image tag of that name (never latest, no version check) to exercise it before a release; delete the tag and the package versions afterwards. Which tags are accepted, and what each publishes, is packaging/release-version.sh.

The mcp-server image is one of the images that workflow publishes. Its build repeats io.modelcontextprotocol.server.name (the same string as the Dockerfile LABEL and as server.json) so a registry can match the image this workflow already tags. There is no second image workflow, and nothing here publishes server.json.

Three places name the images and drift independently — the compose files, shared/modules/*/Dockerfile, the workflow's matrix — so packaging/check-images.py renders the three editions with every profile on and fails CI when a built service's image: is not <registry>/<its Dockerfile's module>:<version> with pull_policy: missing, when a Dockerfile is built by no edition, or when the matrix does not list exactly the Dockerfiles.

Release-only CI. This is the only place GitHub builds images. Before this change CI built four of the nine on every PR, amd64 only, and published none; now PR CI keeps the cheap checks (lint, shell syntax, module tests, the compose and packaging guards, the strict docs build) and the heavy work — nine images on two architectures, the docs deploy to Pages, later the .deb (#5) — happens once, from the release tag. What catches a broken Dockerfile between releases is the process rule that every change is built and deployed on the reference Pi before merge.

Moving a development host past this change. The new image names are part of the service configuration, so the next docker compose up -d recreates every container — and, finding no image under the new name, rebuilds all nine first. To keep the images already built (they are the same code), tag them under the new names before up:

for s in smokeping influxdb grafana web-admin config-manager postgres mcp-server ai-insights alerter; do
  docker tag "pro-$s" "ghcr.io/estcarisimo/smoking-pi/$s:dev" 2>/dev/null || true
done

(pro-smokeping and pro-postgres were the explicit names; the rest were Compose's pro-<service> default.) The containers still restart once.

The apt repository

Done 2026-09-22 (the workflow; publishing waits for the maintainer's key). A flat repository — deb [signed-by=...] <url> ./, no suites, no components — served by GitHub Pages under https://estcarisimo.github.io/smoking-pi/apt/, next to this site. The README has the three lines a user types.

How it is built, and why this shape:

  • From the releases, every time. docs.yml downloads the smoking-pi_*_all.deb asset of every vX.Y.Z GitHub release and builds the repository from all of them (packaging/apt/build-repo.sh; dpkg-scanpackages --multiversion, apt-ftparchive release). The releases are the source of truth: a manual workflow_dispatch of the docs rebuilds the same repository, so a docs hotfix can never publish a site without /apt. reprepro, the plan's tool, keeps a database of its own and forbids re-adding a version; a stateless rebuild from the release assets has neither problem.
  • After the Release workflow, not beside it. The docs used to deploy on the tag push, in parallel with the release. The .deb of that tag is attached by the release's attach job, after every host in the matrix passed, so the docs now run on workflow_run of Release completing successfully. Whether that run was a release is read from the commit it built (git tag --points-at HEAD after checking out head_sha), not from workflow_run.head_branch, whose value for a tag push is a known source of surprises; a test-* tag's run ends in a visible "nothing published" notice and a skipped deploy, never a silent no-op. Not yet exercised: workflow_run only fires for a workflow on the default branch, so the first vX.Y.Z release after this lands is the first real run — watch it, and its "Is this a release?" line.
  • Signed, or absent. APT_SIGNING_KEY (an Actions secret, the ASCII-armored private key without passphrase) signs InRelease and Release.gpg; the public key is exported as smoking-pi.gpg (binary, for signed-by=) and smoking-pi.asc. With the secret unset the site is deployed without /apt and a warning says so. An unsigned repository is refused by apt, and the workaround people find ([trusted=yes]) is worse than no repository.
  • Checked on every PR. The CI job apt repository builds two package versions and the repository with a throwaway key, adds it to the runner's apt as a file: source with signed-by=, and apt install smoking-pi takes the newest version from it, verified. Locally, the same script against any distro container proved the consumer side (Debian 13: Candidate: 0.0.0~b, installed).

What the maintainer does once — generate a key that lives only in the secret, and publish it there:

export GNUPGHOME=$(mktemp -d)
gpg --batch --passphrase '' --quick-gen-key 'Smoking Pi apt repository <you@example.org>' ed25519 sign never
gpg --batch --armor --export-secret-keys | gh secret set APT_SIGNING_KEY --repo estcarisimo/smoking-pi
gpg --batch --armor --export > smoking-pi-apt.asc   # keep a copy of the PUBLIC key; the private one is now the secret
rm -rf "$GNUPGHOME"

sign never is deliberate: an expiring key breaks every user's apt update on the day it expires. Rotation is a new secret and a new smoking-pi.gpg on the site; users re-run the curl line. The user ID's address is what key tooling shows people; use a real one of yours.

Then the next vX.Y.Z release (or a workflow_dispatch of Docs) publishes /apt. Until then apt install ./smoking-pi_<version>_all.deb from the release asset is the package path.

The command

Done 2026-09-20. cli/smoking-pi (packaging/smoking-pi until v2.28.0, which now only forwards there; installed as /usr/bin/smoking-pi by the package; from a clone, setup.sh, upgrade and smoking-pi link symlink it into /usr/local/bin, else ~/.local/bin) is the lifecycle table below, as commands. Everything it does is what the README and Upgrades say to type by hand; what it adds is the order and the guards.

Command What it does Guard
install [--edition] [--database] [--profiles mcp,alerts,ai] [--yes] whiptail menus or flags; setup.sh (secrets, backend profile); the optional profiles appended to COMPOSE_PROFILES in the env file and started; passwords printed refuses over an existing env file (setup.sh would rotate the secrets the volumes hold); validates edition, backend and profile names before touching anything; says which profiles need a key (ai: ANTHROPIC_API_KEY, alerts: NOTIFY_MODE)
upgrade [--skip-doctor] SMOKING_PI_VERSION set (the package, or a clone on a release tag): compose pull — the version changed with the package or the checkout, so this fetches the release (no such images: it stops and names SMOKING_PI_VERSION=dev); unset (a clone off any tag): compose build --pull; then up -d --remove-orphans; with every container running, and still running ten seconds later, the registry's images of other releases are removed one image rm at a time (never prune, never an image a container uses), then the doctor --live refuses without an env file; keeps every image while a container is not running (the previous release is the fallback); prints the reminder that a PostgreSQL or InfluxDB major is a migration (Upgrades)
backup [DIR] [--online] pg_dumpall (the restore path for a PostgreSQL major); the volumes the active services mount, each as volumes/<compose key>.tgz (postgres-data.tgz, whatever Docker name it had), with the stack stopped (--online skips the stop; the tarballs may be inconsistent, and the manifest says so); the env file (mode 600) and the config directory; a manifest (edition, version, online, each key's Docker name) lists the volumes with sizes before stopping; a trap restarts the stack if a tar fails; the directory is mode 700 (it holds every secret)
restore DIR [--force] [--no-start] [--yes] env file and config only where missing (--force overwrites); each tarball's key resolved to the volume this stack mounts for it — pro_postgres-data, or a fixed name: such as Standard's smokeping-standard-postgres-data — from the rendered config, never from the file name; keys no active service mounts are listed and skipped; down; contents replaced (emptied, then extracted; a failure is reported per volume and the rest continue, the stack stays stopped); up (--no-start leaves it stopped to inspect) refuses a directory without a manifest or of another edition (a packaged host with no env file and no edition recorded, a new card, takes the backup's edition instead and records it); shows the plan and warns about an --online backup, then asks you to type the project name (as destructive as purge)
purge [--config] [--yes] down; docker volume rm of the active services' volumes; --config also the env file, the edition record, config and output directories (what install needs gone to start over), and on a package install it first stops and disables the unit, while its env file still exists (install enables it again) asks you to type the project name — --yes is for scripts
(none) / link Alone: the version, the edition, how many of its services run, the address to open, the common commands (--help is the reference). link, from a clone: a symlink to the checkout's cli/smoking-pi in /usr/local/bin (directly, or through a sudo that asks no password), else ~/.local/bin; setup.sh, install and upgrade run it link does nothing outside a clone (no .git, or packaged mode), never shadows another smoking-pi that is not a checkout's link (the package's /usr/bin one, which points into /usr/lib/smoking-pi), never replaces a real file; a link another checkout left is repointed, and it says so

Two details cost a lesson each on the reference Pi:

  • Which volumes. The first version selected volumes by Compose's project label. That set includes a 5.9 GB ClickHouse volume declared in the base file with a fixed name and mounted by no running service (a leftover of the ClickHouse trial), and the first offline backup spent ten minutes of downtime tarring it. The command now reads the rendered config and takes the volumes the active services actually mount — the same set purge deletes.
  • Which env file. Every command takes the env file from SMOKING_PI_ENV_FILE; upgrade run with a stand-in env file (CI's dummy one, by mistake) rebuilt and recreated four containers with dummy secrets against the real volumes. Nothing in the volumes changed — those passwords only apply at first initialization — and up with the real file put it back in two minutes; but smoking-pi paths exists for that reason: read it before upgrade on a host with more than one env file.

The independent review of the PR found the third lesson before it reached anyone: the first restore rebuilt the target volume's name from the tarball's file name (<project>_<suffix>), which is right for Pro's default-named volumes — the only ones the Pi test used — and wrong for every volume declared with a fixed name:, i.e. all of Basic's and Standard's; the data would have landed in a volume nothing mounts. Hence the keys: the backup names tarballs by the Compose volume key and the restore asks the config it is restoring into what that key is called.

cli/tests/cli.bats runs the command against a stubbed docker that records every invocation and answers config --format json, --services and ps: which files in which order (clickhouse overlay, packaged override last), the refusals, the order dump → down → tars → up, that the ClickHouse volume is skipped, a fixed-name volume restored under the right name, a failed extract reported, the typed confirmations. CI runs it (smoking-pi command (bats)). It found one bug before the Pi did: a trailing [ … ] && run_doctor made upgrade --skip-doctor exit 1.

Own repository or a distribution's?

Own. A Debian or Raspberry Pi OS package must build from source and cannot depend on pulling container images at first start; a package whose payload is compose files pointing at pinned upstream images is exactly what those policies exclude. An apt repository on GitHub Pages costs one signing key and one workflow step and gives apt upgrade. Third-party channels (Homebrew tap, a Snap) are the same package with a different wrapper and can follow once the apt path is proven.

Supported hosts

Measured 2026-09-21, not assumed: the package installed with each host's own apt in a container of that OS (arm64, on the Pi), so the host's own resolver chose the engine, CLI and Compose from what its repositories hold. The release workflow repeats it on every tag (packaging/tests/check-package.sh, below); the "Proven by" column names what the throwaway runs test-d0b5662 and test-host-matrix actually executed — read the run, not this table, for a given release.

Host Engine and Compose the host offers apt install ./smoking-pi.deb Proven by
Raspberry Pi OS (Debian 12 bookworm), arm64 — the reference Debian ships docker.io 20.10 and the Python docker-compose 1.29: no Compose v2. Docker's repository (what the Raspberry Pi docs install) adds docker-ce 29 + docker-compose-plugin 5 with Docker's repository, yes (docker-ce chosen); on a stock system apt refuses — install Docker first release CI: Debian 12 container, both architectures, package-level; the stack itself only on the reference Pi
Debian 13 trixie (the next Raspberry Pi OS), arm64 and amd64 docker.io 26.1.5+dfsg1-9+deb13u1 with docker-cli split into its own package (the daemon only Recommends it; the CLI left docker.io at 26.1.4+dfsg1-7, hence the package's version bound), docker-compose 2.26.1 yes, from Debian's own archive release CI: Debian 13 container, both architectures, package-level
Ubuntu 22.04, amd64 and arm64 docker.io 29.1 + docker-compose-v2 2.40 (jammy-updates); Python 3.10 yes release CI: VM, both architectures, Basic started with the release's images, the unit enabled and stopped
Ubuntu 24.04, amd64 and arm64 docker.io 29.1 + docker-compose-v2 2.40; Python 3.12 yes release CI: VM, both architectures, as above; one entry with Docker's own engine instead
Debian 11 bullseye (Raspberry Pi OS Legacy) docker.io 20.10, docker-compose 1.25; no Compose v2 anywhere no without Docker's repository; not tested further —
Raspberry Pi OS 32-bit (armhf) — not supported: the images are published for arm64 and amd64 only —

What the measurement changed in the package (the first Depends line would have failed on three of the four supported hosts):

  • docker-ce | docker.io — apt takes the first installable alternative, so Docker's engine wins wherever its repository is configured (bookworm with the order reversed picked Debian's 20.10 daemon next to Docker's Compose 5), Ubuntu's docker.io otherwise.
  • docker-ce-cli | docker-cli | docker.io (<< 26.1.4) — trixie's daemon no longer contains the CLI; without this the package installed a daemon and no docker command.
  • docker-compose-plugin | docker-compose-v2 | docker-compose (>= 2) — trixie's Compose v2 is called docker-compose; the version bound keeps bookworm's and jammy's Python 1.x out.
  • install now records the edition it installed in /etc/smoking-pi/edition, a state file beside the env file; the unit no longer hard-codes pro. Without it a packaged Basic install was going to be started as Pro by systemctl start smoking-pi. The first version of this fix wrote the edition into /etc/default/smoking-pi; the upgrade test (below) showed why that is wrong: a conffile edited by anyone plus a shipped conffile that changed by a character stops the upgrade at dpkg's conffile prompt — fatal under a non-interactive apt. Nothing the package or the command does edits that file, and packaging/tests/check-package.sh fails if its md5 ever differs from the one dpkg recorded. (The bats suite covers the record; the Docker-in-Docker upgrade run and the release's host jobs cover the conffile.)

The packaged overlay's !override needs Compose >= 2.24: every Compose a supported host offers (2.26, 2.40, 5.5) renders all three editions.

What a container or an Ubuntu VM does not prove: the Raspberry Pi kernel and its Wi-Fi driver (Wi-Fi uplink stats), the real first hop, boot ordering against docker.service, the arm64 image pulls on a Pi's link, and everything Release acceptance on a Pi covers. A green matrix says the package installs and its editions render on that OS version, nothing about measurements from it.

Other platforms, unchanged: macOS via Homebrew later, with the fidelity caveat above (Docker Desktop's host network is the VM); Podman not supported and not planned — config-manager needs the Docker socket and the compose files use v2 semantics (profiles, depends_on.required, network_mode: host).

The check, and running it yourself

packaging/tests/check-package.sh <deb> is the whole check, one script for CI and for a local run, as root on a host that may lose the package afterwards (it removes it at the end). Steps, in order: apt-get install of the file with --no-install-recommends (the worst case for a split package), which engine/CLI/Compose apt chose (--expect-engine), docker compose version (the CLI and the plugin present), smoking-pi version against the tag, paths in the packaged layout with the images pinned, the state directories and /etc/smoking-pi at 0750, systemd-analyze verify, the doctor's static checks, every edition rendered with that host's Compose and the packaged overlay (no state mount left under /opt), install refusing an existing env file, and — with --start basic on a host with a daemon — the real first install, the web UI answering, systemctl enable --now smoking-pi, Docker restarted and Docker stopped then started under it (the stack must answer again by itself after each), a stop, then apt-get remove keeping the env file and the output directory.

From the Pi, against any OS version, without touching the Pi's own stack:

packaging/deb/build.sh 0.0.0~local
docker run --rm -v "$PWD:/repo:ro" debian:trixie bash -c \
  'apt-get update -qq && apt-get install -y -qq systemd >/dev/null &&
   /repo/packaging/tests/check-package.sh /repo/dist/smoking-pi_0.0.0~local_all.deb --expect-engine docker.io'

(For bookworm add Docker's repository first, as release.yml does, and expect docker-ce.)

Lifecycle, end to end (target state)

Step Command What happens
Install apt install smoking-pi Code under /opt/smoking-pi, CLI, unit; nothing runs yet — today dpkg -i of the .deb on the GitHub release; apt once the repository exists (#5)
Configure smoking-pi install Edition, backend, profiles (TUI or flags); secrets into /etc/smoking-pi/env; images pulled; first start; OpenClaw offered; secrets set but not printed; ends on the address to open, checked to answer (smoking-pi url) — done
Boot systemctl enable smoking-pi (install does it) up after Docker; down at shutdown
Status smoking-pi status, smoking-pi url, smoking-pi doctor --live Compose state; where to open it and whether it answers; the doctor's checks
Upgrade apt upgrade && smoking-pi upgrade New tree and CLI; new images pulled; up -d restarts what changed; doctor after. Stateful majors (PostgreSQL, InfluxDB) are excluded from routine upgrades exactly as they are excluded from Dependabot today; smoking-pi backup before a major — done (the command; the .deb in the release is #5)
Failure smoking-pi logs <service> And the doctor's deployed-code-current says whether the container matches the package
Remove apt remove / apt purge / smoking-pi purge Code; +config; +volumes, in that order, the last one only on explicit confirmation

Every command in that table that reads the env file runs as root on a packaged install: /etc/smoking-pi is 0750 and root-owned, which is the point of keeping the secrets there. Run by an ordinary user, every command that reads it stops and names the sudo command to run, rather than answering from an empty file; version, --help, doctor, discover and link still work. sudo throughout, and none of it from a clone, where the env file is the user's own.

What this does not change

The architecture. Nothing above splits or merges a service, and the host-network trio stays host-network. The question was whether the shape fits a package; it does, once the package stops needing the checkout. The Docker dependency also stays: it is the reason four upstream projects cost nothing to maintain here, and removing it would mean alternative A.