Skip to content
GitHub

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.

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.volume and hotloop-data.volume: the database, and everything the app writes under /data (backups, certificates, the forward queue, reports). Named Podman volumes, so podman system prune leaves 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.

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:

Terminal window
sudo cp gateway/hotloop.container gateway/hotloop-db.container /etc/containers/systemd/
sudo systemctl daemon-reload
sudo systemctl restart hotloop.service hotloop-db.service

The 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.

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.

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. No Requires= on a database, no network, no database password, and HOTLOOP_DB_TYPE=sqlite with the file at HOTLOOP_DB_PATH=/data/hotloop.db. It follows :stable and rolls back a bad update exactly like the PostgreSQL unit: same healthcheck, same --sdnotify=healthy.
  • sqlite/hotloop-data.volume: the hotloop-data volume, which is where the database file and its -wal and -shm live. 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:

Terminal window
cd iot/sqlite # or edge/sqlite, gateway/sqlite
sudo cp hotloop.container hotloop-data.volume /etc/containers/systemd/
# make hotloop-secret-key and hotloop-admin-password-hash as the header says
sudo systemctl daemon-reload
sudo systemctl start hotloop.service
curl -s localhost:8080/api/status

A 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.

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-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.json and edit it for the actual devices at this site, see docs/EDGE-RELAY.md for the full format.
Terminal window
sudo mkdir -p /etc/containers/systemd /etc/edge-relay
sudo cp edge-relay.container edge-relay-data.volume /etc/containers/systemd/
sudo cp devices.example.json /etc/edge-relay/devices.json # then edit it
sudo "$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-reload
sudo systemctl start edge-relay.service
sudo systemctl status edge-relay.service
curl localhost:8081/status

Updates. The unit follows :stable and updates itself once Podman’s timer is on:

Terminal window
sudo systemctl enable --now podman-auto-update.timer

If 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.