Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
infra: Kvitt infrastructure
This file covers the development stack on the owner's Mac (infra/dev/, milestone M2c-dev, D-001), the
Firebase setup (SPEC §10.7) and the Pi stack (infra/pi/, milestone M2c-pi, D-100): a self-contained
bundle the owner copies to the Kvitt Pi and runs there. No agent and no Mac script connects to the Pis; the owner
runs every Pi command from the checklist in "Pi: what the owner does".
Layout
| Path | What |
|---|---|
dev/compose.yaml |
Dev stack: server (image kvitt-server:dev) and the on-demand backup job |
dev/.env.example |
Dev settings, no secret values; copy to dev/.env (git-ignored) to override |
dev/build.sh |
Builds kvitt-server:dev from server/Dockerfile and kvitt-backup:dev from backup/Dockerfile |
dev/up.sh |
Starts the server, waits for /health, prints the Mac and phone URLs; --down stops the stack |
dev/logs.sh |
Follows the logs (dev/logs.sh --tail 100 server) |
dev/backup.sh |
Runs the backup job once and checks the snapshot with PRAGMA integrity_check |
dev/copy-google-services.sh |
Copies secrets/google-services.json to android/app/google-services.json |
dev/lib.sh |
Helpers sourced by the scripts |
dev/secrets/ |
Git-ignored: only fcm-service-account.json, copied there by dev/up.sh and mounted read-only at /secrets |
backup/Dockerfile, backup/backup.sh, backup/healthcheck.sh |
Backup image and job of SPEC §10.9 (shared with the Pi stack); Drive upload and D-002 deletion |
cert-sync/Dockerfile, cert-sync/sync.sh, cert-sync/entrypoint.sh |
Certificate sync image of SPEC §10.5.1 |
pi/ |
The Pi bundle (copied to the Kvitt Pi): compose.yaml, Caddyfile, .env.example, install.sh, deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh, lib.sh; git-ignored pi/.env, pi/secrets/, pi/out/ |
build-images.sh |
Mac fallback for the image build: linux/arm64, push to the Gitea registry |
publish-apk.sh |
Mac: release APK, mapping and latest.json into pi/out/apk/ for the owner to copy to the Pi |
RESTORE.md |
Restore from a Drive backup, step by step (SPEC §10.9) |
../.gitea/workflows/build-images.yaml |
Gitea Actions image build (the normal path) |
secrets/ |
Git-ignored: fcm-service-account.json, google-services.json, ha_ssh_key(.pub), rclone.conf, optional registry.env. Never committed |
All Mac commands below run from the repository root of the main checkout (/Users/jhag/projects/kvitt).
Development stack
Choices. No Caddy and no TLS: debug builds accept http:// to private LAN addresses (AC-2.8), so the server
publishes its own port (default 8080) on all of the Mac's interfaces. The server keeps the hardening of SPEC §8.9
(uid 10001, read-only root, no capabilities, no-new-privileges), the 160 MiB limit, json-file logging
(10 MB x 3) and TZ=Europe/Stockholm. Data lives in the bind-mounted folder infra/dev/data/ (git-ignored) so it
is easy to inspect and reset. Only the FCM key reaches the container (D-117): up.sh copies
${KVITT_SECRETS_DIR}/fcm-service-account.json (default infra/secrets/) into infra/dev/secrets/ (git-ignored,
nothing else in it) and that folder is mounted read-only at /secrets, so the server never sees the Home Assistant
SSH key, rclone.conf, the registry credentials or google-services.json. If the key is missing the folder is
empty and the server runs with push disabled (§10.4). Run up.sh once before the bare docker compose commands
below (they mount the same folder); after rotating the key, run up.sh again.
On this Mac docker is Podman (docker compose runs podman-compose); the files work with Docker Desktop as well.
Build
infra/dev/build.sh # both images
infra/dev/build.sh server # only kvitt-server:dev; needs server/Dockerfile
infra/dev/build.sh backup # only kvitt-backup:dev
The server build is docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server
(override the context with KVITT_SERVER_BUILD_CONTEXT). The script refuses to run while a Gradle build holds
/tmp/kvitt-gradle.lock (CLAUDE.md).
Start, stop, logs
infra/dev/up.sh # start; prints health, the Mac URL and the phone URL
infra/dev/logs.sh # follow logs (Ctrl-C to stop following)
infra/dev/up.sh --down # stop and remove the containers (data stays in infra/dev/data/)
KVITT_PUBLIC_URL (used in invite links) is left empty in .env.example; up.sh sets it to
http://<Mac LAN address>:<port>. Pin it in infra/dev/.env if the Mac's address changes often.
Finding the Mac's LAN address
up.sh prints it. By hand: ipconfig getifaddr en0 (Wi-Fi on most Macs), or the interface of the default route:
route -n get default | grep interface, then ipconfig getifaddr <interface>. It must be a private address
(192.168.*, 10.*, 172.16.* to 172.31.*) for a debug build to accept http:// (AC-2.8).
The first time a phone connects, macOS may ask whether the container runtime (Podman's gvproxy, or Docker)
may accept incoming network connections: answer Allow.
Bootstrap and pairing a debug build
The bootstrap and pair-device subcommands (SPEC §10.4, AC-1.1, AC-1.6) arrive with the server milestones.
C="docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example" # or --env-file infra/dev/.env
$C exec server kvitt-server bootstrap # first admin invite: code, link, expiry
$C exec server kvitt-server pair-device --user <user_id> # extra device invite for an existing user
$C exec server kvitt-server invite-partner # partner invite by the admin, as E-12 (D-113)
$C run --rm server migrate # when the server is stopped (entrypoint is kvitt-server)
On the phone (home Wi-Fi, debug build se.hagstedt.kvitt.debug): open the pairing screen, enter the server URL
http://<Mac LAN address>:8080 and the printed code (AC-2.7). Check reachability first by opening
http://<Mac LAN address>:8080/health in Chrome on the phone: it shows "status":"ok".
Backup (local, no Drive upload)
infra/dev/backup.sh
Runs infra/backup/backup.sh once in the backup image (compose profile backup, so up never starts it) with
RCLONE_FOLDER_ID empty: .backup snapshot, PRAGMA integrity_check must print ok, then
infra/dev/backups/kvitt-<UTC stamp>.sqlite.gz, pictures-<stamp>.tar.gz and last-success; the Drive upload
is skipped with a log line and the files are kept (SPEC §10.9, D-001). The wrapper then checks the new snapshot
again. The dev env file has no rclone settings (RCLONE_REMOTE is no longer in dev/.env.example): only the Pi's
.env names the remote. To check by hand: gunzip -c infra/dev/backups/kvitt-<stamp>.sqlite.gz > /tmp/k.sqlite && sqlite3 /tmp/k.sqlite "PRAGMA integrity_check".
Resetting the dev data
infra/dev/up.sh --down
rm -rf infra/dev/data # the dev database, pictures and APKs; never the phones' data
infra/dev/up.sh # fresh database; run bootstrap again
Paired debug builds then hold a token the new database does not know: re-pair them (clear the debug app's data on the phone, or reinstall the debug build).
Worktrees
Agents work in .claude/worktrees/agent-<id>, which has no infra/secrets/ unless copied (CLAUDE.md). Either copy
it from the main checkout, or set KVITT_SECRETS_DIR=/Users/jhag/projects/kvitt/infra/secrets in infra/dev/.env.
infra/dev/copy-google-services.sh /Users/jhag/projects/kvitt copies the Firebase config from the main checkout
into the worktree's android/app/.
Firebase (SPEC §10.7)
Project kvitt-1337 (Firebase added to the existing Google Cloud project). Free tier only; nothing billable is
created by this setup.
| Resource | Name |
|---|---|
| APIs enabled | firebase.googleapis.com, fcm.googleapis.com, cloudresourcemanager.googleapis.com, iam.googleapis.com |
| Android app | "Kvitt", package se.hagstedt.kvitt |
| Android app | "Kvitt debug", package se.hagstedt.kvitt.debug |
| Service account | kvitt-fcm@kvitt-1337.iam.gserviceaccount.com ("Kvitt FCM sender"), role roles/firebasecloudmessaging.admin (the narrowest predefined role holding cloudmessaging.messages.create) |
| Key | infra/secrets/fcm-service-account.json (git-ignored, mode 600) |
| Config | infra/secrets/google-services.json, one file with both Android clients (git-ignored) |
Put the config where Gradle reads it: infra/dev/copy-google-services.sh.
The setup used only gcloud and the Firebase Management REST API. To repeat or inspect it:
T=$(gcloud auth print-access-token)
H=(-H "Authorization: Bearer $T" -H "x-goog-user-project: kvitt-1337")
API=https://firebase.googleapis.com/v1beta1/projects/kvitt-1337
curl -sS "${H[@]}" "$API/androidApps" # list the apps (ids, packages)
curl -sS "${H[@]}" "$API/androidApps/<appId>/config" # configFileContents is base64 google-services.json
gcloud iam service-accounts keys list --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337
Rotating the key: create a new one with
gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337,
restart the server, then delete the old key id with gcloud iam service-accounts keys delete <key id> ....
Copying the key to the Pi (§10.7 step 7, mode 0440, group 10001) is done by pi/install.sh ("Pi: what the owner does", step 6).
Pi stack (SPEC §10.1 to §10.10, D-100)
How it fits together
| Where | What runs | Who runs it |
|---|---|---|
Gitea Actions (runner label host, a Raspberry Pi) |
.gitea/workflows/build-images.yaml: builds kvitt-server, kvitt-cert-sync, kvitt-backup for linux/arm64, pushes them to gitea.lan/jacobh2/... tagged vMAJOR.MINOR.PATCH and latest, pushes the git tag |
Gitea, on a push to server/**, infra/cert-sync/**, infra/backup/**, .gitea/workflows/**, or by hand (Actions, "Run workflow") |
| Owner's Mac | infra/build-images.sh [tag] (fallback image build), infra/publish-apk.sh (release APK into infra/pi/out/apk/) |
The owner (or an agent, for the APK build only) |
Kvitt Pi, /opt/kvitt/ |
compose.yaml, Caddyfile, .env, secrets/, import/, apk/, and the scripts deploy.sh, bootstrap.sh, backup-now.sh, install-apk.sh (copied there by install.sh) |
The owner, in their own SSH session |
| Home Assistant Pi | Its own certbot renews icehack.asuscomm.com; cert-sync reads the two PEM files over SSH with a dedicated key |
Untouched apart from one authorized_keys entry |
Stack (pi/compose.yaml, project name kvitt): caddy (publishes 8443, the only published port), server,
cert-sync, backup; volumes data, certs, caddy_data, backups; network kvitt; every service
restart: unless-stopped, json-file logs 10 MB x 3, TZ=Europe/Stockholm. Images are
${KVITT_REGISTRY}/kvitt-server|kvitt-cert-sync|kvitt-backup:${KVITT_TAG} with KVITT_REGISTRY=gitea.lan/jacobh2
(D-104; the names the workflow pushes).
Versions (resolved 2026-10-18)
| Component | Version | Source |
|---|---|---|
| Caddy | caddy:2.11.7@sha256:685ae3d7c34d9b5f8caeddc5d14051db7bbd88ff1f5a2fa0c16bd8ca328fb7af (multi-arch index; has linux/arm64/v8) |
Docker Hub official image; tag 2.11.7 = 2.11 = latest, pushed 2026-10-14 |
| Alpine (cert-sync, backup) | alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 |
Docker Hub official image; 3.24 = latest |
| cert-sync packages (Alpine v3.24) | openssh-client-default 10.3_p1-r1, curl 8.22.0-r0, openssl 3.5.9-r0, supercronic 0.2.49-r1, tzdata 2026e-r0 | apk policy in the pinned image |
| backup packages (Alpine v3.24) | sqlite 3.53.4, rclone 1.74.1, tar 1.35, tzdata 2026e (M2c-dev), supercronic 0.2.49-r1 | apk policy in the pinned image |
| Docker Engine on the Pi | Docker's Debian apt repository, arm64 packages (docker-ce 5:29.8.2 current): docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin |
docs.docker.com/engine/install/raspberry-pi-os/: that page is for 32-bit (armhf) only; 64-bit Raspberry Pi OS uses the Debian instructions (docs.docker.com/engine/install/debian/) |
Why supercronic, not busybox crond (D-104). Both sidecars run as non-root users (cert-sync uid 1000, backup
uid 10001). Busybox crond started as a non-root user in alpine 3.24.2 never ran a job (tested 2026-10-18: an
every-minute job produced no output in two minutes). supercronic is a cron for containers that runs jobs as the
user it runs as and honours TZ; the schedules are unchanged (0 4 * * * cert-sync, 30 3 * * * backup).
Backup uid (D-105, SPEC §10.2 "M2c-pi reconciles the two")
backup runs as uid 10001, the server's uid, on the Pi as on the Mac: sqlite3 .backup on a WAL database
must open (and write) kvitt.sqlite-shm, which the server creates as 10001 with mode 0644, so uid 1000 could not
back up the live database without loosening the server's files. Therefore /opt/kvitt/secrets/rclone.conf is
mode 0600 owned by 10001:10001 (not 1000). cert-sync stays uid 1000 with ha_ssh_key and ha_known_hosts
mode 0600 owned by 1000:1000 (ssh refuses a key not owned by its user or readable by others). rclone writes
refreshed access tokens back to its config; the mounted file is read-only, so backup.sh works on a private copy
in /tmp each run. install.sh gives the named volumes their owners once (/data, /data/apk, /backups to
10001, /certs to 1000 with mode 0750, Caddy's /data to 10002) with one-off root containers
(docker compose run --user 0:0 --cap-add CHOWN, plus FOWNER for the chmod).
Caddy uid (D-117). Caddy runs as uid 10002 with the supplementary group 1000 (cert-sync's group): the
certificate files are 0640 1000:1000 and /certs is 0750 1000:1000, so cert-sync writes and Caddy only reads.
Read-only root filesystem, no-new-privileges, cap_drop: [ALL] with cap_add: [NET_BIND_SERVICE], /config
and /tmp as tmpfs, caddy_data writable by 10002. Port 8443 needs no capability, but the official image's
/usr/bin/caddy has the file capability cap_net_bind_service=ep, and the kernel refuses to exec it when that
capability is outside the bounding set ("exec container process /usr/bin/caddy: Operation not permitted" with
cap_drop: [ALL] alone, checked 2026-10-18), so it is the one capability kept.
What was checked on the Mac (2026-10-18)
shellcheckon everyinfra/**/*.sh: clean.podman-compose -f infra/pi/compose.yaml --env-file <filled sample> config: exit 0.cert-syncandbackupimages built for linux/arm64 with Podman (not pushed).- End-to-end rehearsal with Podman: a stand-in Home Assistant sshd with the variant A key below, the cert-sync
image as uid 1000 with
StrictHostKeyChecking=yes, the pinned Caddy withpi/Caddyfilein front of a stand-in server. The first fetch installed both files (0640);GET /healththrough Caddy answered over TLS; a renewed certificate was installed and Caddy reloaded through its admin API without a restart; an unchanged certificate was left alone; an older certificate and a key that does not match the certificate were refused with the old pair kept; TLS 1.1 was refused. Caddy logged the request headers with"Authorization":["REDACTED"]and the token never appeared in its log (SPEC §10.5.2). - Backup rehearsal with a local rclone remote: upload,
lsfconfirmation by name and size, local deletion only after both files were confirmed (D-002); files kept andlast-successunchanged when the upload failed; the leftovers uploaded by the next good run; healthcheck green on a fresh start and after a run, red at 26 h. - Caddy hardening rehearsal (D-117, 2026-10-18, Podman, pinned Caddy image,
pi/Caddyfile, a self-signed pair in acertsvolume set up asinstall.shand cert-sync do, a stand-in upstream namedserver): Caddy runs asuid=10002 gid=10002 groups=1000,10002,NoNewPrivs: 1, capability sets0x400(NET_BIND_SERVICE only), the root filesystem refuses writes, the healthcheck command succeeds, memory about 12 MB.GET /i/<code>?from=<name>with a bearer header answered 200 withContent-Security-Policy,X-Content-Type-Options: nosniff,X-Frame-Options: DENYand oneReferrer-Policy: no-referrer(the upstream sent it too). The access log showed"uri":"/i/REDACTED?REDACTED"and"/sync/pull?REDACTED","Authorization":["REDACTED"]; with the upstream stopped (502) Caddy's error log carried/i/REDACTED?REDACTEDtoo; the code, the name,kvt_and the query value occurred 0 times in either log. A reload through the admin API from another container (POST /load, as cert-sync does) succeeded. TLS 1.1 refused. - Not checked (needs the real hosts): the Home Assistant add-on's handling of
authorized_keysoptions, the Gitea workflow (not yet run), pulls fromgitea.lan, the router forward, Drive.
Gitea Actions workflow (.gitea/workflows/build-images.yaml), not yet run
Same setup as the owner's working example: runs-on: host, sh steps, apk add git docker-cli docker-cli-buildx,
docker login gitea.lan with the repository secret kvitt_cicd (created by the owner) and ${{ github.actor }},
a buildx builder whose buildkitd.toml sets http = true for gitea.lan (created once and reused, so its cache
survives), actions/checkout@v4 with set-safe-directory and fetch-depth: 0, the version from the newest
v1.0.* git tag plus one, and the git tag pushed after the build. That version is what deploy.sh takes.
The token reaches docker login through an environment variable, never as text in the script. kvitt_cicd needs
only the write:package scope: the tag push uses the credentials actions/checkout stored (the job's own token).
If the "Tag and Push Version" step fails with a permission error, the job token cannot push tags on this Gitea;
then add write:repository to the token (owner decision below). The step "Print image digests" prints each
image's sha256: digest, which must match what deploy.sh prints on the Pi (D-117).
The builder's buildkitd.toml is read only when the builder is created. After changing the registry setting in the
workflow, remove the builder once on the runner Pi: docker buildx rm local-http-builder (this also drops its
build cache, so the next server build is a full one).
Risk: the runner is a Raspberry Pi 3 with 1 GB RAM. The server image compiles about 232 Rust crates,
aws-lc-sys's C code among them. The workflow passes --build-arg CARGO_BUILD_JOBS=1; it takes effect only once
server/Dockerfile declares ARG CARGO_BUILD_JOBS in its build stage (requested from the server's owner; until
then buildx warns about an unused build argument and cargo uses all four cores, which may run out of memory). The
Dockerfile already keeps the cargo registry and target directory in BuildKit cache mounts, so only the first build
is a full one. The first build's duration on the Pi is unknown (a cold arm64 build takes 48 s on the M4 Mac). If
it fails for memory, add swap on the runner Pi, or build on the Mac with infra/build-images.sh.
Mac fallback: infra/build-images.sh [tag]
Builds the same three images for linux/arm64 and pushes them when infra/secrets/registry.env exists
(KVITT_REGISTRY=gitea.lan/jacobh2, optional KVITT_REGISTRY_USER and KVITT_REGISTRY_TOKEN with
write:package); otherwise it builds locally and prints the exact push commands. Tag: the short git SHA by default,
plus latest. Refuses a dirty tree and does not build while a Gradle build holds the gradle-serial lock. With
Podman (this Mac) it pushes with --tls-verify=false; with Docker it creates a buildx builder that allows plain
HTTP to the registry.
Publishing an APK: infra/publish-apk.sh
Version rule (SPEC §10.6): version_code = git rev-list --count HEAD, version_name = YYYY.MM.DD-<short sha>
of HEAD's commit date (Europe/Stockholm), passed as -Pkvitt.versionCode and -Pkvitt.versionName. The image
tags of the Gitea workflow (v1.0.N) are separate and say nothing about the APK. The script refuses a dirty tree,
builds :app:assembleRelease through tools/gradle-serial.sh (JDK 25 on PATH), refuses an unsigned APK, and
writes into infra/pi/out/apk/: kvitt-<version_code>.apk, kvitt-<version_code>.mapping.txt (the R8 mapping,
for reading stack traces; never served) and latest.json (temp file, then rename); it keeps the newest 5 APKs
with their mappings. Then the owner, on the Mac:
cd /Users/jhag/projects/kvitt
scp infra/pi/out/apk/* <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:/opt/kvitt/apk/
ssh <KVITT_PI_SSH_USER>@<KVITT_PI_HOST> /opt/kvitt/install-apk.sh
install-apk.sh checks the SHA-256 against latest.json, copies the APKs into the data volume (/data/apk/)
and latest.json last with a rename, keeps the newest 5 there and prints GET /apk/latest.
Pi: what the owner does
Fill these in first (in infra/pi/.env on the Mac; the bundle carries the file to the Pi):
Placeholder in infra/pi/.env |
What | Where to find it |
|---|---|---|
KVITT_PI_HOST |
LAN address of the Kvitt Pi (give it a static DHCP lease) | ASUS router web UI, LAN, DHCP Server, client list |
KVITT_PI_SSH_USER |
Your user on the Kvitt Pi | The user set in Raspberry Pi Imager |
HA_HOST |
LAN address of the Home Assistant Pi | Router client list, or Home Assistant, Settings, System, Network |
HA_PORT |
SSH port of the SSH add-on (default 22) | Home Assistant, Settings, Add-ons, the SSH add-on, Configuration, Network |
HA_USER |
SSH user of the add-on (root for "Terminal & SSH"; the configured username for "Advanced SSH & Web Terminal"); root only with the restricted key of step 3, otherwise a dedicated non-root user |
Same add-on, Configuration |
HA_CERT_DIR |
Directory holding fullchain.pem and privkey.pem on the Home Assistant Pi (/ssl on Home Assistant OS) |
In the add-on's terminal: ls -l /ssl |
KVITT_TAG |
Image tag to run: the newest v1.0.N the Gitea workflow pushed (or the SHA from build-images.sh) |
Gitea, the repository, Packages, kvitt-server |
KVITT_REGISTRY_USER, KVITT_REGISTRY_TOKEN |
Only if the Gitea packages are private: a Gitea user and a token with read:package |
Gitea, Settings, Applications, Generate New Token |
KVITT_REGISTRY |
Already gitea.lan/jacobh2 |
|
RCLONE_REMOTE, RCLONE_FOLDER_ID |
Already gdrive: and 1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV |
1. Provision the Kvitt Pi
Raspberry Pi Imager on the Mac: Raspberry Pi 3, Raspberry Pi OS Lite (64-bit); in its settings set the host
name, your user and password, the network, time zone Europe/Stockholm, and enable SSH with your public key. Boot
it, give it a static DHCP lease in the router, then from the Mac: ssh <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>.
2. Install Docker Engine on the Pi (docs.docker.com, Debian arm64 repository)
On the Pi:
sudo apt update && sudo apt install -y ca-certificates curl python3
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
exit
Log in again; docker compose version prints a version and docker run --rm hello-world prints
"Hello from Docker!".
3. Put the cert-sync key on the Home Assistant SSH add-on
Home Assistant, Settings, Add-ons, your SSH add-on, Configuration, authorized_keys: add one new entry, the
restricted key (variant A; replace /ssl if your HA_CERT_DIR differs):
restrict,command="tar -C /ssl -cf - fullchain.pem privkey.pem" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMiXj2dU8GU0Xmdj91WxLhVpPOI1zRWvBF5h2MbE4S8V kvitt-cert-sync
Optionally also limit it to the Kvitt Pi: start the line with restrict,from="<KVITT_PI_HOST>",command=....
Save, restart the add-on; its Log tab shows no error about the key.
A plain key for root (the old variant B) is not acceptable (D-117). The Home Assistant SSH add-ons log in as
root, and without restrict,command= whoever holds this key (a compromised cert-sync container, or a copy of the
key on the Mac or the Pi) gets an unrestricted root shell on the Home Assistant Pi, which also owns ports 80/443
and the house's automation. If the add-on refuses the restricted line, use a dedicated non-root user instead: a
user with no sudo that can only read HA_CERT_DIR (set HA_USER to it), with the plain key line in that user's
authorized_keys. If neither is possible, stop and tell the orchestrator; the fallback is Caddy's internal CA
(SPEC §10.5.3). cert-sync works unchanged with both: it always asks for exactly that tar command. Tell the
orchestrator which one the add-on accepted (restricted root key, or the dedicated user), so it is recorded here.
The private key is /Users/jhag/projects/kvitt/infra/secrets/ha_ssh_key (git-ignored; never paste it anywhere).
4. Fill the .env and stage the secrets, on the Mac
cd /Users/jhag/projects/kvitt
cp infra/pi/.env.example infra/pi/.env
open -e infra/pi/.env # replace every CHANGE_ME (table above), save
mkdir -p infra/pi/secrets
cp infra/secrets/fcm-service-account.json infra/secrets/ha_ssh_key infra/secrets/rclone.conf infra/pi/secrets/
rclone.conf is already generated from your rclone authorize "drive" (remote [gdrive], type = drive,
scope = drive, the token); the Drive folder id is RCLONE_FOLDER_ID in .env, not in the conf.
infra/pi/.env and infra/pi/secrets/ are git-ignored.
5. Copy the bundle to the Pi and record the Home Assistant host key
On the Mac:
scp -r infra/pi <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:kvitt-pi
rm -r infra/pi/secrets # the originals stay in infra/secrets/
On the Pi:
cd ~/kvitt-pi
ssh-keyscan -p <HA_PORT> <HA_HOST> > secrets/ha_known_hosts
wc -l secrets/ha_known_hosts # at least 1 line
6. Install and start
On the Pi:
cd ~/kvitt-pi && ./install.sh
It creates /opt/kvitt/{secrets,import,apk}, installs the four secret files (fcm-service-account.json 0440
root:10001; ha_ssh_key, ha_known_hosts 0600 1000:1000; rclone.conf 0600 10001:10001), adds gitea.lan to
insecure-registries in /etc/docker/daemon.json (and restarts Docker if that changed), copies compose.yaml,
Caddyfile, .env (0600) and the scripts to /opt/kvitt, pulls, starts and waits. Expected end:
server: healthy, cert-sync: healthy, caddy: healthy, a docker compose ps table, the line
GET https://icehack.asuscomm.com:8443/health (via this Pi): {"status":"ok",...}, then "Kvitt is running.".
Then rm -r ~/kvitt-pi/secrets (the script reminds you). Running install.sh again is safe. Re-running
install.sh keeps the KVITT_TAG that deploy.sh set; change the tag only with deploy.sh <tag>.
7. Router forward
ASUS router web UI, WAN, Virtual Server / Port Forwarding: add a rule, protocol TCP, external port 8443, internal
IP <KVITT_PI_HOST>, internal port 8443; Apply. Then on the phone with Wi-Fi off, open
https://icehack.asuscomm.com:8443/health in Chrome: it shows "status":"ok" without a certificate warning (yes/no).
8. First backup and bootstrap
On the Pi:
/opt/kvitt/backup-now.sh # ends with "last-success: <stamp>" and the newest kvitt-*.sqlite.gz and pictures-*.tar.gz on Drive
/opt/kvitt/bootstrap.sh # first install only: prints the admin invite code, link and expiry
9. Deploying a new version
After the Gitea workflow pushed v1.0.N (or infra/build-images.sh printed a tag), on the Pi:
/opt/kvitt/deploy.sh v1.0.N
It backs up first (Drive confirmed) on every deploy, because any tag may carry a migration (SPEC §10.10), sets
KVITT_TAG, pulls, prints the sha256: digest of every image and appends it to /opt/kvitt/deployed-digests,
starts, waits for server: healthy and prints /health. The images come over plain HTTP from gitea.lan
(D-001), so compare the printed digests with the workflow's "Print image digests" step for that run; a line
starting WARNING: means a tag that was deployed before now points at a different image: do not continue, roll
back and find out why (D-117). If the server does not come up:
/opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup; if a migration had partially applied,
restore the pre-deploy backup (infra/RESTORE.md). A newer bundle with a changed compose.yaml or Caddyfile is
installed by copying the bundle again (step 5, without the secrets) and running ./install.sh.
Fallback: Caddy internal CA (SPEC §10.5.3, not deployed)
Only if cert-sync cannot be made to work. In /opt/kvitt/Caddyfile replace the line
tls /certs/fullchain.pem /certs/privkey.pem with tls internal, then docker compose restart caddy in
/opt/kvitt. Copy Caddy's root to the Mac once: on the Pi
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/kvitt_root_ca.crt, then on the Mac
scp <KVITT_PI_SSH_USER>@<KVITT_PI_HOST>:/tmp/kvitt_root_ca.crt android/app/src/release/res/raw/kvitt_root_ca.crt
(a public certificate, committed by an Android agent) and set kvitt.tls.pinnedRoot=true in
android/gradle.properties (D-082). The root is valid 10 years. Caddy still waits for cert-sync to be healthy,
which needs the two files in the certs volume: keep the last fetched pair there, or remove the cert-sync
condition from depends_on in /opt/kvitt/compose.yaml.
Operations (SPEC §10.10), on the Pi in /opt/kvitt
| Task | Command |
|---|---|
| Status | docker compose ps |
| Logs | docker compose logs --tail 100 <caddy, server, cert-sync or backup> |
| Backup now | /opt/kvitt/backup-now.sh |
| Upgrade | /opt/kvitt/deploy.sh <tag> |
| Roll back | /opt/kvitt/deploy.sh "$(cat /opt/kvitt/previous-tag)" --skip-backup |
| Certificate now | docker compose exec cert-sync /usr/local/bin/sync.sh |
| Certificate expiry and issuer | docker compose exec cert-sync openssl x509 -in /certs/fullchain.pem -noout -enddate -issuer |
| Extra device invite | docker compose exec server kvitt-server pair-device --user <user_id> |
| Memory | docker stats --no-stream (server RSS under 100 MB with both phones syncing) |
| Disk | df -h / and docker system df; SQLite a few MB, pictures under 1 MB, APKs about 10 MB each since R8, 5 kept |
| No token in Caddy's log | docker compose logs caddy | grep -c 'kvt_' prints 0 |
| No invite code or name in Caddy's log | docker compose logs caddy | grep '"uri":"/i/' | grep -vc '/i/REDACTED' prints 0 |
| Image digests deployed | tail -n 8 /opt/kvitt/deployed-digests |
| Client IPs are real (once, after the router forward) | open https://icehack.asuscomm.com:8443/health on the phone with Wi-Fi off, then docker compose logs --tail 20 caddy | grep -o '"remote_ip":"[^"]*"': the address is your mobile network's public address, not 172.* or 10.*. If every request shows the Docker bridge gateway, all internet clients share one per-IP rate limit (SPEC §8.6): tell the orchestrator |
| Restore | infra/RESTORE.md |
APK downloads (security review L-3). GET /apk/files/<name> is public (the landing page links it) and about
11 MB per response; the only limit is the general 60 requests per minute per IP of SPEC §8.6, so one stranger can
pull several hundred MB a minute and fill the home uplink. A lower per-IP limit for this path is a server change
and a SPEC number that is not decided yet (owner decision below). Until then, if docker compose logs caddy
shows one address fetching /apk/files/ over and over, remove the router forward (step 7) until it stops.
Owner decisions (open)
- Backup encryption (security review M-1). The Drive backups (
kvitt-*.sqlite.gz,pictures-*.tar.gz) are not encrypted (SPEC §8.7), andrclone.confholds a token with the fulldrivescope (read and write to your whole Drive). Should the backups go through an rclonecryptremote (two passwords kept inrclone.conf, restore through the same remote) and should rclone be re-authorised with the narrowerdrive.filescope? Both change SPEC §8.7 and §10.9. Until you decide, the backup scripts stay as they are. - APK download limit (security review L-3). May
/apk/files/*get its own lower per-IP limit (for example a few downloads per minute per IP)? The number goes into SPEC §8.6 and the server; it is not set here. - cert-sync key (security review M-2, step 3). Which form did the Home Assistant add-on accept: the restricted root key, or a dedicated non-root user?
- Gitea token scope. Does the workflow's tag push work with
kvitt_cicdlimited towrite:package? If not, is addingwrite:repositoryacceptable?
Troubleshooting
cert-syncunhealthy, socaddydoes not start:docker compose logs cert-sync. "Host key verification failed": redossh-keyscan(step 5) and run./install.shagain. "Permission denied (publickey)": the add-on does not have the key (step 3). "did not hold fullchain.pem and privkey.pem": wrongHA_CERT_DIR, orHA_USERcannot readprivkey.pem.- Pull fails with "http: server gave HTTP response to HTTPS client":
gitea.lanis missing from/etc/docker/daemon.json(install.shadds it; check withdocker info | grep -A3 'Insecure Registries'). - Pull fails with "unauthorized": the packages are private; set
KVITT_REGISTRY_USERandKVITT_REGISTRY_TOKENin~/kvitt-pi/.envand run./install.shagain. install.shsays/opt/kvitt/secrets/<file>is a directory: Docker created it because the file was missing at a start;sudo rmdirit, put the file in~/kvitt-pi/secrets/, run./install.sh.