Deploy a self-written long-running app (poller, monitor, etc.) as a Home Assistant OS
local add-on, running 24/7 on a Raspberry Pi instead of a workstation that sleeps.
Every step below has been verified on real hardware; all pitfalls are marked ⚠️.
In the examples, <alias> = your SSH alias for the HA box, <slug> = add-on slug (becomes
local_<slug> once installed), and $C = the Docker container name resolved at runtime
by the snippet in Scope below — never hardcoded, because its prefix differs between Supervisor
versions. Substitute your own values; nothing here is host-specific.
Scope
- Applies to Home Assistant OS (or Supervised) installs only — Container/Core
installs have no Supervisor, no
/addons, nohaCLI; this skill does not apply. - Battle-tested on a Raspberry Pi 4 (aarch64), HAOS 17.x–18.1 (Supervisor + Docker appliance; the container naming below was observed on Supervisor 2026.07.5), with the Advanced SSH & Web Terminal add-on (Protection mode off — see §0, §8).
- ⚠️ Recent HAOS labels add-ons as "Apps" in the UI and CLI (
ha apps), and the rename is half-applied — never assume which spelling a given surface uses, always check:- still
addon: the/addonsdelivery path, the Supervisor REST API (/addons/<slug>/…), and the Docker image name (local/<arch>-addon-<slug>:<version>) - now
app: the CLI (ha apps), the Supervisor's own store path (/data/apps/local/…), and the Docker container name (app_local_<slug>, seen on Supervisor 2026.07.5) - ⚠️ Do not key this off the HAOS version. The container name is produced by
Supervisor, which auto-updates independently of the OS image, so "newer HAOS ⇒
app_" is not a sound rule — and only one Supervisor version has actually been observed here. Never hardcode either prefix; resolve$Conce and reuse it everywhere:
Without that guard an empty# -a so a stopped add-on is still found — those are exactly the ones you need to inspect C=$(ssh <alias> "sudo docker ps -a --format '{{.Names}}' \ | grep -E '^(app|addon)_local_<slug>$'") || true [ -n "$C" ] || { echo "no container for <slug> — installed under a different slug?" >&2; exit 1; }$Cturnsdocker exec $C python3 …intodocker exec python3 …, and Docker reports "no such container: python3" — a confusing error that sends you looking in the wrong place.
- still
0. Global Rules (read first)
- ⚠️ Every
haCLI / Supervisor API call needs a login shell, which loadsSUPERVISOR_TOKEN; without it the call fails withunauthorized. Plaindocker/ls/grepare unaffected — onlyhaand directhttp://supervisorcalls need the token.- Single command:
ssh <alias> 'bash -lc "ha …"' - Multi-step:
ssh <alias> bash -l -s <<'EOF' … EOF— the-lis what makes a heredoc work; a barebash -sheredoc is a non-login shell and everyhacall inside it fails. - ⚠️ How that failure looks matters.
hawritesError: unauthorized: missing or invalid API tokento stderr and exits 1. So a pipeline that only consumes stdout (ha apps info … | grep …) shows an empty result, which reads exactly like "that field isn't set" rather than "the call failed". Check the exit code, or add2>&1, before concluding anything from an empty output.
- Single command:
- ⚠️
sshreads stdin, so give eachsshthe stdin it actually needs. Both directions have bitten real deploys, and both fail silently: no error, the commands just never run.- Script for the remote in a heredoc →
ssh <alias> bash -l -s <<'EOF'with no-n:-npoints stdin at/dev/null, so the heredoc never reaches the remote shell. - One-shot
ssh <alias> '…'inside a script that is itself read from stdin (abash <<'EOF'wrapper, or any script piped intobash) → add-n(or</dev/null). Without it, thatsshswallows the rest of the local script as its own stdin, and every line after it is skipped. - The tell: a step's output is simply missing. Read a missing block as "didn't run", never as "passed", and re-run that step on its own to confirm.
- Script for the remote in a heredoc →
- ⚠️ Protection mode on the SSH add-on must be off, otherwise you can't reach the Supervisor API. The user flips it on the add-on's info page; an agent asks the user and, when it stays on, falls back to log-based verification (§5 explains why).
- ⚠️ CLI names: use
ha apps(ha addonsis deprecated); to re-detect local add-ons useha store reload(ha apps reloaddoesn't exist). - ⚠️ Never run the same app on two machines at once (workstation daemon + RPi add-on): it doubles the load on external APIs/sites, and anything with rotating tokens will have the two instances kicking each other out. Once migrated, stop the workstation copy and disable its autostart.
- A single-SD-card RPi has no redundancy: take a full HA backup before touching anything,
and download it off the card. The web download is slow (HA streaming bottleneck);
scp <alias>:/backup/<file>.tar ~/backups/is noticeably faster.
1. Repo Layout
Keep all HA-specific files in a haos/ folder in the repo; flatten on delivery:
haos/
├── config.yaml # add-on manifest (name/slug/version/arch/options/schema)
├── Dockerfile
├── run.sh # entrypoint: create /data subdirs, then exec the main app
└── .dockerignore # exclude .env, logs/, __pycache__, .git/
config.yaml essentials:
name: My App
slug: my_app # installed as local_my_app
version: "1.0.0" # ⚠️ bump this to make HA detect a code update
arch: [aarch64, amd64]
startup: application
boot: auto
map:
- data:rw # /data = persistent volume, survives rebuild/update
init: false
options: # user-editable settings (HA web UI "Configuration" tab)
some_secret: ""
schema:
some_secret: password # the password type masks the value in the UI
⚠️ Base image choice (musl vs glibc): pure Python/requests → python:3.12-alpine
is fine (small); but anything depending on the onnxruntime / opencv / numpy ecosystem
(e.g. ddddocr) must use python:3.12-slim (Debian glibc) — those packages ship no
musllinux wheels, so on Alpine pip falls back to source builds (hangs or fails).
Non-headless opencv additionally needs apt-get install libgl1 libglib2.0-0.
Not using the HA official base image → no bashio; the app reads its own config
from /data/options.json instead.
Idiomatic config loading: if /data/options.json exists, upper-case each key into an
environment variable (parse list-typed fields, e.g. a watch list, directly); if it
doesn't (local development), fall back to .env.
2. Deliver the Code (run on the workstation, into /addons)
The device never clones (avoids credentials for private repos); the workstation pushes a snapshot instead. Pick one:
Committed tree (the default once the repo has commits): ships exactly HEAD, so no
stray local files (a half-finished edit, a debug script) ride along by accident — that
guarantee is the whole reason this is the default.
# /addons is owned by root; create the folder once to gain write access
ssh <alias> 'sudo mkdir -p /addons/<slug> && sudo chown $(whoami) /addons/<slug>'
git archive --format=tar HEAD | ssh <alias> 'tar -x -C /addons/<slug>'
git archive --format=tar HEAD haos | ssh <alias> 'tar -x --strip-components=1 -C /addons/<slug>'
ssh <alias> 'rm -rf /addons/<slug>/haos' # remove the duplicate haos/ subfolder the full-HEAD archive brought along
Working tree directly, no commit required first — either the repo has no commits yet, or
this is a hotfix that needs to reach the box now; committing is a separate, later step (see
git-helper), not a prerequisite for deploying. Name the paths
this add-on actually needs, same as the committed-tree route — never tar the whole project root
blindly (see the duplicate-slug fix below for why):
ssh <alias> 'sudo mkdir -p /addons/<slug> && sudo chown $(whoami) /addons/<slug>'
tar -cf - <app files/dirs, e.g. main.py requirements.txt app/> | ssh <alias> 'tar -x -C /addons/<slug>'
tar -cf - -C haos . | ssh <alias> 'tar -x -C /addons/<slug>'
⚠️ tar doesn't read .dockerignore (that's a Docker-build-time mechanism, not a tar
one) — __pycache__, .venv, stray .pyc files, whatever's locally present rides along
if you don't exclude it explicitly. Clean it off the delivered directory afterward if it
slips through: ssh <alias> "find /addons/<slug> -name __pycache__ -type d -exec rm -rf {} +".
⚠️ This path ships whatever is on disk right now, uncommitted work included — none of the committed-tree route's filtering. Fine for a deliberate hotfix; for a repo with no commits yet, treat it as a one-time bootstrap and switch to the committed-tree route once the first commit lands, not as the standing method. Either way, a hotfix still needs its own commit afterward — this route only decouples when that happens from when the box gets the fix.
⚠️ The deploy directory must be flat and self-contained: config.yaml/Dockerfile/run.sh
all at the top level of /addons/<slug>/.
⚠️ Two directories must never declare the same slug (either delivery method above can cause this)
Supervisor's local store walks the local add-on tree recursively — the source does
path.glob("**/config.*") (supervisor/store/data.py), so it accepts config.yaml,
config.yml and config.json, at any depth. The committed-tree route's git archive --format=tar HEAD
ships the whole repo, so if
the repo also holds a second add-on's folder (one repo, two add-ons — e.g. a poller and an
ingest service), that second manifest lands inside this add-on's deploy directory and now
two directories declare that second slug.
⚠️ Which one wins is not deterministic. Supervisor simply overwrites as it walks
(apps[app_slug] = app), so the last one visited wins — and Path.glob returns
filesystem scandir order, not sorted, not by mtime, and not by highest version. The
outcome can flip after a re-delivery or a reboot, so a single "looks fine on this box" check
proves nothing. The only safe state is: never let two manifests declare the same slug.
When the stale copy wins, for the other add-on:
version_latestreports the stale copy's version, soupdate_availablestaysfalseandha apps updateis a no-op — no matter how many times you re-deliver the real directory- the Docker build context is that stale directory too, so a successful-looking
ha apps rebuildbakes old code into the image ha store reload,ha supervisor restart,docker builder prune -a, a full HAOS reboot, and evenha apps uninstall+installall change nothing — Supervisor isn't stale, the winning directory genuinely is old. (Real case: a whole day lost to this, misdiagnosed as a Supervisor version-tracking bug.)
Best fix: don't ship the whole repo. Send only the paths this add-on needs, so the collision never exists:
git archive --format=tar HEAD -- <app-dir> <shared-dir> | ssh <alias> 'tar -x -C /addons/<slug>'
(.gitattributes with export-ignore on the other add-on's directory achieves the same.)
If you must ship everything, delete the other add-on's directory afterwards — with the
delete guarded, because an unset variable here expands to rm -rf /addons// and takes out
every add-on on the box:
ssh <alias> bash -l -s <<'EOF'
set -euo pipefail
SLUG=<slug>; OTHER=<other-addon-dir>
ls -la -- "/addons/${SLUG:?}/${OTHER:?}" # look before you delete
rm -rf -- "/addons/${SLUG:?}/${OTHER:?}"
EOF
Then verify — as a mechanical check, not an eyeball one (exits non-zero on a duplicate):
ssh <alias> bash -l -s <<'EOF'
find /addons -name 'config.*' ! -name '.*' -not -path '*/rootfs/*' \
-exec sh -c 'printf "%s → " "$1"; grep "^slug:" "$1"' _ {} \;
find /addons -name 'config.*' ! -name '.*' -not -path '*/rootfs/*' \
-exec grep -h "^slug:" {} \; | sort | uniq -d > /tmp/dupe_slugs
[ ! -s /tmp/dupe_slugs ] || { echo "DUPLICATE SLUG:"; cat /tmp/dupe_slugs; exit 1; }
EOF
Diagnosing it when already deployed — compare a source file's byte count inside the
container against the same file on disk; if they differ, the build context is not the directory
you delivered to, so go find the other copy ($C from §Scope):
# WORKDIR is whatever this add-on's Dockerfile sets, so resolve it rather than hardcoding
ssh <alias> "sudo docker exec $C sh -c 'wc -c \"\$(pwd)/<file>.py\"'"
ssh <alias> 'wc -c /addons/<slug>/<file>.py' # what you delivered
# every manifest on the box, wherever the local store actually lives on this install
ssh <alias> "sudo docker inspect $C --format '{{range .Mounts}}{{.Source}}{{\"\n\"}}{{end}}'"
"Supervisor can read the correct version off disk" and "Supervisor is using that copy" are two different claims — verifying only the first is what makes this bug look unsolvable.
3. Install
ssh <alias> 'bash -lc "ha store reload && ha apps install local_<slug>"'
# First build takes minutes (longer with heavy deps like onnxruntime/opencv);
# the build runs in the Supervisor background, so a dropped SSH session doesn't matter
ssh <alias> 'bash -lc "ha apps info local_<slug> | grep -E \"state:|version:\""' # a version line = installed
4. Configuration (add-on options)
⚠️ ha apps has no options subcommand — pick one of two routes:
- HA web UI (most reliable): add-on → Configuration tab → fill in and save.
Secrets (tokens, push topics) always go here — never
.env, never the repo. - Supervisor REST API (scriptable):
echo '{"options":{...complete object...}}' \ | ssh <alias> 'bash -lc "curl -sS -X POST -H \"Authorization: Bearer \$SUPERVISOR_TOKEN\" \ -H \"Content-Type: application/json\" -d @- http://supervisor/addons/local_<slug>/options"'
⚠️ This POST replaces the whole options object — it is not a patch. Sending only the
one field you want to change (e.g. {"options":{"some_token":"..."}}) drops every other
option, and Supervisor rejects the result if any required field is now missing:
"App local_<slug> has invalid options: Missing option '<required_option>' in root...".
To add or change a single field, GET the current full object first, merge in your one
change locally, then POST the complete merged object back — never hand-write a partial
one:
ssh <alias> 'bash -lc "curl -sS -H \"Authorization: Bearer \$SUPERVISOR_TOKEN\" \
http://supervisor/addons/local_<slug>/info"' > opts.json # full current options
python3 - <<'EOF'
import json
d = json.load(open('opts.json'))['data']['options']
d['some_token'] = 'new-value'
json.dump({'options': d}, open('payload.json', 'w'))
EOF
chmod 600 opts.json payload.json # password-typed options come back in plaintext — see below
ssh <alias> 'bash -lc "curl -sS -X POST -H \"Authorization: Bearer \$SUPERVISOR_TOKEN\" \
-H \"Content-Type: application/json\" -d @- http://supervisor/addons/local_<slug>/options"' < payload.json
Note: the info endpoint returns password-typed schema fields in plaintext, unmasked
— that's exactly what makes this merge possible, but also why both files are chmod 600
before the POST, and why this whole exchange should be treated as touching real secrets (don't
echo the merged object to a log or chat transcript).
After POSTing, keep the pre-change opts.json as the rollback copy while you re-GET info
and check the field you didn't intend to touch (e.g. a refresh token) is still non-empty — a
wrong merge silently wiping a rotating credential is a much worse failure than a rejected POST,
and opts.json is the only place its old value survives. If it was wiped, POST
{"options": <opts.json's data.options>} back the same way. Once it checks out,
rm -f opts.json payload.json.
The schema supports a list of dicts (lets the user add/remove entries in the UI, e.g. monitoring targets):
options:
targets:
- name: example
date: "2026-01-01"
schema:
targets:
- name: str
date: str
⚠️ The schema only declares types — format validation is the app's job at startup; report which entry is wrong and why, then fail the start (so the user sees it in the log immediately).
5. Start and Verify
ssh <alias> 'bash -lc "ha apps start local_<slug>"'
ssh <alias> 'bash -lc "ha apps logs local_<slug>"'
Testing inside the container (e.g. manually trigger one push notification):
ssh <alias> "sudo docker exec $C python3 -c '…'"
# ⚠️ the SSH add-on user has no docker socket permission — sudo required
# ⚠️ $C must come from the resolver in §Scope — never hardcode app_local_/addon_local_,
# and never skip its empty check (an empty $C makes docker read the next arg as the
# container name, giving a "no such container: python3" that looks unrelated)
⚠️ docker exec needs Protection mode off on the SSH add-on (§0); if it's on, this whole
byte-fingerprint/exec-based verification path may be unavailable to you. If you're an
autonomous coding agent, don't assume you can just flip the toggle yourself — turning
Protection mode off is a Supervisor security-setting change (via the same options API as
§4), and a permission-conscious agent harness may (correctly) refuse to let you toggle it
without the user watching. Don't fight this by finding a workaround; treat it as a real
boundary. Fall back to log-based verification instead — it's usually sufficient:
ha apps logs local_<slug> after start/update should show the new version's log strings
(a line, a phrase, a startup banner) that only exist in the code you just delivered, plus a
grep -iE "error|traceback|exception|Errno|Fatal" with no hits. If the deploy also added a
reachable endpoint, hitting it for real (see §6's version-check philosophy — trust behavior
you can observe over inference) is stronger evidence than a static byte count anyway.
6. Update (⚠️ pick one of three — the wrong one gets rejected)
| What changed | Command |
|---|---|
| Options only (no code change) | ha apps restart local_<slug> |
| Code changed, config.yaml version not bumped | redo §2 delivery → ha apps rebuild local_<slug> |
| Code changed, version bumped | redo §2 delivery → ha store reload → ha apps update local_<slug> (rebuild is rejected here: "Local and store versions differ, use Update instead of Rebuild") |
⚠️ For code updates, always bump version and take the update route. Rebuild
leaves the device reporting the old version while running new code — ha apps info
can no longer tell which code the box is actually running, which hurts later
debugging. Reserve rebuild for rapid iterate-and-test cycles where the version
trail doesn't matter yet.
⚠️ Always confirm the store actually saw the bump before running update — this is the one check that turns a day-long mystery into a two-minute fix:
ssh <alias> 'bash -lc "ha apps info local_<slug> | grep -E \"^(version|version_latest|update_available):\""'
version_latest must equal the version you just delivered. If it still shows the old one after
ha store reload, stop — do not start rebuilding or reinstalling. Supervisor is reading a
different directory that declares your slug: go to §2's "Two directories must never declare
the same slug".
7. Persistence and Logs
/data/is the persistent volume (host side:/data/apps/data/local_<slug>/): DB, logs and tokens all live here and survive rebuild/update.- Log to both stdout (for
ha apps logs) and/data/logs/(rotating files). - ⚠️ Never print secrets in the startup log (
ha apps logsis visible to anyone who can open HA) — print on/off status only.
8. SSH Prerequisites (only when touching a new HA host for the first time)
Set an alias in the workstation's ~/.ssh/config (HostName, User, IdentityFile);
add the public key to the Advanced SSH & Web Terminal add-on config (YAML mode):
ssh:
username: <account>
authorized_keys:
- >-
ssh-ed25519 AAAA... public key
⚠️ If the saved config keeps reverting, the usual causes are private key file
permissions too loose (chmod 600) or the YAML nested at the wrong level.
Your SSH lands in the SSH add-on container (not the HAOS host, not the HA core
container); sudo works there.