Podman Quadlet
For a Pi or a small industrial PC that is not part of a k3s cluster. If it is, use the product’s chart instead. This directory and the charts are the two supported ways to run HotLoop.
No Docker, and no bare podman run from a shell that dies with the session.
Quadlet turns the containers into real systemd services: start on boot with
no user logged in, restart on failure, log to journald.
| Directory | Product | Image |
|---|---|---|
iot/ |
HotLoop IoT, with PostgreSQL 16 | ghcr.io/hotloop-io/hotloop-iot |
edge/ |
HotLoop Edge, with TimescaleDB | ghcr.io/hotloop-io/hotloop-edge |
gateway/ |
HotLoop Gateway, with TimescaleDB | ghcr.io/hotloop-io/hotloop |
| this directory | HotLoop Edge Relay, no database | ghcr.io/hotloop-io/hotloop-edge-relay |
docs/PRODUCTS.md says which one you want.
Each of the first three also has a sqlite/ directory: the same product with
the database as a file and no database container. See
SQLite instead of PostgreSQL.
IoT, Edge and the Gateway
Section titled “IoT, Edge and the Gateway”Each directory has the same five units:
hotloop.network: the network the app and its database share. The app reaches the database by name (hotloop-db:5432) and nothing else on the box can, because the database publishes no port. Only the app’s 8080 is published.hotloop.container: the app. Its header has the install steps; read it before copying anything.hotloop-db.container: the database, the same image and version that product’s chart runs. It reports started once it answers, so the app waits for a database it can use.hotloop-db.volumeandhotloop-data.volume: the database, and everything the app writes under/data(backups, certificates, the forward queue, reports). Named Podman volumes, sopodman system pruneleaves them alone.
Three Podman secrets hold what doesn’t belong in a file:
hotloop-db-password, hotloop-secret-key (encrypts device passwords in the
database; keep a copy off the box) and hotloop-admin-password-hash (the
bcrypt hash of the first admin’s password, never the password).
Podman 5.0 or newer. Raspberry Pi OS Bookworm ships Podman 4.3, which has no Quadlet at all: use Raspberry Pi OS Trixie (Podman 5.4) or newer. The updates below were run on Podman 6.1.3; the Edge Relay’s unit, which shares the same keys, on 4.9.3. And put the database volume on a USB SSD, not the SD card. History writes all day, and SD cards die of exactly that.
Why every product uses the same names
Section titled “Why every product uses the same names”The unit names, the volume names and the secret names are identical in
iot/, edge/ and gateway/, on purpose. Moving a box up a product is
copying the bigger product’s hotloop.container and hotloop-db.container
over yours:
sudo cp gateway/hotloop.container gateway/hotloop-db.container /etc/containers/systemd/sudo systemctl daemon-reloadsudo systemctl restart hotloop.service hotloop-db.serviceThe volumes and the secrets stay, and so does every device, tag, alarm, user
and hour of history. Back up first anyway (POST /api/backup). That move has
been run, IoT to Edge to the Gateway on one set of volumes. Going back down a
product hasn’t been yet, so don’t, on a box you care about.
It also means one HotLoop per box. Two would fight over port 8080 anyway.
Verified
Section titled “Verified”The three products’ shipped files, untouched, generate under the quadlet
generator from Podman 6.1.3 with the same structure: the app requires the
database and the network, publishes its own port, carries the auto-update
label, and the database carries none and publishes nothing.
The Gateway’s units were then run for real: rootless, under systemd, with a
local registry standing in for ghcr.io and the products’ own podman auto-update. A first start with the database coming up healthy and the app
healthy after it; an update to a release that adds a migration to a table the
app already uses, taken with the existing device and its data intact and the
database container never restarted; and an update to a release that migrates
the database and then can’t start, which Podman rolled back, the previous
release starting healthy on the database the failed one had already migrated.
Edge and IoT were generated and compared, not run. Under systemd on a real
Pi: not yet.
SQLite instead of PostgreSQL
Section titled “SQLite instead of PostgreSQL”The database is a setting, not an image (docs/DEPLOYMENT.md, “SQLite”). Under
Quadlet that means a second set of files, not a second image. Each product’s
sqlite/ directory has two:
sqlite/hotloop.container: the app alone. NoRequires=on a database, no network, no database password, andHOTLOOP_DB_TYPE=sqlitewith the file atHOTLOOP_DB_PATH=/data/hotloop.db. It follows:stableand rolls back a bad update exactly like the PostgreSQL unit: same healthcheck, same--sdnotify=healthy.sqlite/hotloop-data.volume: thehotloop-datavolume, which is where the database file and its-waland-shmlive. The same volume name as the PostgreSQL files’, so it is the same data volume if you ever switch.
| Directory | Install | Secrets |
|---|---|---|
iot/, edge/, gateway/ |
hotloop.network, hotloop.container, hotloop-db.container, hotloop-db.volume, hotloop-data.volume |
three |
iot/sqlite/, edge/sqlite/, gateway/sqlite/ |
hotloop.container, hotloop-data.volume |
two: hotloop-secret-key and hotloop-admin-password-hash |
Install one set or the other, never both: they use the same unit names. The
header of sqlite/hotloop.container has the exact steps. In short:
cd iot/sqlite # or edge/sqlite, gateway/sqlitesudo cp hotloop.container hotloop-data.volume /etc/containers/systemd/# make hotloop-secret-key and hotloop-admin-password-hash as the header sayssudo systemctl daemon-reloadsudo systemctl start hotloop.servicecurl -s localhost:8080/api/statusA few things to know before you pick it. SQLite is one writer on one local
disk: a Pi puts the volume on a USB SSD, and network storage is not an option
because SQLite corrupts on it. History is one table with an index, not
partitions or a hypertable, so a site with years of history at one second wants
the PostgreSQL files. And moving a box between SQLite and PostgreSQL is a
backup and a restore (BACKUP.md), not a swap of units:
the new database starts empty. Do not copy a sqlite/hotloop.container over a
running PostgreSQL install to “switch”; the PostgreSQL volume’s data is not
read by it.
Plain PostgreSQL on Edge or the Gateway is the TimescaleDB unit with
Environment=HOTLOOP_HISTORIAN_TIMESCALE=false added to hotloop.container.
IoT’s unit is plain PostgreSQL already, and IoT has no TimescaleDB unit at all.
Verified (SQLite)
Section titled “Verified (SQLite)”iot/sqlite/hotloop.container and its volume were run rootless on Podman 4.9.3
under systemd (systemctl --user) from the committed files, changed only in the
names, the port, the image (built from the same tree) and the runtime directory
a user manager needs. The unit came up healthy, /healthz and /readyz
answered 200, the admin logged in from the hash secret, /api/status answered,
hotloop.db, hotloop.db-wal and hotloop.db-shm appeared on the volume,
and a restart applied no migrations and logged the admin in again. Edge and
Gateway sqlite/ units are the same file apart from the image and the
retention default, and were checked by the guard, not run. Under systemd as
root on a real Pi: not yet.
Edge Relay
Section titled “Edge Relay”edge-relay-hold.conf: opt-in: hold image updates until an operator approves them. A systemd drop-in, not part of a default install.edge-relay.container, the service itself. Full install steps are in its own header comment; read it before copying anything.edge-relay-data.volume, a named Podman volume for the forward queue, so buffered readings survive a container restart. Podman creates it automatically the first time the service starts.devices.example.json, a starting point for the device file. Copy it to/etc/edge-relay/devices.jsonand edit it for the actual devices at this site, see docs/EDGE-RELAY.md for the full format.
Quick start
Section titled “Quick start”sudo mkdir -p /etc/containers/systemd /etc/edge-relaysudo cp edge-relay.container edge-relay-data.volume /etc/containers/systemd/sudo cp devices.example.json /etc/edge-relay/devices.json # then edit itsudo "$EDITOR" /etc/containers/systemd/edge-relay.container # broker, group, node
# Skip this if the broker takes anonymous connections.printf '%s' 'your-broker-password' | sudo podman secret create edge-relay-broker-password -
sudo systemctl daemon-reloadsudo systemctl start edge-relay.servicesudo systemctl status edge-relay.servicecurl localhost:8081/statusUpdates. The unit follows :stable and updates itself once Podman’s timer
is on:
sudo systemctl enable --now podman-auto-update.timerIf a new image can’t pass its healthcheck, Podman puts the old one back. The
Gateway’s fleet list shows each relay’s version and image digest and logs every
change. To hold updates until an admin approves them on the Gateway, install
edge-relay-hold.conf as the comment at the top of that file says, and turn the
timer off. Everything this does, and what it can’t, is in
docs/EDGE-RELAY.md.
To pick up a change to the local file: edit it, then
sudo systemctl restart edge-relay.service. The relay reads that file at boot
and doesn’t watch it.
A device file the Gateway pushes doesn’t need any of that: it applies live,
without a restart. It can’t be written to /etc/edge-relay/devices.json
(that’s mounted :ro on a read-only root), so it goes on the data volume as
/data/pushed-devices.json, and while that exists it’s what the relay runs
and boots from. Your edits to the local file sit there unused until you clear
the push: DELETE /api/fleet/{id}/config on the Gateway while the relay is
running, and it switches back to the local file on the spot. The journal
tells you which file it’s on, so check there before you start doubting your
JSON.
Verified against the actual quadlet binary (podman-5.8.6’s
/usr/libexec/podman/quadlet -dryrun): both units parse cleanly and generate
the expected podman run invocation, with the volume dependency and secret
wired in automatically.