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, 5python:3.14-slimapps. 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.pyfiles 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.envgenerated bysetup.shinto 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), everybuild: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 --infoand--contentsare 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 versionreads 2.9.0 fromCITATION.cff;smoking-pi pathsreports the edition, env file and profiles. docker compose configsucceeds from/opt/smoking-pi/editions/prowith theinfluxdb,mcp,alertsprofiles: the relative../../sharedbuild 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-piexists). Its start timeout isinfinityon purpose: when images are built rather than pulled (a clone, or a package whose registry is unreachable), a cold firstupcan outlast any timeout, and systemd killing a build half-way is the worst outcome — soinstallis run interactively first and the unit handles every boot after. It is wanted bydocker.serviceas well asmulti-user.target:Requires=docker.servicestops it (andExecStopremoves 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.postinstre-enables an enabled unit so an upgrade adds it. smoking-pi installrefuses to run over an existing.env:setup.shregenerates 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:
.envand the mutated config directories sit inside/opt. A package upgrade replaces/opt/smoking-pi;editions/pro/.envwould be orphaned or clobbered, andconfig-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.- 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". - 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 anapt upgradechanges code inside a running container without a restart. Closed by backlog #2 (2026-09-20) — see Packaged mode below. - config-manager writes generated output as root into a directory the
package owns; upgrades would tread on it. It belongs in
/var/lib. - Helper scripts hard-code
pro-influxdb-1,pro_default,grafana-influx_*and Compose v1 invocations; harmless while the project name stayspro, but a package should not depend on it. Thegrafana-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_VERSIONunset →: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 namedghcr.io/estcarisimo/smoking-pi/<service>:devinstead ofpro-<service>. A:latestdefault would have done the opposite: a fresh clone would silently run the last release's code with a newer checkout, and only the doctor'sdeployed-code-currentwould 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.0overv2.13.0-rc.3). Before, only aSMOKING_PI_VERSIONtyped in front of every command did this, and the reference Pi — onv2.13.0, running the:2.13.0images — was one baresmoking-pi upfrom recreating every container on stale local:devbuilds.SMOKING_PI_VERSION=devbuilds the checkout whatever it is on. Only the command does this; a baredocker composestill means:dev. - The package: in packaged mode the command takes
SMOKING_PI_VERSIONfrom 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 pathsprints 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_REGISTRYpoints 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.ymldownloads thesmoking-pi_*_all.debasset of everyvX.Y.ZGitHub 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 manualworkflow_dispatchof 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
.debof that tag is attached by the release'sattachjob, after every host in the matrix passed, so the docs now run onworkflow_runof Release completing successfully. Whether that run was a release is read from the commit it built (git tag --points-at HEADafter checking outhead_sha), not fromworkflow_run.head_branch, whose value for a tag push is a known source of surprises; atest-*tag's run ends in a visible "nothing published" notice and a skipped deploy, never a silent no-op. Not yet exercised:workflow_runonly fires for a workflow on the default branch, so the firstvX.Y.Zrelease 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) signsInReleaseandRelease.gpg; the public key is exported assmoking-pi.gpg(binary, forsigned-by=) andsmoking-pi.asc. With the secret unset the site is deployed without/aptand 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 withsigned-by=, andapt install smoking-pitakes 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
purgedeletes. - Which env file. Every command takes the env file from
SMOKING_PI_ENV_FILE;upgraderun 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 — andupwith the real file put it back in two minutes; butsmoking-pi pathsexists for that reason: read it beforeupgradeon 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'sdocker.iootherwise.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 nodockercommand.docker-compose-plugin | docker-compose-v2 | docker-compose (>= 2)— trixie's Compose v2 is calleddocker-compose; the version bound keeps bookworm's and jammy's Python 1.x out.installnow records the edition it installed in/etc/smoking-pi/edition, a state file beside the env file; the unit no longer hard-codespro. Without it a packaged Basic install was going to be started as Pro bysystemctl 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-interactiveapt. Nothing the package or the command does edits that file, andpackaging/tests/check-package.shfails 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'shostjobs 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.